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/core.yml b/.github/workflows/core.yml index 13e7c0dee7..c864c650a7 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -25,6 +25,7 @@ env: internal/api/assets/router.go openapi/v2 pkg/grpc/**/*.pb.* + pkg/grpc/**/*.connect.go jobs: build: diff --git a/.github/workflows/login-container.yml b/.github/workflows/login-container.yml 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/.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/API_DESIGN.md b/API_DESIGN.md index 11b7766a49..cdf43a71df 100644 --- a/API_DESIGN.md +++ b/API_DESIGN.md @@ -135,6 +135,8 @@ message CreateUserRequest { ``` Only allow providing a context where it is required. The context MUST not be provided if not required. +If the context is required but deferrable, the context can be defaulted. +For example, creating an Authorization without an organization id will default the organization id to the projects resource owner. For example, when retrieving or updating a user, the `organization_id` is not required, since the user can be determined by the user's id. However, it is possible to provide the `organization_id` as a filter to retrieve a list of users of a specific organization. diff --git a/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/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/cmd/start/start.go b/cmd/start/start.go index 06f3554a58..9c1e2a4d28 100644 --- a/cmd/start/start.go +++ b/cmd/start/start.go @@ -38,10 +38,12 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/admin" app "github.com/zitadel/zitadel/internal/api/grpc/app/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/auth" + authorization_v2beta "github.com/zitadel/zitadel/internal/api/grpc/authorization/v2beta" feature_v2 "github.com/zitadel/zitadel/internal/api/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/internal/api/grpc/feature/v2beta" idp_v2 "github.com/zitadel/zitadel/internal/api/grpc/idp/v2" instance "github.com/zitadel/zitadel/internal/api/grpc/instance/v2beta" + internal_permission_v2beta "github.com/zitadel/zitadel/internal/api/grpc/internal_permission/v2beta" "github.com/zitadel/zitadel/internal/api/grpc/management" oidc_v2 "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2" oidc_v2beta "github.com/zitadel/zitadel/internal/api/grpc/oidc/v2beta" @@ -59,7 +61,8 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/system" user_v2 "github.com/zitadel/zitadel/internal/api/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/internal/api/grpc/user/v2beta" - webkey "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" + webkey_v2 "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2" + webkey_v2beta "github.com/zitadel/zitadel/internal/api/grpc/webkey/v2beta" http_util "github.com/zitadel/zitadel/internal/api/http" "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/idp" @@ -473,7 +476,7 @@ func startAPIs( if err := apis.RegisterService(ctx, user_v2beta.CreateServer(commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, user_v2.CreateServer(config.SystemDefaults, commands, queries, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { + if err := apis.RegisterService(ctx, user_v2.CreateServer(commands, queries, config.SystemDefaults, keys.User, keys.IDPConfig, idp.CallbackURL(), idp.SAMLRootURL(), assets.AssetAPI(), permissionCheck)); err != nil { return nil, err } if err := apis.RegisterService(ctx, session_v2beta.CreateServer(commands, queries, permissionCheck)); err != nil { @@ -509,18 +512,27 @@ func startAPIs( if err := apis.RegisterService(ctx, project_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, internal_permission_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, userschema_v3_alpha.CreateServer(config.SystemDefaults, commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, user_v3_alpha.CreateServer(commands)); err != nil { return nil, err } - if err := apis.RegisterService(ctx, webkey.CreateServer(commands, queries)); err != nil { + if err := apis.RegisterService(ctx, webkey_v2beta.CreateServer(commands, queries)); err != nil { + return nil, err + } + if err := apis.RegisterService(ctx, webkey_v2.CreateServer(commands, queries)); err != nil { return nil, err } if err := apis.RegisterService(ctx, debug_events.CreateServer(commands, queries)); err != nil { return nil, err } + if err := apis.RegisterService(ctx, authorization_v2beta.CreateServer(config.SystemDefaults, commands, queries, permissionCheck)); err != nil { + return nil, err + } if err := apis.RegisterService(ctx, app.CreateServer(commands, queries, permissionCheck)); err != nil { return nil, err } diff --git a/docs/.gitignore b/docs/.gitignore index bd99d98c6f..e894d20ec6 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -27,3 +27,4 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .vercel +/protoc-gen-connect-openapi* diff --git a/docs/base.yaml b/docs/base.yaml new file mode 100644 index 0000000000..dc5b9aa0f9 --- /dev/null +++ b/docs/base.yaml @@ -0,0 +1,3 @@ +openapi: 3.1.0 +info: + version: v2 \ No newline at end of file diff --git a/docs/buf.gen.yaml b/docs/buf.gen.yaml index a628f6e748..d23040f416 100644 --- a/docs/buf.gen.yaml +++ b/docs/buf.gen.yaml @@ -1,11 +1,18 @@ # buf.gen.yaml -version: v1 +version: v2 managed: enabled: true plugins: - - plugin: buf.build/grpc-ecosystem/openapiv2 + - remote: buf.build/grpc-ecosystem/openapiv2 out: .artifacts/openapi opt: - allow_delete_body - remove_internal_comments=true - preserve_rpc_order=true + - local: ./protoc-gen-connect-openapi/protoc-gen-connect-openapi + out: .artifacts/openapi3 + strategy: all + opt: + - short-service-tags + - ignore-googleapi-http + - base=base.yaml diff --git a/docs/docs/apis/migration_v1_to_v2.mdx b/docs/docs/apis/migration_v1_to_v2.mdx new file mode 100644 index 0000000000..6258b99fd9 --- /dev/null +++ b/docs/docs/apis/migration_v1_to_v2.mdx @@ -0,0 +1,173 @@ +--- +title: Migrate from v1 APIs to v2 APIs +--- + +This guide gives you an overview for migrating from our v1 API to the new and improved v2 API. +This upgrade introduces some significant architectural changes designed to make your development experience smoother, more intuitive, and efficient. + +## The v1 Approach: Use-Case Based APIs +Our v1 API was structured around use cases, meaning we provided distinct APIs tailored to different user roles: + +- Auth API: For authenticated users. +- Management API: For administrators of an organization. +- Admin API: For administrators of an instance. +- System API: For managing multiple instances. + +While this approach served its initial purpose, it presented a few challenges. +Developers often found it difficult to determine which specific API endpoint to use for their needs. +Additionally, this model sometimes led to redundant implementations of similar functionalities across different APIs – for example, listing users might have existed in slightly different forms for an instance context versus an organization context. +This often required more extensive reading of documentation and further explanation to ensure correct usage. + +## The v2 Approach: A Resource-Based API +With our v2 API, we introduce a resource-based architecture. +This means instead of organizing by user type, we now structure our API around logical resources, such as: +- Users API +- Instance API +- Organization API +- And more... + +A key improvement in v2 is how context and permissions are handled. +The data you receive from an endpoint will now automatically be scoped based on the role and permissions of the authenticated user. + +For example: +- An instance administrator calling a GET /users endpoint will receive a list of all users within that instance. +- An organization administrator calling the exact same GET /users endpoint will receive a list of users belonging only to their specific organization. + +## Why the Change +The primary goals behind this architectural shift are to make our API: +- More Intuitive: Finding the right endpoint should feel natural. If you want to interact with users, you look at the Users API. +- Self-Explanatory: The structure itself guides you, reducing the need to sift through extensive documentation to understand which API "hat" you need to wear. +- Developer-Friendly: A cleaner, more consistent API surface means faster integration and less room for confusion. + +We're confident that these changes will significantly enhance your experience working with our platform. +The following sections will detail the specific resources that have been migrated and outline any changes you'll need to be aware of. + +## Resource Migration + +This section details the migrated resources, including any breaking changes and other important considerations for your transition from v1 to v2. + +### General Changes + +#### Sunsetting OpenAPI/REST Support in Favor of Connect RPC +While our v1 API already offered gRPC, it also provided a parallel REST/OpenAPI interface for clients who preferred making traditional HTTP calls. + +In our v2 API, we are consolidating our efforts to provide a more streamlined and efficient development experience. +The primary change is the removal of the OpenAPI/REST interface. +We will now exclusively support interaction with our gRPC services directly or through [Connect RPC](https://connectrpc.com/). + +Connect RPC is being introduced as the new, official way to interact with our gRPC services using familiar, plain HTTP/1.1. +It effectively replaces the previous REST gateway. + +For teams already using gRPC, your transition will be minimal. +For teams who were using the v1 REST API, migrating to v2 will involve adopting one of the following methods: + +- Native gRPC: For the highest performance and to leverage features like bi-directional streaming. +- Connect RPC: For making CRUD-like (Create, Read, Update, Delete) calls over HTTP. This is the recommended path for most clients migrating from our v1 REST API. + +A significant advantage of this new architecture remains the automatic generation of client libraries. +Based on our .proto service definitions, you can generate type-safe clients for your specific programming language, whether you use native gRPC or Connect RPC. +This eliminates the need to write boilerplate code for handling HTTP requests and parsing responses, leading to a more streamlined and less error-prone development process. + +#### Contextual Information in the Request Body + +A key change in v2 is that contextual data, like organization_id, must now be sent in the request body. +Previously, this was sent in the request headers. + +**v1 (Header)** +``` +x-zitadel-orgid: 1234567890 +``` + +**v2 (Request Body)** +``` +{ + "organization_id": "1234567890" +} +``` + +### Instances + +No major changes have been made to the organization requests. + +### Organizations + +No major changes have been made to the organization requests. + +### Users + +When migrating your user management from v1 to v2, the most significant updates involve user states and the initial onboarding process: + +- **Unified User Creation Endpoint**: + - A significant simplification in v2 is the consolidation of user creation. There is now one primary endpoint for creating users, regardless of whether they are human or machine users. + - You can use this single endpoint to provision both human users (individuals interacting with your application) and machine users (e.g., service accounts, API clients), typically by specifying the user type in the request payload. +- **No More "Initial" State**: + - In v1, new users without a password or verified email were automatically assigned an initial state. This default assumption wasn't always ideal. + - In v2, this initial state has been removed. All newly created users are now active by default, regardless of their initial attributes. +- **New Onboarding Process**: + - To enable users to set up their accounts, you can now send them an invitation code. This allows them to securely add their authentication methods. +- **Flexible Email Verification**: + - v2 provides more control over email verification: + - You can choose at user creation whether an email verification code should be sent automatically. + - Alternatively, the API can return the verification code directly to you, empowering you to send a customized verification email. + +[Users API v2 Documentation](/docs/apis/resources/user_service_v2) + +### Projects + +We've simplified how you interact with projects by unifying projects and granted_projects into a single resource. +From a consumer's perspective, it no longer matters if you own a project or if it was granted to you by another organization; it's all just a project. + +The main difference now is your level of permission. +Your permissions determine whether you have administrative rights (like updating the project's details) or if you can only view the project and manage authorizations for your users. + +This change significantly streamlines API calls. +For example, where you previously had to make two separate requests to see all projects, you now make one. + +**v1 (Separate Requests):** +``` +- ListProjects +- ListGrantedProjects +``` + +**v2 (Single Request with Filter):** +``` +- ListProjects (returns all projects you have access to) +``` + +You can now use filters within the single ListProjects request if you need to differentiate between project types, such as filtering by projects you own versus those that have been granted to you. +Update your code to use this new unified ListProjects endpoint. + +### Applications + +We have streamlined the creation and management of applications. +In v1, each application type had its own unique endpoints. +In v2, we have unified these into a single set of endpoints for all application types. + +The biggest change is in how you create/update applications. +Instead of calling a specific endpoint for each type (e.g., CreateOidcApp, CreateSamlApp), you will now use a single CreateApp endpoint. + +To specify the type of application, you will include its specific configuration object within the request body. +For example, to create a OIDC app, you will provide a oidc object in the request. +All properties that are common to every application, such as name, are now top-level fields in the request body, consistent across all types. + +This approach simplifies client-side logic, as you no longer need to route requests to different endpoints. + +**v1 (Multiple, Type-Specific Endpoints):** + +``` +- AddOIDCApp +- AddSAMLApp +- AddAPIApp +``` + +**v2 (Single Endpoint with Type-Specific Body):** + +``` +- CreateApplication + - ProjectID + - Name + - Type + - OIDC + - SAML + - API +``` \ No newline at end of file diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index abf5c742a5..3ca8247a57 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -218,7 +218,6 @@ module.exports = { showLastUpdateTime: true, editUrl: "https://github.com/zitadel/zitadel/edit/main/docs/", remarkPlugins: [require("mdx-mermaid")], - docItemComponent: "@theme/ApiItem", }, theme: { @@ -243,6 +242,17 @@ module.exports = { }, }, ], + [ + "@signalwire/docusaurus-plugin-llms-txt", + { + depth: 3, + logLevel: 1, + content: { + excludeRoutes: ["/search"], + enableMarkdownFiles: true, + }, + }, + ], [ "docusaurus-plugin-openapi-docs", { @@ -337,7 +347,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 +383,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,22 +392,48 @@ 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", categoryLinkSource: "auto", }, }, + authorization_v2: { + specPath: + ".artifacts/openapi3/zitadel/authorization/v2beta/authorization_service.openapi.yaml", + outputDir: "docs/apis/resources/authorization_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, + internal_permission_v2: { + specPath: + ".artifacts/openapi3/zitadel/internal_permission/v2beta/internal_permission_service.openapi.yaml", + outputDir: "docs/apis/resources/internal_permission_service_v2", + sidebarOptions: { + groupPathsBy: "tag", + categoryLinkSource: "auto", + }, + }, }, }, ], diff --git a/docs/package.json b/docs/package.json index 2e1214f378..c1b23cecb1 100644 --- a/docs/package.json +++ b/docs/package.json @@ -18,7 +18,8 @@ "generate:apidocs": "docusaurus gen-api-docs all", "generate:configdocs": "cp -r ../cmd/defaults.yaml ./docs/self-hosting/manage/configure/ && cp -r ../cmd/setup/steps.yaml ./docs/self-hosting/manage/configure/", "generate:re-gen": "yarn generate:clean-all && yarn generate", - "generate:clean-all": "docusaurus clean-api-docs all" + "generate:clean-all": "docusaurus clean-api-docs all", + "postinstall": "sh ./plugin-download.sh" }, "dependencies": { "@bufbuild/buf": "^1.14.0", @@ -29,6 +30,7 @@ "@docusaurus/theme-search-algolia": "^3.8.1", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@signalwire/docusaurus-plugin-llms-txt": "^1.2.0", "@inkeep/cxkit-docusaurus": "^0.5.89", "autoprefixer": "^10.4.13", "clsx": "^1.2.1", diff --git a/docs/plugin-download.sh b/docs/plugin-download.sh new file mode 100644 index 0000000000..499326a1e7 --- /dev/null +++ b/docs/plugin-download.sh @@ -0,0 +1,22 @@ +echo $(uname -m) +mkdir protoc-gen-connect-openapi +cd ./protoc-gen-connect-openapi/ +if [ "$(uname)" = "Darwin" ]; then + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_darwin_all.tar.gz +else + ARCH=$(uname -m) + case $ARCH in + x86_64) + ARCH="amd64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; + esac + curl -L -o protoc-gen-connect-openapi.tar.gz https://github.com/sudorandom/protoc-gen-connect-openapi/releases/download/v0.18.0/protoc-gen-connect-openapi_0.18.0_linux_${ARCH}.tar.gz +fi +tar -xvf protoc-gen-connect-openapi.tar.gz \ No newline at end of file diff --git a/docs/sidebars.js b/docs/sidebars.js index fe77ea0af2..bc75f9ed87 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -16,6 +16,9 @@ const sidebar_api_actions_v2 = require("./docs/apis/resources/action_service_v2/ const sidebar_api_project_service_v2 = require("./docs/apis/resources/project_service_v2/sidebar.ts").default const sidebar_api_webkey_service_v2 = require("./docs/apis/resources/webkey_service_v2/sidebar.ts").default const sidebar_api_instance_service_v2 = require("./docs/apis/resources/instance_service_v2/sidebar.ts").default +const sidebar_api_authorization_service_v2 = require("./docs/apis/resources/authorization_service_v2/sidebar.ts").default +const sidebar_api_permission_service_v2 = require("./docs/apis/resources/internal_permission_service_v2/sidebar.ts").default +const sidebar_api_app_v2 = require("./docs/apis/resources/application_service_v2/sidebar.ts").default module.exports = { guides: [ @@ -661,6 +664,228 @@ module.exports = { id: "apis/apis/index", }, items: [ + + { + type: "category", + label: "V2", + collapsed: false, + link: { + type: "doc", + id: "apis/v2", + }, + items: [ + { + type: "category", + label: "User", + link: { + type: "generated-index", + title: "User Service API", + slug: "/apis/resources/user_service_v2", + description: + "This API is intended to manage users in a ZITADEL instance.\n", + }, + items: sidebar_api_user_service_v2, + }, + { + type: "category", + label: "Session", + link: { + type: "generated-index", + title: "Session Service API", + slug: "/apis/resources/session_service_v2", + description: + "This API is intended to manage sessions in a ZITADEL instance.\n", + }, + items: sidebar_api_session_service_v2, + }, + { + type: "category", + label: "OIDC", + link: { + type: "generated-index", + title: "OIDC Service API", + slug: "/apis/resources/oidc_service_v2", + description: + "Get OIDC Auth Request details and create callback URLs.\n", + }, + items: sidebar_api_oidc_service_v2, + }, + { + type: "category", + label: "Settings", + link: { + type: "generated-index", + title: "Settings Service API", + slug: "/apis/resources/settings_service_v2", + description: + "This API is intended to manage settings in a ZITADEL instance.\n", + }, + items: sidebar_api_settings_service_v2, + }, + { + type: "category", + label: "Feature", + link: { + type: "generated-index", + title: "Feature Service API", + slug: "/apis/resources/feature_service_v2", + description: + 'This API is intended to manage features for ZITADEL. Feature settings that are available on multiple "levels", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n', + }, + items: sidebar_api_feature_service_v2, + }, + { + type: "category", + label: "Organization", + link: { + type: "generated-index", + title: "Organization Service API", + slug: "/apis/resources/org_service/v2", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2, + }, + { + type: "category", + label: "Organization (Beta)", + link: { + type: "generated-index", + title: "Organization Service beta API", + slug: "/apis/resources/org_service/v2beta", + description: + "This API is intended to manage organizations for ZITADEL. \n", + }, + items: sidebar_api_org_service_v2beta, + }, + { + type: "category", + label: "Identity Provider", + link: { + type: "generated-index", + title: "Identity Provider Service API", + slug: "/apis/resources/idp_service_v2", + description: + "This API is intended to manage identity providers (IdPs) for ZITADEL.\n", + }, + items: sidebar_api_idp_service_v2, + }, + { + type: "category", + label: "Web Key", + link: { + type: "generated-index", + title: "Web Key Service API", + slug: "/apis/resources/webkey_service_v2", + description: + "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + + "\n"+ + "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n", + }, + items: sidebar_api_webkey_service_v2 + }, + { + type: "category", + label: "Action (Beta)", + link: { + type: "generated-index", + title: "Action Service API (Beta)", + slug: "/apis/resources/action_service_v2", + description: + "This API is intended to manage custom executions and targets (previously known as actions) in a ZITADEL instance.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "The version 2 of actions provide much more options to customize ZITADELs behaviour than previous action versions.\n" + + "Also, v2 actions are available instance-wide, whereas previous actions had to be managed for each organization individually\n" + + "ZITADEL doesn't restrict the implementation languages, tooling and runtime for v2 action executions anymore.\n" + + "Instead, it calls external endpoints which are implemented and maintained by action v2 users.\n"+ + "\n" + + "Please make sure to enable the `actions` feature flag on your instance to use this service and that you're running Zitadel V3.", + }, + items: sidebar_api_actions_v2, + }, + { + type: "category", + label: "Instance (Beta)", + link: { + type: "generated-index", + title: "Instance Service API (Beta)", + slug: "/apis/resources/instance_service_v2", + description: + "This API is intended to manage instances, custom domains and trusted domains in ZITADEL.\n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ + "\n" + + "This v2 of the API provides the same functionalities as the v1, but organised on a per resource basis.\n" + + "The whole functionality related to domains (custom and trusted) has been moved under this instance API." + , + }, + items: sidebar_api_instance_service_v2, + }, + { + type: "category", + label: "Project (Beta)", + link: { + type: "generated-index", + title: "Project Service API (Beta)", + slug: "/apis/resources/project_service_v2", + description: + "This API is intended to manage projects and subresources for ZITADEL. \n" + + "\n" + + "This service is in beta state. It can AND will continue breaking until a stable version is released.", + }, + items: sidebar_api_project_service_v2, + }, + { + type: "category", + label: "App (Beta)", + link: { + type: "generated-index", + title: "Application Service API (Beta)", + slug: "/apis/resources/application_service_v2", + description: + "This API lets you manage Zitadel applications (API, SAML, OIDC).\n"+ + "\n"+ + "The API offers generic endpoints that work for all app types (API, SAML, OIDC), "+ + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_app_v2, + }, + { + type: "category", + label: "Authorizations (Beta)", + link: { + type: "generated-index", + title: "Authorization Service API (Beta)", + slug: "/apis/resources/authorization_service_v2", + description: + "AuthorizationService provides methods to manage authorizations for users within your projects and applications.\n" + + "\n" + + "For managing permissions and roles for ZITADEL internal resources, like organizations, projects,\n" + + "users, etc., please use the InternalPermissionService."+ + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_authorization_service_v2, + }, + { + type: "category", + label: "Permissions (Beta)", + link: { + type: "generated-index", + title: "Permission Service API (Beta)", + slug: "/apis/resources/permission_service_v2", + description: + "This API is intended to manage internal permissions in ZITADEL.\n" + + "\n"+ + "This API is in beta state. It can AND will continue breaking until a stable version is released.\n" + }, + items: sidebar_api_permission_service_v2, + }, + ], + }, { type: "category", label: "V1", @@ -723,140 +948,7 @@ module.exports = { }, items: sidebar_api_system, }, - ], - }, - { - type: "category", - label: "V2", - collapsed: false, - link: { - type: "doc", - id: "apis/v2", - }, - items: [ - { - type: "category", - label: "User", - link: { - type: "generated-index", - title: "User Service API", - slug: "/apis/resources/user_service_v2", - description: - "This API is intended to manage users in a ZITADEL instance.\n", - }, - items: sidebar_api_user_service_v2, - }, - { - type: "category", - label: "Session", - link: { - type: "generated-index", - title: "Session Service API", - slug: "/apis/resources/session_service_v2", - description: - "This API is intended to manage sessions in a ZITADEL instance.\n", - }, - items: sidebar_api_session_service_v2, - }, - { - type: "category", - label: "OIDC", - link: { - type: "generated-index", - title: "OIDC Service API", - slug: "/apis/resources/oidc_service_v2", - description: - "Get OIDC Auth Request details and create callback URLs.\n", - }, - items: sidebar_api_oidc_service_v2, - }, - { - type: "category", - label: "Settings", - link: { - type: "generated-index", - title: "Settings Service API", - slug: "/apis/resources/settings_service_v2", - description: - "This API is intended to manage settings in a ZITADEL instance.\n", - }, - items: sidebar_api_settings_service_v2, - }, - { - type: "category", - label: "Feature", - link: { - type: "generated-index", - title: "Feature Service API", - slug: "/apis/resources/feature_service_v2", - description: - 'This API is intended to manage features for ZITADEL. Feature settings that are available on multiple "levels", such as instance and organization. The higher level instance acts as a default for the lower level. When a feature is set on multiple levels, the lower level takes precedence. Features can be experimental where ZITADEL will assume a sane default, such as disabled. When over time confidence in such a feature grows, ZITADEL can default to enabling the feature. As a final step we might choose to always enable a feature and remove the setting from this API, reserving the proto field number. Such removal is not considered a breaking change. Setting a removed field will effectively result in a no-op.\n', - }, - items: sidebar_api_feature_service_v2, - }, - { - type: "category", - label: "Organization", - link: { - type: "generated-index", - title: "Organization Service API", - slug: "/apis/resources/org_service/v2", - description: - "This API is intended to manage organizations for ZITADEL. \n", - }, - items: sidebar_api_org_service_v2, - }, - { - type: "category", - label: "Identity Provider", - link: { - type: "generated-index", - title: "Identity Provider Service API", - slug: "/apis/resources/idp_service_v2", - description: - "This API is intended to manage identity providers (IdPs) for ZITADEL.\n", - }, - items: sidebar_api_idp_service_v2, - }, - { - type: "category", - label: "Web key (Beta)", - link: { - type: "generated-index", - title: "Web Key Service API (Beta)", - slug: "/apis/resources/webkey_service_v2", - description: - "This API is intended to manage web keys for a ZITADEL instance, used to sign and validate OIDC tokens.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ - "\n"+ - "The public key endpoint (outside of this service) is used to retrieve the public keys of the active and inactive keys.\n"+ - "\n"+ - "Please make sure to enable the `web_key` feature flag on your instance to use this service and that you're running ZITADEL V3.", - }, - items: sidebar_api_webkey_service_v2 - }, - { - type: "category", - label: "Action (Beta)", - link: { - type: "generated-index", - title: "Action Service API (Beta)", - slug: "/apis/resources/action_service_v2", - description: - "This API is intended to manage custom executions and targets (previously known as actions) in a ZITADEL instance.\n" + - "\n" + - "This service is in beta state. It can AND will continue breaking until a stable version is released.\n"+ - "\n" + - "The version 2 of actions provide much more options to customize ZITADELs behaviour than previous action versions.\n" + - "Also, v2 actions are available instance-wide, whereas previous actions had to be managed for each organization individually\n" + - "ZITADEL doesn't restrict the implementation languages, tooling and runtime for v2 action executions anymore.\n" + - "Instead, it calls external endpoints which are implemented and maintained by action v2 users.\n"+ - "\n" + - "Please make sure to enable the `actions` feature flag on your instance to use this service and that you're running Zitadel V3.", - }, - items: sidebar_api_actions_v2, - }, + "apis/migration_v1_to_v2" ], }, { diff --git a/docs/yarn.lock b/docs/yarn.lock index c48c5b8bd6..cb0c5c8381 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -4231,6 +4231,23 @@ resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== +"@signalwire/docusaurus-plugin-llms-txt@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@signalwire/docusaurus-plugin-llms-txt/-/docusaurus-plugin-llms-txt-1.2.0.tgz#895174eb6a786099dd9517052176a644f99a05e9" + integrity sha512-KkuerZy5VtzeordKL+osxHHAJPs7GcjKEyiC8Up4i2lODE38cVhHtn3F+chxOLPRWXpWUCskFhEJI5r+aZ1+7A== + dependencies: + fs-extra "^11.0.0" + hast-util-select "^6.0.4" + hast-util-to-html "^9.0.5" + hast-util-to-string "^3.0.1" + rehype-parse "^9" + rehype-remark "^10" + remark-gfm "^4" + remark-stringify "^11" + string-width "^5.0.0" + unified "^11" + unist-util-visit "^5" + "@sinclair/typebox@^0.27.8": version "0.27.8" resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" @@ -5857,6 +5874,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +bcp-47-match@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/bcp-47-match/-/bcp-47-match-2.0.3.tgz#603226f6e5d3914a581408be33b28a53144b09d0" + integrity sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ== + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -6121,6 +6143,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" @@ -6720,6 +6747,11 @@ css-select@^5.1.0: domutils "^3.0.1" nth-check "^2.0.1" +css-selector-parser@^3.0.0: + version "3.1.3" + resolved "https://registry.yarnpkg.com/css-selector-parser/-/css-selector-parser-3.1.3.tgz#fb1ba303cfa00e0a7b7a49ede46c12e1b87a081f" + integrity sha512-gJMigczVZqYAk0hPVzx/M4Hm1D9QOtqkdQk9005TNzDIUGzo5cnHEDiKUT7jGPximL/oYb+LIitcHFQ4aKupxg== + css-tree@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" @@ -7309,6 +7341,11 @@ dir-glob@^3.0.1: dependencies: path-type "^4.0.0" +direction@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/direction/-/direction-2.0.1.tgz#71800dd3c4fa102406502905d3866e65bdebb985" + integrity sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA== + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -7503,6 +7540,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" @@ -8096,6 +8138,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^11.0.0: + version "11.3.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^11.1.1, fs-extra@^11.2.0: version "11.2.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" @@ -8413,6 +8464,14 @@ hasown@^2.0.0, hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-embedded@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz#be4477780fbbe079cdba22982e357a0de4ba853e" + integrity sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA== + dependencies: + "@types/hast" "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-from-html@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-1.0.2.tgz#2482fd701b2d8270b912b3909d6fb645d4a346cf" @@ -8424,6 +8483,18 @@ hast-util-from-html@^1.0.1: vfile "^5.0.0" vfile-message "^3.0.0" +hast-util-from-html@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz#485c74785358beb80c4ba6346299311ac4c49c82" + integrity sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw== + dependencies: + "@types/hast" "^3.0.0" + devlop "^1.1.0" + hast-util-from-parse5 "^8.0.0" + parse5 "^7.0.0" + vfile "^6.0.0" + vfile-message "^4.0.0" + hast-util-from-parse5@^7.0.0: version "7.1.2" resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-7.1.2.tgz#aecfef73e3ceafdfa4550716443e4eb7b02e22b0" @@ -8451,6 +8522,38 @@ hast-util-from-parse5@^8.0.0: vfile-location "^5.0.0" web-namespaces "^2.0.0" +hast-util-has-property@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz#4e595e3cddb8ce530ea92f6fc4111a818d8e7f93" + integrity sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-body-ok-link@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz#ef63cb2f14f04ecf775139cd92bda5026380d8b4" + integrity sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-is-element@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz#6e31a6532c217e5b533848c7e52c9d9369ca0932" + integrity sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-minify-whitespace@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz#7588fd1a53f48f1d30406b81959dffc3650daf55" + integrity sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-whitespace "^3.0.0" + unist-util-is "^6.0.0" + hast-util-parse-selector@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-3.1.1.tgz#25ab00ae9e75cbc62cf7a901f68a247eade659e2" @@ -8465,6 +8568,17 @@ hast-util-parse-selector@^4.0.0: dependencies: "@types/hast" "^3.0.0" +hast-util-phrasing@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz#fa284c0cd4a82a0dd6020de8300a7b1ebffa1690" + integrity sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ== + dependencies: + "@types/hast" "^3.0.0" + hast-util-embedded "^3.0.0" + hast-util-has-property "^3.0.0" + hast-util-is-body-ok-link "^3.0.0" + hast-util-is-element "^3.0.0" + hast-util-raw@^7.2.0: version "7.2.3" resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-7.2.3.tgz#dcb5b22a22073436dbdc4aa09660a644f4991d99" @@ -8501,6 +8615,27 @@ hast-util-raw@^9.0.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-select@^6.0.4: + version "6.0.4" + resolved "https://registry.yarnpkg.com/hast-util-select/-/hast-util-select-6.0.4.tgz#1d8f69657a57441d0ce0ade35887874d3e65a303" + integrity sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + bcp-47-match "^2.0.0" + comma-separated-tokens "^2.0.0" + css-selector-parser "^3.0.0" + devlop "^1.0.0" + direction "^2.0.0" + hast-util-has-property "^3.0.0" + hast-util-to-string "^3.0.0" + hast-util-whitespace "^3.0.0" + nth-check "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + hast-util-to-estree@^2.1.0: version "2.3.3" resolved "https://registry.yarnpkg.com/hast-util-to-estree/-/hast-util-to-estree-2.3.3.tgz#da60142ffe19a6296923ec222aba73339c8bf470" @@ -8544,6 +8679,23 @@ hast-util-to-estree@^3.0.0: unist-util-position "^5.0.0" zwitch "^2.0.0" +hast-util-to-html@^9.0.0, hast-util-to-html@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + hast-util-to-jsx-runtime@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz#3ed27caf8dc175080117706bf7269404a0aa4f7c" @@ -8565,6 +8717,26 @@ hast-util-to-jsx-runtime@^2.0.0: unist-util-position "^5.0.0" vfile-message "^4.0.0" +hast-util-to-mdast@^10.0.0: + version "10.1.2" + resolved "https://registry.yarnpkg.com/hast-util-to-mdast/-/hast-util-to-mdast-10.1.2.tgz#bc76f7f5f72f2cde4d6a66ad4cd0aba82bb79909" + integrity sha512-FiCRI7NmOvM4y+f5w32jPRzcxDIz+PUqDwEqn1A+1q2cdp3B8Gx7aVrXORdOKjMNDQsD1ogOr896+0jJHW1EFQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + hast-util-phrasing "^3.0.0" + hast-util-to-html "^9.0.0" + hast-util-to-text "^4.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-hast "^13.0.0" + mdast-util-to-string "^4.0.0" + rehype-minify-whitespace "^6.0.0" + trim-trailing-lines "^2.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + hast-util-to-parse5@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-7.1.0.tgz#c49391bf8f151973e0c9adcd116b561e8daf29f3" @@ -8590,6 +8762,23 @@ hast-util-to-parse5@^8.0.0: web-namespaces "^2.0.0" zwitch "^2.0.0" +hast-util-to-string@^3.0.0, hast-util-to-string@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz#a4f15e682849326dd211c97129c94b0c3e76527c" + integrity sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A== + dependencies: + "@types/hast" "^3.0.0" + +hast-util-to-text@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz#57b676931e71bf9cb852453678495b3080bfae3e" + integrity sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + hast-util-is-element "^3.0.0" + unist-util-find-after "^5.0.0" + hast-util-whitespace@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz#0ec64e257e6fc216c7d14c8a1b74d27d650b4557" @@ -11204,7 +11393,7 @@ nprogress@^0.2.0: resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA== -nth-check@^2.0.1: +nth-check@^2.0.0, nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== @@ -12459,6 +12648,11 @@ property-information@^6.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.5.0.tgz#6212fbb52ba757e92ef4fb9d657563b933b7ffec" integrity sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig== +property-information@^7.0.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/property-information/-/property-information-7.1.0.tgz#b622e8646e02b580205415586b40804d3e8bfd5d" + integrity sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ== + proto-list@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" @@ -13036,6 +13230,23 @@ regjsparser@^0.9.1: dependencies: jsesc "~0.5.0" +rehype-minify-whitespace@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/rehype-minify-whitespace/-/rehype-minify-whitespace-6.0.2.tgz#7dd234ce0775656ce6b6b0aad0a6093de29b2278" + integrity sha512-Zk0pyQ06A3Lyxhe9vGtOtzz3Z0+qZ5+7icZ/PL/2x1SHPbKao5oB/g/rlc6BCTajqBb33JcOe71Ye1oFsuYbnw== + dependencies: + "@types/hast" "^3.0.0" + hast-util-minify-whitespace "^1.0.0" + +rehype-parse@^9: + version "9.0.1" + resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-9.0.1.tgz#9993bda129acc64c417a9d3654a7be38b2a94c20" + integrity sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag== + dependencies: + "@types/hast" "^3.0.0" + hast-util-from-html "^2.0.0" + unified "^11.0.0" + rehype-raw@7.0.0, rehype-raw@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/rehype-raw/-/rehype-raw-7.0.0.tgz#59d7348fd5dbef3807bbaa1d443efd2dd85ecee4" @@ -13054,6 +13265,17 @@ rehype-raw@^6.1.1: hast-util-raw "^7.2.0" unified "^10.0.0" +rehype-remark@^10: + version "10.0.1" + resolved "https://registry.yarnpkg.com/rehype-remark/-/rehype-remark-10.0.1.tgz#f669fa68cfb8b5baaf4fa95476a923516111a43b" + integrity sha512-EmDndlb5NVwXGfUa4c9GPK+lXeItTilLhE6ADSaQuHr4JUlKw9MidzGzx4HpqZrNCt6vnHmEifXQiiA+CEnjYQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + hast-util-to-mdast "^10.0.0" + unified "^11.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" @@ -13100,10 +13322,10 @@ remark-gfm@3.0.1: 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" - integrity sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA== +remark-gfm@^4, remark-gfm@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.1.tgz#33227b2a74397670d357bf05c098eaf8513f0d6b" + integrity sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg== dependencies: "@types/mdast" "^4.0.0" mdast-util-gfm "^3.0.0" @@ -13112,10 +13334,10 @@ 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== +remark-gfm@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-4.0.0.tgz#aea777f0744701aa288b67d28c43565c7e8c35de" + integrity sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA== dependencies: "@types/mdast" "^4.0.0" mdast-util-gfm "^3.0.0" @@ -13172,7 +13394,7 @@ remark-rehype@^11.0.0: unified "^11.0.0" vfile "^6.0.0" -remark-stringify@^11.0.0: +remark-stringify@^11, remark-stringify@^11.0.0: version "11.0.0" resolved "https://registry.yarnpkg.com/remark-stringify/-/remark-stringify-11.0.0.tgz#4c5b01dd711c269df1aaae11743eb7e2e7636fd3" integrity sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw== @@ -13845,7 +14067,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^5.0.1, string-width@^5.1.2: +string-width@^5.0.0, string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== @@ -14221,6 +14443,11 @@ trim-lines@^3.0.0: resolved "https://registry.yarnpkg.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== +trim-trailing-lines@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-2.1.0.tgz#9aac7e89b09cb35badf663de7133c6de164f86df" + integrity sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg== + trough@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/trough/-/trough-2.2.0.tgz#94a60bd6bd375c152c1df911a4b11d5b0256f50f" @@ -14335,6 +14562,19 @@ unified@^10.0.0: trough "^2.0.0" vfile "^5.0.0" +unified@^11: + version "11.0.5" + resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.5.tgz#f66677610a5c0a9ee90cab2b8d4d66037026d9e1" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + unified@^11.0.0, unified@^11.0.3, unified@^11.0.4: version "11.0.4" resolved "https://registry.yarnpkg.com/unified/-/unified-11.0.4.tgz#f4be0ac0fe4c88cb873687c07c64c49ed5969015" @@ -14355,6 +14595,14 @@ unique-string@^3.0.0: dependencies: crypto-random-string "^4.0.0" +unist-util-find-after@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz#3fccc1b086b56f34c8b798e1ff90b5c54468e896" + integrity sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-generated@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-2.0.1.tgz#e37c50af35d3ed185ac6ceacb6ca0afb28a85cae" @@ -14457,7 +14705,7 @@ unist-util-visit@^4.0.0: unist-util-is "^5.0.0" unist-util-visit-parents "^5.1.1" -unist-util-visit@^5.0.0: +unist-util-visit@^5, unist-util-visit@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== @@ -15141,7 +15389,7 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== -zwitch@^2.0.0: +zwitch@^2.0.0, zwitch@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/go.mod b/go.mod index f0ab6246d6..22980acfaf 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ toolchain go1.24.1 require ( cloud.google.com/go/profiler v0.4.2 cloud.google.com/go/storage v1.54.0 + connectrpc.com/connect v1.18.1 + connectrpc.com/grpcreflect v1.3.0 dario.cat/mergo v1.0.2 github.com/BurntSushi/toml v1.5.0 github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -29,7 +31,7 @@ require ( github.com/fatih/color v1.18.0 github.com/fergusstrange/embedded-postgres v1.30.0 github.com/gabriel-vasile/mimetype v1.4.9 - github.com/go-chi/chi/v5 v5.2.1 + github.com/go-chi/chi/v5 v5.2.2 github.com/go-jose/go-jose/v4 v4.1.0 github.com/go-ldap/ldap/v3 v3.4.11 github.com/go-webauthn/webauthn v0.10.2 @@ -80,7 +82,7 @@ require ( github.com/twilio/twilio-go v1.26.1 github.com/zitadel/exifremove v0.1.0 github.com/zitadel/logging v0.6.2 - github.com/zitadel/oidc/v3 v3.37.0 + github.com/zitadel/oidc/v3 v3.39.1 github.com/zitadel/passwap v0.9.0 github.com/zitadel/saml v0.3.5 github.com/zitadel/schema v1.3.1 @@ -99,8 +101,8 @@ require ( golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 golang.org/x/net v0.40.0 golang.org/x/oauth2 v0.30.0 - golang.org/x/sync v0.14.0 - golang.org/x/text v0.25.0 + golang.org/x/sync v0.15.0 + golang.org/x/text v0.26.0 google.golang.org/api v0.233.0 google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9 google.golang.org/grpc v1.72.1 @@ -127,7 +129,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-redsync/redsync/v4 v4.13.0 // indirect github.com/go-sql-driver/mysql v1.7.1 // indirect - github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/go-viper/mapstructure/v2 v2.3.0 // indirect github.com/go-webauthn/x v0.1.9 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.2 // indirect diff --git a/go.sum b/go.sum index e2ab9768a6..7221111a2b 100644 --- a/go.sum +++ b/go.sum @@ -24,6 +24,10 @@ cloud.google.com/go/storage v1.54.0 h1:Du3XEyliAiftfyW0bwfdppm2MMLdpVAfiIg4T2nAI cloud.google.com/go/storage v1.54.0/go.mod h1:hIi9Boe8cHxTyaeqh7KMMwKg088VblFK46C2x/BWaZE= cloud.google.com/go/trace v1.11.3 h1:c+I4YFjxRQjvAhRmSsmjpASUKq88chOX854ied0K/pE= cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= @@ -234,8 +238,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= -github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= -github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= +github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q= github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs= @@ -279,8 +283,8 @@ github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LB github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= -github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk= +github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4= github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs= github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE= @@ -806,8 +810,8 @@ github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8 github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek= github.com/zitadel/logging v0.6.2 h1:MW2kDDR0ieQynPZ0KIZPrh9ote2WkxfBif5QoARDQcU= github.com/zitadel/logging v0.6.2/go.mod h1:z6VWLWUkJpnNVDSLzrPSQSQyttysKZ6bCRongw0ROK4= -github.com/zitadel/oidc/v3 v3.37.0 h1:nYATWlnP7f18XiAbw6upUruBaqfB1kUrXrSTf1EYGO8= -github.com/zitadel/oidc/v3 v3.37.0/go.mod h1:/xDan4OUQhguJ4Ur73OOJrtugvR164OMnidXP9xfVNw= +github.com/zitadel/oidc/v3 v3.39.1 h1:6QwGwI3yxh4somT7fwRCeT1KOn/HOGv0PA0dFciwJjE= +github.com/zitadel/oidc/v3 v3.39.1/go.mod h1:aH8brOrzoliAybVdfq2xIdGvbtl0j/VsKRNa7WE72gI= github.com/zitadel/passwap v0.9.0 h1:QvDK8OHKdb73C0m+mwXvu87UJSBqix3oFwTVENHdv80= github.com/zitadel/passwap v0.9.0/go.mod h1:6QzwFjDkIr3FfudzSogTOx5Ydhq4046dRJtDM/kX+G8= github.com/zitadel/saml v0.3.5 h1:L1RKWS5y66cGepVxUGjx/WSBOtrtSpRA/J3nn5BJLOY= @@ -944,8 +948,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= -golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -988,8 +992,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/internal/api/api.go b/internal/api/api.go index 62d3e14b35..349e9186bc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -7,16 +7,18 @@ import ( "sort" "strings" + "connectrpc.com/grpcreflect" "github.com/gorilla/mux" "github.com/improbable-eng/grpc-web/go/grpcweb" "github.com/zitadel/logging" "google.golang.org/grpc" "google.golang.org/grpc/health" healthpb "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/reflection" "github.com/zitadel/zitadel/internal/api/authz" + grpc_api "github.com/zitadel/zitadel/internal/api/grpc" "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware" http_util "github.com/zitadel/zitadel/internal/api/http" http_mw "github.com/zitadel/zitadel/internal/api/http/middleware" "github.com/zitadel/zitadel/internal/api/ui/login" @@ -24,10 +26,16 @@ import ( "github.com/zitadel/zitadel/internal/telemetry/metrics" "github.com/zitadel/zitadel/internal/telemetry/tracing" "github.com/zitadel/zitadel/internal/zerrors" + system_pb "github.com/zitadel/zitadel/pkg/grpc/system" +) + +var ( + metricTypes = []metrics.MetricType{metrics.MetricTypeTotalCount, metrics.MetricTypeRequestCount, metrics.MetricTypeStatusCode} ) type API struct { port uint16 + externalDomain string grpcServer *grpc.Server verifier authz.APITokenVerifier health healthCheck @@ -37,16 +45,23 @@ type API struct { healthServer *health.Server accessInterceptor *http_mw.AccessInterceptor queries *query.Queries + authConfig authz.Config + systemAuthZ authz.Config + connectServices map[string][]string } func (a *API) ListGrpcServices() []string { serviceInfo := a.grpcServer.GetServiceInfo() - services := make([]string, len(serviceInfo)) + services := make([]string, len(serviceInfo)+len(a.connectServices)) i := 0 for servicename := range serviceInfo { services[i] = servicename i++ } + for prefix := range a.connectServices { + services[i] = strings.Trim(prefix, "/") + i++ + } sort.Strings(services) return services } @@ -59,6 +74,11 @@ func (a *API) ListGrpcMethods() []string { methods = append(methods, "/"+servicename+"/"+method.Name) } } + for service, methodList := range a.connectServices { + for _, method := range methodList { + methods = append(methods, service+method) + } + } sort.Strings(methods) return methods } @@ -82,12 +102,16 @@ func New( ) (_ *API, err error) { api := &API{ port: port, + externalDomain: externalDomain, verifier: verifier, health: queries, router: router, queries: queries, accessInterceptor: accessInterceptor, hostHeaders: hostHeaders, + authConfig: authZ, + systemAuthZ: systemAuthz, + connectServices: make(map[string][]string), } api.grpcServer = server.CreateServer(api.verifier, systemAuthz, authZ, queries, externalDomain, tlsConfig, accessInterceptor.AccessService()) @@ -100,10 +124,15 @@ func New( api.RegisterHandlerOnPrefix("/debug", api.healthHandler()) api.router.Handle("/", http.RedirectHandler(login.HandlerPrefix, http.StatusFound)) - reflection.Register(api.grpcServer) return api, nil } +func (a *API) serverReflection() { + reflector := grpcreflect.NewStaticReflector(a.ListGrpcServices()...) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1(reflector)) + a.RegisterHandlerOnPrefix(grpcreflect.NewHandlerV1Alpha(reflector)) +} + // RegisterServer registers a grpc service on the grpc server, // creates a new grpc gateway and registers it as a separate http handler // @@ -131,17 +160,50 @@ func (a *API) RegisterServer(ctx context.Context, grpcServer server.WithGatewayP // and its gateway on the gateway handler // // used for >= v2 api (e.g. user, session, ...) -func (a *API) RegisterService(ctx context.Context, grpcServer server.Server) error { - grpcServer.RegisterServer(a.grpcServer) - err := server.RegisterGateway(ctx, a.grpcGateway, grpcServer) - if err != nil { - return err +func (a *API) RegisterService(ctx context.Context, srv server.Server) error { + switch service := srv.(type) { + case server.GrpcServer: + service.RegisterServer(a.grpcServer) + case server.ConnectServer: + a.registerConnectServer(service) } - a.verifier.RegisterServer(grpcServer.AppName(), grpcServer.MethodPrefix(), grpcServer.AuthMethods()) - a.healthServer.SetServingStatus(grpcServer.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) + if withGateway, ok := srv.(server.WithGateway); ok { + err := server.RegisterGateway(ctx, a.grpcGateway, withGateway) + if err != nil { + return err + } + } + a.verifier.RegisterServer(srv.AppName(), srv.MethodPrefix(), srv.AuthMethods()) + a.healthServer.SetServingStatus(srv.MethodPrefix(), healthpb.HealthCheckResponse_SERVING) return nil } +func (a *API) registerConnectServer(service server.ConnectServer) { + prefix, handler := service.RegisterConnectServer( + connect_middleware.CallDurationHandler(), + connect_middleware.MetricsHandler(metricTypes, grpc_api.Probes...), + connect_middleware.NoCacheInterceptor(), + connect_middleware.InstanceInterceptor(a.queries, a.externalDomain, system_pb.SystemService_ServiceDesc.ServiceName, healthpb.Health_ServiceDesc.ServiceName), + connect_middleware.AccessStorageInterceptor(a.accessInterceptor.AccessService()), + connect_middleware.ErrorHandler(), + connect_middleware.LimitsInterceptor(system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.AuthorizationInterceptor(a.verifier, a.systemAuthZ, a.authConfig), + connect_middleware.TranslationHandler(), + connect_middleware.QuotaExhaustedInterceptor(a.accessInterceptor.AccessService(), system_pb.SystemService_ServiceDesc.ServiceName), + connect_middleware.ExecutionHandler(a.queries), + connect_middleware.ValidationHandler(), + connect_middleware.ServiceHandler(), + connect_middleware.ActivityInterceptor(), + ) + methods := service.FileDescriptor().Services().Get(0).Methods() + methodNames := make([]string, methods.Len()) + for i := 0; i < methods.Len(); i++ { + methodNames[i] = string(methods.Get(i).Name()) + } + a.connectServices[prefix] = methodNames + a.RegisterHandlerPrefixes(handler, prefix) +} + // HandleFunc allows registering a [http.HandlerFunc] on an exact // path, instead of prefix like RegisterHandlerOnPrefix. func (a *API) HandleFunc(path string, f http.HandlerFunc) { @@ -173,6 +235,9 @@ func (a *API) registerHealthServer() { } func (a *API) RouteGRPC() { + // since all services are now registered, we can build the grpc server reflection and register the handler + a.serverReflection() + http2Route := a.router. MatcherFunc(func(r *http.Request, _ *mux.RouteMatch) bool { return r.ProtoMajor == 2 diff --git a/internal/api/grpc/action/v2beta/execution.go b/internal/api/grpc/action/v2beta/execution.go index 5477a8128e..3b49ebb364 100644 --- a/internal/api/grpc/action/v2beta/execution.go +++ b/internal/api/grpc/action/v2beta/execution.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionRequest) (*action.SetExecutionResponse, error) { - reqTargets := req.GetTargets() +func (s *Server) SetExecution(ctx context.Context, req *connect.Request[action.SetExecutionRequest]) (*connect.Response[action.SetExecutionResponse], error) { + reqTargets := req.Msg.GetTargets() targets := make([]*execution.Target, len(reqTargets)) for i, target := range reqTargets { targets[i] = &execution.Target{Type: domain.ExecutionTargetTypeTarget, Target: target} @@ -25,7 +26,7 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque var err error var details *domain.ObjectDetails instanceID := authz.GetInstance(ctx).InstanceID() - switch t := req.GetCondition().GetConditionType().(type) { + switch t := req.Msg.GetCondition().GetConditionType().(type) { case *action.Condition_Request: cond := executionConditionFromRequest(t.Request) details, err = s.command.SetExecutionRequest(ctx, cond, set, instanceID) @@ -43,27 +44,27 @@ func (s *Server) SetExecution(ctx context.Context, req *action.SetExecutionReque if err != nil { return nil, err } - return &action.SetExecutionResponse{ + return connect.NewResponse(&action.SetExecutionResponse{ SetDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListExecutionFunctions(ctx context.Context, _ *action.ListExecutionFunctionsRequest) (*action.ListExecutionFunctionsResponse, error) { - return &action.ListExecutionFunctionsResponse{ +func (s *Server) ListExecutionFunctions(ctx context.Context, _ *connect.Request[action.ListExecutionFunctionsRequest]) (*connect.Response[action.ListExecutionFunctionsResponse], error) { + return connect.NewResponse(&action.ListExecutionFunctionsResponse{ Functions: s.ListActionFunctions(), - }, nil + }), nil } -func (s *Server) ListExecutionMethods(ctx context.Context, _ *action.ListExecutionMethodsRequest) (*action.ListExecutionMethodsResponse, error) { - return &action.ListExecutionMethodsResponse{ +func (s *Server) ListExecutionMethods(ctx context.Context, _ *connect.Request[action.ListExecutionMethodsRequest]) (*connect.Response[action.ListExecutionMethodsResponse], error) { + return connect.NewResponse(&action.ListExecutionMethodsResponse{ Methods: s.ListGRPCMethods(), - }, nil + }), nil } -func (s *Server) ListExecutionServices(ctx context.Context, _ *action.ListExecutionServicesRequest) (*action.ListExecutionServicesResponse, error) { - return &action.ListExecutionServicesResponse{ +func (s *Server) ListExecutionServices(ctx context.Context, _ *connect.Request[action.ListExecutionServicesRequest]) (*connect.Response[action.ListExecutionServicesResponse], error) { + return connect.NewResponse(&action.ListExecutionServicesResponse{ Services: s.ListGRPCServices(), - }, nil + }), nil } func executionConditionFromRequest(request *action.RequestExecution) *command.ExecutionAPICondition { diff --git a/internal/api/grpc/action/v2beta/integration_test/query_test.go b/internal/api/grpc/action/v2beta/integration_test/query_test.go index 5c59bee5d1..2d74486f3e 100644 --- a/internal/api/grpc/action/v2beta/integration_test/query_test.go +++ b/internal/api/grpc/action/v2beta/integration_test/query_test.go @@ -782,12 +782,3 @@ func TestServer_ListExecutions(t *testing.T) { }) } } - -func containExecution(t *assert.CollectT, executionList []*action.Execution, execution *action.Execution) bool { - for _, exec := range executionList { - if assert.EqualExportedValues(t, execution, exec) { - return true - } - } - return false -} diff --git a/internal/api/grpc/action/v2beta/query.go b/internal/api/grpc/action/v2beta/query.go index 1dbe80a8f7..9428b6ab7b 100644 --- a/internal/api/grpc/action/v2beta/query.go +++ b/internal/api/grpc/action/v2beta/query.go @@ -4,6 +4,7 @@ import ( "context" "strings" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -22,14 +23,14 @@ const ( conditionIDEventGroupSegmentCount = 1 ) -func (s *Server) GetTarget(ctx context.Context, req *action.GetTargetRequest) (*action.GetTargetResponse, error) { - resp, err := s.query.GetTargetByID(ctx, req.GetId()) +func (s *Server) GetTarget(ctx context.Context, req *connect.Request[action.GetTargetRequest]) (*connect.Response[action.GetTargetResponse], error) { + resp, err := s.query.GetTargetByID(ctx, req.Msg.GetId()) if err != nil { return nil, err } - return &action.GetTargetResponse{ + return connect.NewResponse(&action.GetTargetResponse{ Target: targetToPb(resp), - }, nil + }), nil } type InstanceContext interface { @@ -41,8 +42,8 @@ type Context interface { GetOwner() InstanceContext } -func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest) (*action.ListTargetsResponse, error) { - queries, err := s.ListTargetsRequestToModel(req) +func (s *Server) ListTargets(ctx context.Context, req *connect.Request[action.ListTargetsRequest]) (*connect.Response[action.ListTargetsResponse], error) { + queries, err := s.ListTargetsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -50,14 +51,14 @@ func (s *Server) ListTargets(ctx context.Context, req *action.ListTargetsRequest if err != nil { return nil, err } - return &action.ListTargetsResponse{ + return connect.NewResponse(&action.ListTargetsResponse{ Result: targetsToPb(resp.Targets), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } -func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsRequest) (*action.ListExecutionsResponse, error) { - queries, err := s.ListExecutionsRequestToModel(req) +func (s *Server) ListExecutions(ctx context.Context, req *connect.Request[action.ListExecutionsRequest]) (*connect.Response[action.ListExecutionsResponse], error) { + queries, err := s.ListExecutionsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -65,10 +66,10 @@ func (s *Server) ListExecutions(ctx context.Context, req *action.ListExecutionsR if err != nil { return nil, err } - return &action.ListExecutionsResponse{ + return connect.NewResponse(&action.ListExecutionsResponse{ Result: executionsToPb(resp.Executions), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func targetsToPb(targets []*query.Target) []*action.Target { diff --git a/internal/api/grpc/action/v2beta/server.go b/internal/api/grpc/action/v2beta/server.go index ef0d8eb2ba..440bf842ca 100644 --- a/internal/api/grpc/action/v2beta/server.go +++ b/internal/api/grpc/action/v2beta/server.go @@ -1,7 +1,10 @@ package action import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/action/v2beta/actionconnect" ) -var _ action.ActionServiceServer = (*Server)(nil) +var _ actionconnect.ActionServiceHandler = (*Server)(nil) type Server struct { - action.UnimplementedActionServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -43,8 +46,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - action.RegisterActionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return actionconnect.NewActionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return action.File_zitadel_action_v2beta_action_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/action/v2beta/target.go b/internal/api/grpc/action/v2beta/target.go index 26c88b9683..b13f3461f0 100644 --- a/internal/api/grpc/action/v2beta/target.go +++ b/internal/api/grpc/action/v2beta/target.go @@ -3,6 +3,7 @@ package action import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( action "github.com/zitadel/zitadel/pkg/grpc/action/v2beta" ) -func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetRequest) (*action.CreateTargetResponse, error) { - add := createTargetToCommand(req) +func (s *Server) CreateTarget(ctx context.Context, req *connect.Request[action.CreateTargetRequest]) (*connect.Response[action.CreateTargetResponse], error) { + add := createTargetToCommand(req.Msg) instanceID := authz.GetInstance(ctx).InstanceID() createdAt, err := s.command.AddTarget(ctx, add, instanceID) if err != nil { @@ -24,16 +25,16 @@ func (s *Server) CreateTarget(ctx context.Context, req *action.CreateTargetReque if !createdAt.IsZero() { creationDate = timestamppb.New(createdAt) } - return &action.CreateTargetResponse{ + return connect.NewResponse(&action.CreateTargetResponse{ Id: add.AggregateID, CreationDate: creationDate, SigningKey: add.SigningKey, - }, nil + }), nil } -func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetRequest) (*action.UpdateTargetResponse, error) { +func (s *Server) UpdateTarget(ctx context.Context, req *connect.Request[action.UpdateTargetRequest]) (*connect.Response[action.UpdateTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - update := updateTargetToCommand(req) + update := updateTargetToCommand(req.Msg) changedAt, err := s.command.ChangeTarget(ctx, update, instanceID) if err != nil { return nil, err @@ -42,15 +43,15 @@ func (s *Server) UpdateTarget(ctx context.Context, req *action.UpdateTargetReque if !changedAt.IsZero() { changeDate = timestamppb.New(changedAt) } - return &action.UpdateTargetResponse{ + return connect.NewResponse(&action.UpdateTargetResponse{ ChangeDate: changeDate, SigningKey: update.SigningKey, - }, nil + }), nil } -func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetRequest) (*action.DeleteTargetResponse, error) { +func (s *Server) DeleteTarget(ctx context.Context, req *connect.Request[action.DeleteTargetRequest]) (*connect.Response[action.DeleteTargetResponse], error) { instanceID := authz.GetInstance(ctx).InstanceID() - deletedAt, err := s.command.DeleteTarget(ctx, req.GetId(), instanceID) + deletedAt, err := s.command.DeleteTarget(ctx, req.Msg.GetId(), instanceID) if err != nil { return nil, err } @@ -58,9 +59,9 @@ func (s *Server) DeleteTarget(ctx context.Context, req *action.DeleteTargetReque if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &action.DeleteTargetResponse{ + return connect.NewResponse(&action.DeleteTargetResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func createTargetToCommand(req *action.CreateTargetRequest) *command.AddTarget { diff --git a/internal/api/grpc/admin/export.go b/internal/api/grpc/admin/export.go index 8024cd9d6e..7ce2dbd7b5 100644 --- a/internal/api/grpc/admin/export.go +++ b/internal/api/grpc/admin/export.go @@ -659,7 +659,7 @@ func (s *Server) getUsers(ctx context.Context, org string, withPasswords bool, w if err != nil { return nil, nil, nil, nil, err } - metadataList, err := s.query.SearchUserMetadata(ctx, false, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{metadataOrgSearch}}, false) + metadataList, err := s.query.SearchUserMetadata(ctx, false, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{metadataOrgSearch}}, nil) metaspan.EndWithError(err) if err != nil { return nil, nil, nil, nil, err @@ -984,7 +984,7 @@ func (s *Server) getNecessaryUserGrantsForOrg(ctx context.Context, org string, p return nil, err } - queriedUserGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantSearchOrg}}, true) + queriedUserGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantSearchOrg}}, true, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/admin/iam_member.go b/internal/api/grpc/admin/iam_member.go index 8f9b11ce2a..301acec6d6 100644 --- a/internal/api/grpc/admin/iam_member.go +++ b/internal/api/grpc/admin/iam_member.go @@ -4,6 +4,7 @@ import ( "context" "time" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" @@ -33,35 +34,35 @@ func (s *Server) ListIAMMembers(ctx context.Context, req *admin_pb.ListIAMMember } func (s *Server) AddIAMMember(ctx context.Context, req *admin_pb.AddIAMMemberRequest) (*admin_pb.AddIAMMemberResponse, error) { - member, err := s.command.AddInstanceMember(ctx, req.UserId, req.Roles...) + member, err := s.command.AddInstanceMember(ctx, AddIAMMemberToCommand(req, authz.GetInstance(ctx).InstanceID())) if err != nil { return nil, err } return &admin_pb.AddIAMMemberResponse{ Details: object.AddToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) UpdateIAMMember(ctx context.Context, req *admin_pb.UpdateIAMMemberRequest) (*admin_pb.UpdateIAMMemberResponse, error) { - member, err := s.command.ChangeInstanceMember(ctx, UpdateIAMMemberToDomain(req)) + member, err := s.command.ChangeInstanceMember(ctx, UpdateIAMMemberToCommand(req, authz.GetInstance(ctx).InstanceID())) if err != nil { return nil, err } return &admin_pb.UpdateIAMMemberResponse{ Details: object.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) RemoveIAMMember(ctx context.Context, req *admin_pb.RemoveIAMMemberRequest) (*admin_pb.RemoveIAMMemberResponse, error) { - objectDetails, err := s.command.RemoveInstanceMember(ctx, req.UserId) + objectDetails, err := s.command.RemoveInstanceMember(ctx, authz.GetInstance(ctx).InstanceID(), req.UserId) if err != nil { return nil, err } diff --git a/internal/api/grpc/admin/iam_member_converter.go b/internal/api/grpc/admin/iam_member_converter.go index 2fe75214fd..695711d9cc 100644 --- a/internal/api/grpc/admin/iam_member_converter.go +++ b/internal/api/grpc/admin/iam_member_converter.go @@ -3,23 +3,25 @@ package admin import ( member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" - "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin" member_pb "github.com/zitadel/zitadel/pkg/grpc/member" ) -func AddIAMMemberToDomain(req *admin_pb.AddIAMMemberRequest) *domain.Member { - return &domain.Member{ - UserID: req.UserId, - Roles: req.Roles, +func AddIAMMemberToCommand(req *admin_pb.AddIAMMemberRequest, instanceID string) *command.AddInstanceMember { + return &command.AddInstanceMember{ + InstanceID: instanceID, + UserID: req.UserId, + Roles: req.Roles, } } -func UpdateIAMMemberToDomain(req *admin_pb.UpdateIAMMemberRequest) *domain.Member { - return &domain.Member{ - UserID: req.UserId, - Roles: req.Roles, +func UpdateIAMMemberToCommand(req *admin_pb.UpdateIAMMemberRequest, instanceID string) *command.ChangeInstanceMember { + return &command.ChangeInstanceMember{ + InstanceID: instanceID, + UserID: req.UserId, + Roles: req.Roles, } } diff --git a/internal/api/grpc/admin/iam_member_converter_test.go b/internal/api/grpc/admin/iam_member_converter_test.go index 74dd329ee1..70e282d621 100644 --- a/internal/api/grpc/admin/iam_member_converter_test.go +++ b/internal/api/grpc/admin/iam_member_converter_test.go @@ -27,7 +27,7 @@ func TestAddIAMMemberToDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := AddIAMMemberToDomain(tt.args.req) + got := AddIAMMemberToCommand(tt.args.req, "INSTANCE") test.AssertFieldsMapped(t, got, "ObjectRoot") }) } @@ -53,7 +53,7 @@ func TestUpdateIAMMemberToDomain(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := UpdateIAMMemberToDomain(tt.args.req) + got := UpdateIAMMemberToCommand(tt.args.req, "INSTANCE") test.AssertFieldsMapped(t, got, "ObjectRoot") }) } diff --git a/internal/api/grpc/admin/import.go b/internal/api/grpc/admin/import.go index 84b0215f03..ac085c135b 100644 --- a/internal/api/grpc/admin/import.go +++ b/internal/api/grpc/admin/import.go @@ -1046,7 +1046,7 @@ func importOrg2(ctx context.Context, s *Server, errors *[]*admin_pb.ImportDataEr if org.UserGrants != nil { for _, grant := range org.GetUserGrants() { logging.Debugf("import usergrant: %s", grant.GetProjectId()+"_"+grant.GetUserId()) - _, err := s.command.AddUserGrant(ctx, management.AddUserGrantRequestToDomain(grant), org.GetOrgId()) + _, err := s.command.AddUserGrant(ctx, management.AddUserGrantRequestToDomain(grant, org.GetOrgId()), nil) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "user_grant", Id: org.GetOrgId() + "_" + grant.GetProjectId() + "_" + grant.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1088,7 +1088,7 @@ func importOrgMembers(ctx context.Context, s *Server, errors *[]*admin_pb.Import } for _, member := range org.GetOrgMembers() { logging.Debugf("import orgmember: %s", member.GetUserId()) - _, err := s.command.AddOrgMember(ctx, org.GetOrgId(), member.GetUserId(), member.GetRoles()...) + _, err := s.command.AddOrgMember(ctx, management.AddOrgMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "org_member", Id: org.GetOrgId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1112,7 +1112,7 @@ func importProjectGrantMembers(ctx context.Context, s *Server, errors *[]*admin_ } for _, member := range org.GetProjectGrantMembers() { logging.Debugf("import projectgrantmember: %s", member.GetProjectId()+"_"+member.GetGrantId()+"_"+member.GetUserId()) - _, err := s.command.AddProjectGrantMember(ctx, management.AddProjectGrantMemberRequestToDomain(member)) + _, err := s.command.AddProjectGrantMember(ctx, management.AddProjectGrantMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_grant_member", Id: org.GetOrgId() + "_" + member.GetProjectId() + "_" + member.GetGrantId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { @@ -1136,7 +1136,7 @@ func importProjectMembers(ctx context.Context, s *Server, errors *[]*admin_pb.Im } for _, member := range org.GetProjectMembers() { logging.Debugf("import orgmember: %s", member.GetProjectId()+"_"+member.GetUserId()) - _, err := s.command.AddProjectMember(ctx, management.AddProjectMemberRequestToDomain(member), org.GetOrgId()) + _, err := s.command.AddProjectMember(ctx, management.AddProjectMemberRequestToCommand(member, org.GetOrgId())) if err != nil { *errors = append(*errors, &admin_pb.ImportDataError{Type: "project_member", Id: org.GetOrgId() + "_" + member.GetProjectId() + "_" + member.GetUserId(), Message: err.Error()}) if isCtxTimeout(ctx) { diff --git a/internal/api/grpc/admin/integration_test/import_test.go b/internal/api/grpc/admin/integration_test/import_test.go index 4a546bbcf1..3f0d364aec 100644 --- a/internal/api/grpc/admin/integration_test/import_test.go +++ b/internal/api/grpc/admin/integration_test/import_test.go @@ -457,7 +457,7 @@ func TestServer_ImportData(t *testing.T) { { Type: "project_grant_member", Id: orgIDs[5] + "_" + projectIDs[4] + "_" + grantIDs[5] + "_" + userIDs[2], - Message: "ID=V3-DKcYh Message=Errors.Project.Member.AlreadyExists Parent=(ERROR: duplicate key value violates unique constraint \"unique_constraints_pkey\" (SQLSTATE 23505))", + Message: "ID=PROJECT-37fug Message=Errors.AlreadyExists", }, }, Success: &admin.ImportDataSuccess{ diff --git a/internal/api/grpc/app/v2beta/app.go b/internal/api/grpc/app/v2beta/app.go 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/auth/user.go b/internal/api/grpc/auth/user.go index 13f955fd81..4015b0d370 100644 --- a/internal/api/grpc/auth/user.go +++ b/internal/api/grpc/auth/user.go @@ -32,7 +32,7 @@ func (s *Server) RemoveMyUser(ctx context.Context, _ *auth_pb.RemoveMyUserReques return nil, err } queries := &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantUserID}} - grants, err := s.query.UserGrants(ctx, queries, true) + grants, err := s.query.UserGrants(ctx, queries, true, nil) if err != nil { return nil, err } @@ -97,7 +97,7 @@ func (s *Server) ListMyMetadata(ctx context.Context, req *auth_pb.ListMyMetadata if err != nil { return nil, err } - res, err := s.query.SearchUserMetadata(ctx, true, authz.GetCtxData(ctx).UserID, queries, false) + res, err := s.query.SearchUserMetadata(ctx, true, authz.GetCtxData(ctx).UserID, queries, nil) if err != nil { return nil, err } @@ -151,7 +151,7 @@ func (s *Server) ListMyUserGrants(ctx context.Context, req *auth_pb.ListMyUserGr if err != nil { return nil, err } - res, err := s.query.UserGrants(ctx, queries, false) + res, err := s.query.UserGrants(ctx, queries, false, nil) if err != nil { return nil, err } @@ -180,7 +180,7 @@ func (s *Server) ListMyProjectOrgs(ctx context.Context, req *auth_pb.ListMyProje return nil, err } - grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantProjectID, userGrantUserID}}, false) + grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantProjectID, userGrantUserID}}, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/authorization/v2beta/authorization.go b/internal/api/grpc/authorization/v2beta/authorization.go new file mode 100644 index 0000000000..f5410c959a --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/authorization.go @@ -0,0 +1,76 @@ +package authorization + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/eventstore/v1/models" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" +) + +func (s *Server) CreateAuthorization(ctx context.Context, req *connect.Request[authorization.CreateAuthorizationRequest]) (*connect.Response[authorization.CreateAuthorizationResponse], error) { + grant := &domain.UserGrant{ + UserID: req.Msg.UserId, + ProjectID: req.Msg.ProjectId, + RoleKeys: req.Msg.RoleKeys, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: req.Msg.GetOrganizationId(), + }, + } + grant, err := s.command.AddUserGrant(ctx, grant, s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.CreateAuthorizationResponse{ + Id: grant.AggregateID, + CreationDate: timestamppb.New(grant.ChangeDate), + }), nil +} + +func (s *Server) UpdateAuthorization(ctx context.Context, request *connect.Request[authorization.UpdateAuthorizationRequest]) (*connect.Response[authorization.UpdateAuthorizationResponse], error) { + userGrant, err := s.command.ChangeUserGrant(ctx, &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: request.Msg.Id, + }, + RoleKeys: request.Msg.RoleKeys, + }, true, true, s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.UpdateAuthorizationResponse{ + ChangeDate: timestamppb.New(userGrant.ChangeDate), + }), nil +} + +func (s *Server) DeleteAuthorization(ctx context.Context, request *connect.Request[authorization.DeleteAuthorizationRequest]) (*connect.Response[authorization.DeleteAuthorizationResponse], error) { + details, err := s.command.RemoveUserGrant(ctx, request.Msg.Id, "", true, s.command.NewPermissionCheckUserGrantDelete(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.DeleteAuthorizationResponse{ + DeletionDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) ActivateAuthorization(ctx context.Context, request *connect.Request[authorization.ActivateAuthorizationRequest]) (*connect.Response[authorization.ActivateAuthorizationResponse], error) { + details, err := s.command.ReactivateUserGrant(ctx, request.Msg.Id, "", s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.ActivateAuthorizationResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} + +func (s *Server) DeactivateAuthorization(ctx context.Context, request *connect.Request[authorization.DeactivateAuthorizationRequest]) (*connect.Response[authorization.DeactivateAuthorizationResponse], error) { + details, err := s.command.DeactivateUserGrant(ctx, request.Msg.Id, "", s.command.NewPermissionCheckUserGrantWrite(ctx)) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.DeactivateAuthorizationResponse{ + ChangeDate: timestamppb.New(details.EventDate), + }), nil +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go b/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go new file mode 100644 index 0000000000..d24844f2a2 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/authorization_test.go @@ -0,0 +1,1023 @@ +//go:build integration + +package authorization_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/integration" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_CreateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.CreateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + name: "add authorization, project owned, PROJECT_OWNER, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, PROJECT_OWNER, no org id, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, ORG_OWNER, ok", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateOrgMembership(t, IAMCTX, selfOrgId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "add authorization, project owned, no permission, error", + args: args{ + + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, role does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, project does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + request.ProjectId = gofakeit.AppName() + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, org does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = gu.Ptr(gofakeit.AppName()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + { + name: "add authorization, project owner, project granted, no permission", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + Instance.CreateProjectGrant(IAMCTX, t, request.ProjectId, selfOrgId, request.RoleKeys...) + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, role key not granted, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + Instance.CreateProjectGrant(IAMCTX, t, request.ProjectId, selfOrgId) + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "add authorization, grant does not exist, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + projectID := Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.ProjectId = projectID + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, projectID, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectID, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + { + name: "add authorization, PROJECT_OWNER on wrong org, error", + args: args{ + func(t *testing.T, request *authorization.CreateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + request.OrganizationId = &selfOrgId + foreignOrg := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()) + request.ProjectId = Instance.CreateProject(IAMCTX, t, foreignOrg.OrganizationId, gofakeit.AppName(), false, false).Id + request.RoleKeys = []string{gofakeit.AppName()} + Instance.AddProjectRole(IAMCTX, t, request.ProjectId, request.RoleKeys[0], gofakeit.AppName(), "") + request.UserId = Instance.Users.Get(integration.UserTypeIAMOwner).ID + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrg.OrganizationId}), request.ProjectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.CreateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + got, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.Id, "id is empty") + creationDate := got.CreationDate.AsTime() + assert.Greater(t, creationDate, now, "creation date is before the test started") + assert.Less(t, creationDate, time.Now(), "creation date is in the future") + }) + } +} + +func TestServer_UpdateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.UpdateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantChangedDateDuringPrepare bool + }{ + { + name: "update authorization, owned project, ok", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "update authorization, owned project, role not found, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1, projectRole2, gofakeit.AppName()} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "update authorization, owned project, unchanged, ok, changed date is creation date", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1, projectRole2} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantChangedDateDuringPrepare: true, + }, + { + name: "update authorization, granted project, ok", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + token := createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId) + return integration.WithAuthorizationToken(EmptyCTX, token) + + }, + }, + }, + { + name: "update authorization, granted project, role not granted, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectId, projectRole3, projectRole3} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectGrantMembership(t, IAMCTX, projectId, selfOrgId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "update authorization, granted project, grant removed, error", + args: args{ + func(t *testing.T, request *authorization.UpdateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + request.RoleKeys = []string{projectRole1} + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectGrantMembership(t, IAMCTX, projectId, selfOrgId, callingUser.Id) + _, err = Instance.Client.Projectv2Beta.DeleteProjectGrant(IAMCTX, &project.DeleteProjectGrantRequest{ + ProjectId: projectId, + GrantedOrganizationId: selfOrgId, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.UpdateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.UpdateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantChangedDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func TestServer_DeleteAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.DeleteAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "delete authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "delete authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "delete authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "delete authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + + }, + }, + }, + { + name: "delete authorization, already deleted, ok, deletion date is creation date", + args: args{ + func(t *testing.T, request *authorization.DeleteAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + _, err = Instance.Client.AuthorizationV2Beta.DeleteAuthorization(IAMCTX, &authorization.DeleteAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.DeleteAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.DeleteAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.DeletionDate, "deletion date is empty") + changeDate := got.DeletionDate.AsTime() + assert.Greater(t, changeDate, now, "deletion date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "deletion date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "deletion date is in the future") + } + }) + } +} + +func TestServer_DeactivateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.DeactivateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "deactivate authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "deactivate authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "deactivate authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "deactivate authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + }, + }, + }, + { + name: "deactivate authorization, already inactive, ok, change date is creation date", + args: args{ + func(t *testing.T, request *authorization.DeactivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.DeactivateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func TestServer_ActivateAuthorization(t *testing.T) { + type args struct { + prepare func(*testing.T, *authorization.ActivateAuthorizationRequest) context.Context + } + tests := []struct { + name string + args args + wantErr bool + wantDeletionDateDuringPrepare bool + }{ + { + name: "activate authorization, project owned by calling users org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, { + name: "activate authorization, owned project, user membership on project owning org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + }, + { + name: "activate authorization, granted project, user membership on project owning org, error", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, authz.SetCtxData(IAMCTX, authz.CtxData{OrgID: foreignOrgId}), projectId, callingUser.Id) + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantErr: true, + }, + { + name: "activate authorization, granted project, user membership on project granted org, ok", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + foreignOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, foreignOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + projectRole3 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole3, projectRole3, "") + Instance.CreateProjectGrant(IAMCTX, t, projectId, selfOrgId, projectRole1, projectRole2) + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + OrganizationId: &selfOrgId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + _, err = Instance.Client.AuthorizationV2Beta.DeactivateAuthorization(IAMCTX, &authorization.DeactivateAuthorizationRequest{ + Id: preparedAuthorization.Id, + }) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, createUserWithProjectGrantMembership(IAMCTX, t, Instance, projectId, selfOrgId)) + }, + }, + }, + { + name: "activate authorization, already active, ok, change date is creation date", + args: args{ + func(t *testing.T, request *authorization.ActivateAuthorizationRequest) context.Context { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + projectId := Instance.CreateProject(IAMCTX, t, selfOrgId, gofakeit.AppName(), false, false).Id + projectRole1 := gofakeit.AppName() + projectRole2 := gofakeit.AppName() + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole1, projectRole1, "") + Instance.AddProjectRole(IAMCTX, t, projectId, projectRole2, projectRole2, "") + preparedAuthorization, err := Instance.Client.AuthorizationV2Beta.CreateAuthorization(IAMCTX, &authorization.CreateAuthorizationRequest{ + UserId: Instance.Users.Get(integration.UserTypeIAMOwner).ID, + ProjectId: projectId, + RoleKeys: []string{projectRole1, projectRole2}, + }) + require.NoError(t, err) + request.Id = preparedAuthorization.Id + callingUser := Instance.CreateUserTypeMachine(IAMCTX, selfOrgId) + Instance.CreateProjectMembership(t, IAMCTX, projectId, callingUser.Id) + token, err := Instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + return integration.WithAuthorizationToken(EmptyCTX, token.Token) + }, + }, + wantDeletionDateDuringPrepare: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + req := &authorization.ActivateAuthorizationRequest{} + ctx := tt.args.prepare(t, req) + afterPrepare := time.Now() + got, err := Instance.Client.AuthorizationV2Beta.ActivateAuthorization(ctx, req) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.NotEmpty(t, got.ChangeDate, "change date is empty") + changeDate := got.ChangeDate.AsTime() + assert.Greater(t, changeDate, now, "change date is before the test started") + if tt.wantDeletionDateDuringPrepare { + assert.Less(t, changeDate, afterPrepare, "change date is after prepare finished") + } else { + assert.Less(t, changeDate, time.Now(), "change date is in the future") + } + }) + } +} + +func createUserWithProjectGrantMembership(ctx context.Context, t *testing.T, instance *integration.Instance, projectID, grantID string) string { + selfOrgId := Instance.CreateOrganization(IAMCTX, gofakeit.AppName(), gofakeit.Email()).OrganizationId + callingUser := instance.CreateUserTypeMachine(ctx, selfOrgId) + instance.CreateProjectGrantMembership(t, ctx, projectID, grantID, callingUser.Id) + token, err := instance.Client.UserV2.AddPersonalAccessToken(IAMCTX, &user.AddPersonalAccessTokenRequest{UserId: callingUser.Id, ExpirationDate: timestamppb.New(time.Now().Add(24 * time.Hour))}) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 10*time.Minute) + require.EventuallyWithT(t, func(tt *assert.CollectT) { + got, err := instance.Client.AuthorizationV2Beta.ListAuthorizations(ctx, &authorization.ListAuthorizationsRequest{ + Filters: nil, + }) + assert.NoError(tt, err) + if !assert.NotEmpty(tt, got.Pagination.TotalResult) { + return + } + }, retryDuration, tick) + return token.GetToken() +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/query_test.go b/internal/api/grpc/authorization/v2beta/integration_test/query_test.go new file mode 100644 index 0000000000..fd0a807c1d --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/query_test.go @@ -0,0 +1,971 @@ +//go:build integration + +package authorization_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_ListAuthorizations(t *testing.T) { + iamOwnerCtx := Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + projectOwnerResp := Instance.CreateMachineUser(iamOwnerCtx) + projectOwnerPatResp := Instance.CreatePersonalAccessToken(iamOwnerCtx, projectOwnerResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), false, false) + Instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), projectOwnerResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectOwnerPatResp.Token) + + projectGrantOwnerResp := Instance.CreateMachineUser(iamOwnerCtx) + projectGrantOwnerPatResp := Instance.CreatePersonalAccessToken(iamOwnerCtx, projectGrantOwnerResp.GetUserId()) + grantedProjectResp := createGrantedProject(iamOwnerCtx, Instance, t, projectResp) + Instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId(), projectGrantOwnerResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectGrantOwnerPatResp.Token) + + type args struct { + ctx context.Context + dep func(*authorization.ListAuthorizationsRequest, *authorization.ListAuthorizationsResponse) + req *authorization.ListAuthorizationsRequest + } + tests := []struct { + name string + args args + want *authorization.ListAuthorizationsResponse + wantErr bool + }{ + { + name: "list by user id, unauthenticated", + args: args{ + ctx: EmptyCTX, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeNoPermission), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{ + {Filter: &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: "notexisting", + }, + }, + }, + }, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_AuthorizationIds{ + AuthorizationIds: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectId{ + ProjectId: &filter.IDFilter{ + Id: resp.GetProjectId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectName{ + ProjectName: &authorization.ProjectNameQuery{ + Name: resp.GetProjectName(), + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single grant id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectGrantId{ + ProjectGrantId: &filter.IDFilter{ + Id: resp.GetProjectGrantId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, multiple", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[5] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[4] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[3] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[2] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[1] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[0] = createAuthorization(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 6, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, {}, {}, {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, org owner", + args: args{ + ctx: Instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeOrgOwner), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[0] = createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + createAuthorizationWithProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := Instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + createAuthorizationForProject(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationForProjectGrant(iamOwnerCtx, Instance, t, Instance.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Instance.Client.AuthorizationV2Beta.ListAuthorizations(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Authorizations, len(tt.want.Authorizations)) { + for i := range tt.want.Authorizations { + assert.EqualExportedValues(ttt, tt.want.Authorizations[i], got.Authorizations[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} + +func createAuthorization(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID string, grant bool) *authorization.Authorization { + projectName := gofakeit.AppName() + projectResp := instance.CreateProject(ctx, t, orgID, projectName, false, false) + + if grant { + return createAuthorizationWithProjectGrant(ctx, instance, t, orgID, userID, projectName, projectResp.GetId()) + } + return createAuthorizationForProject(ctx, instance, t, orgID, userID, projectName, projectResp.GetId()) +} + +func createAuthorizationForProject(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID string) *authorization.Authorization { + userResp, err := instance.Client.UserV2.GetUserByID(ctx, &user.GetUserByIDRequest{UserId: userID}) + require.NoError(t, err) + + userGrantResp := instance.CreateProjectUserGrant(t, ctx, projectID, userID) + return &authorization.Authorization{ + Id: userGrantResp.GetUserGrantId(), + ProjectId: projectID, + ProjectName: projectName, + ProjectOrganizationId: orgID, + OrganizationId: orgID, + CreationDate: userGrantResp.Details.GetCreationDate(), + ChangeDate: userGrantResp.Details.GetCreationDate(), + State: 1, + User: &authorization.User{ + Id: userID, + PreferredLoginName: userResp.User.GetPreferredLoginName(), + DisplayName: userResp.User.GetHuman().GetProfile().GetDisplayName(), + AvatarUrl: userResp.User.GetHuman().GetProfile().GetAvatarUrl(), + OrganizationId: userResp.GetUser().GetDetails().GetResourceOwner(), + }, + } +} + +func createAuthorizationWithProjectGrant(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID string) *authorization.Authorization { + grantedOrgName := gofakeit.Company() + integration.RandString(10) + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + instance.CreateProjectGrant(ctx, t, projectID, grantedOrg.GetOrganizationId()) + + return createAuthorizationForProjectGrant(ctx, instance, t, orgID, userID, projectName, projectID, grantedOrg.GetOrganizationId()) +} + +func createAuthorizationForProjectGrant(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, userID, projectName, projectID, grantedOrgID string) *authorization.Authorization { + userResp, err := instance.Client.UserV2.GetUserByID(ctx, &user.GetUserByIDRequest{UserId: userID}) + require.NoError(t, err) + + userGrantResp := instance.CreateProjectGrantUserGrant(ctx, orgID, projectID, grantedOrgID, userID) + return &authorization.Authorization{ + Id: userGrantResp.GetUserGrantId(), + ProjectId: projectID, + ProjectName: projectName, + ProjectOrganizationId: orgID, + ProjectGrantId: gu.Ptr(grantedOrgID), + GrantedOrganizationId: gu.Ptr(grantedOrgID), + OrganizationId: orgID, + CreationDate: userGrantResp.Details.GetCreationDate(), + ChangeDate: userGrantResp.Details.GetCreationDate(), + State: 1, + User: &authorization.User{ + Id: userID, + PreferredLoginName: userResp.User.GetPreferredLoginName(), + DisplayName: userResp.User.GetHuman().GetProfile().GetDisplayName(), + AvatarUrl: userResp.User.GetHuman().GetProfile().GetAvatarUrl(), + OrganizationId: userResp.GetUser().GetDetails().GetResourceOwner(), + }, + } +} + +func createProject(ctx context.Context, instance *integration.Instance, t *testing.T, orgID string, projectRoleCheck, hasProjectCheck bool) *project.Project { + name := gofakeit.AppName() + resp := instance.CreateProject(ctx, t, orgID, name, projectRoleCheck, hasProjectCheck) + return &project.Project{ + Id: resp.GetId(), + Name: name, + OrganizationId: orgID, + CreationDate: resp.GetCreationDate(), + ChangeDate: resp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: hasProjectCheck, + AuthorizationRequired: projectRoleCheck, + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + } +} + +func createGrantedProject(ctx context.Context, instance *integration.Instance, t *testing.T, projectToGrant *project.Project) *project.Project { + grantedOrgName := gofakeit.AppName() + grantedOrg := instance.CreateOrganization(ctx, grantedOrgName, gofakeit.Email()) + projectGrantResp := instance.CreateProjectGrant(ctx, t, projectToGrant.GetId(), grantedOrg.GetOrganizationId()) + + return &project.Project{ + Id: projectToGrant.GetId(), + Name: projectToGrant.GetName(), + OrganizationId: projectToGrant.GetOrganizationId(), + CreationDate: projectGrantResp.GetCreationDate(), + ChangeDate: projectGrantResp.GetCreationDate(), + State: 1, + ProjectRoleAssertion: false, + ProjectAccessRequired: projectToGrant.GetProjectAccessRequired(), + AuthorizationRequired: projectToGrant.GetAuthorizationRequired(), + PrivateLabelingSetting: project.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_UNSPECIFIED, + GrantedOrganizationId: gu.Ptr(grantedOrg.GetOrganizationId()), + GrantedOrganizationName: gu.Ptr(grantedOrgName), + GrantedState: 1, + } +} + +func TestServer_ListAuthorizations_PermissionsV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, InstancePermissionV2) + iamOwnerCtx := InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + + projectOwnerResp := InstancePermissionV2.CreateMachineUser(iamOwnerCtx) + projectOwnerPatResp := InstancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, projectOwnerResp.GetUserId()) + projectResp := createProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), false, false) + InstancePermissionV2.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), projectOwnerResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectOwnerPatResp.Token) + + //projectGrantOwnerResp := InstancePermissionV2.CreateMachineUser(iamOwnerCtx) + //projectGrantOwnerPatResp := InstancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, projectGrantOwnerResp.GetUserId()) + grantedProjectResp := createGrantedProject(iamOwnerCtx, InstancePermissionV2, t, projectResp) + //InstancePermissionV2.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), grantedProjectResp.GetGrantedOrganizationId(), projectGrantOwnerResp.GetUserId()) + //projectGrantOwnerCtx := integration.WithAuthorizationToken(EmptyCTX, projectGrantOwnerPatResp.Token) + + type args struct { + ctx context.Context + dep func(*authorization.ListAuthorizationsRequest, *authorization.ListAuthorizationsResponse) + req *authorization.ListAuthorizationsRequest + } + tests := []struct { + name string + args args + want *authorization.ListAuthorizationsResponse + wantErr bool + }{ + { + name: "list by user id, unauthenticated", + args: args{ + ctx: EmptyCTX, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeNoPermission), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{ + {Filter: &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: "notexisting", + }, + }, + }, + }, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_AuthorizationIds{ + AuthorizationIds: &filter.InIDsFilter{ + Ids: []string{resp.GetId()}, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project id", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectId{ + ProjectId: &filter.IDFilter{ + Id: resp.GetProjectId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single project name", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectName{ + ProjectName: &authorization.ProjectNameQuery{ + Name: resp.GetProjectName(), + Method: filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS, + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single grant id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + resp := createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_ProjectGrantId{ + ProjectGrantId: &filter.IDFilter{ + Id: resp.GetProjectGrantId(), + }, + } + response.Authorizations[0] = resp + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + { + name: "list single id, project and project grant, multiple", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + response.Authorizations[5] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[4] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[3] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), false) + response.Authorizations[2] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[1] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + response.Authorizations[0] = createAuthorization(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), true) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 6, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, {}, {}, {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, org owner", + args: args{ + ctx: InstancePermissionV2.WithAuthorizationToken(EmptyCTX, integration.UserTypeOrgOwner), + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + { + name: "list single id, project and project grant, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + response.Authorizations[1] = createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationWithProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, {}, + }, + }, + }, + /* + TODO: correct when permission check is added for project grants https://github.com/zitadel/zitadel/issues/9972 + { + name: "list single id, project and project grant, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *authorization.ListAuthorizationsRequest, response *authorization.ListAuthorizationsResponse) { + userResp := InstancePermissionV2.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.Filters[0].Filter = &authorization.AuthorizationsSearchFilter_UserId{ + UserId: &filter.IDFilter{ + Id: userResp.GetId(), + }, + } + + createAuthorizationForProject(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), projectResp.GetName(), projectResp.GetId()) + response.Authorizations[0] = createAuthorizationForProjectGrant(iamOwnerCtx, InstancePermissionV2, t, InstancePermissionV2.DefaultOrg.GetId(), userResp.GetId(), grantedProjectResp.GetName(), grantedProjectResp.GetId()) + }, + req: &authorization.ListAuthorizationsRequest{ + Filters: []*authorization.AuthorizationsSearchFilter{{}}, + }, + }, + want: &authorization.ListAuthorizationsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Authorizations: []*authorization.Authorization{ + {}, + }, + }, + }, + */ + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := InstancePermissionV2.Client.AuthorizationV2Beta.ListAuthorizations(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Authorizations, len(tt.want.Authorizations)) { + for i := range tt.want.Authorizations { + assert.EqualExportedValues(ttt, tt.want.Authorizations[i], got.Authorizations[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} diff --git a/internal/api/grpc/authorization/v2beta/integration_test/server_test.go b/internal/api/grpc/authorization/v2beta/integration_test/server_test.go new file mode 100644 index 0000000000..fc59713708 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/integration_test/server_test.go @@ -0,0 +1,65 @@ +//go:build integration + +package authorization_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +var ( + EmptyCTX context.Context + IAMCTX context.Context + Instance *integration.Instance + InstancePermissionV2 *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + EmptyCTX = ctx + Instance = integration.NewInstance(ctx) + IAMCTX = Instance.WithAuthorizationToken(ctx, integration.UserTypeIAMOwner) + InstancePermissionV2 = integration.NewInstance(ctx) + return m.Run() + }()) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorizationToken(EmptyCTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + assert.NoError(ttt, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/authorization/v2beta/query.go b/internal/api/grpc/authorization/v2beta/query.go new file mode 100644 index 0000000000..75c3d67178 --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/query.go @@ -0,0 +1,208 @@ +package authorization + +import ( + "context" + "errors" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" +) + +func (s *Server) ListAuthorizations(ctx context.Context, req *connect.Request[authorization.ListAuthorizationsRequest]) (*connect.Response[authorization.ListAuthorizationsResponse], error) { + queries, err := s.listAuthorizationsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.UserGrants(ctx, queries, false, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&authorization.ListAuthorizationsResponse{ + Authorizations: userGrantsToPb(resp.UserGrants), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) listAuthorizationsRequestToModel(req *authorization.ListAuthorizationsRequest) (*query.UserGrantsQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := AuthorizationQueriesToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.UserGrantsQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: authorizationFieldNameToSortingColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func authorizationFieldNameToSortingColumn(field authorization.AuthorizationFieldName) query.Column { + switch field { + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_UNSPECIFIED: + return query.UserGrantCreationDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_CREATED_DATE: + return query.UserGrantCreationDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_CHANGED_DATE: + return query.UserGrantChangeDate + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_ID: + return query.UserGrantID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_USER_ID: + return query.UserGrantUserID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_PROJECT_ID: + return query.UserGrantProjectID + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_ORGANIZATION_ID: + return query.UserGrantResourceOwner + case authorization.AuthorizationFieldName_AUTHORIZATION_FIELD_NAME_USER_ORGANIZATION_ID: + return query.UserResourceOwnerCol + default: + return query.UserGrantCreationDate + } +} + +func AuthorizationQueriesToQuery(queries []*authorization.AuthorizationsSearchFilter) (q []query.SearchQuery, err error) { + q = make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = AuthorizationSearchFilterToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func AuthorizationSearchFilterToQuery(query *authorization.AuthorizationsSearchFilter) (query.SearchQuery, error) { + switch q := query.Filter.(type) { + case *authorization.AuthorizationsSearchFilter_AuthorizationIds: + return AuthorizationIDQueryToModel(q.AuthorizationIds) + case *authorization.AuthorizationsSearchFilter_OrganizationId: + return AuthorizationOrganizationIDQueryToModel(q.OrganizationId) + case *authorization.AuthorizationsSearchFilter_State: + return AuthorizationStateQueryToModel(q.State) + case *authorization.AuthorizationsSearchFilter_UserId: + return AuthorizationUserUserIDQueryToModel(q.UserId) + case *authorization.AuthorizationsSearchFilter_UserOrganizationId: + return AuthorizationUserOrganizationIDQueryToModel(q.UserOrganizationId) + case *authorization.AuthorizationsSearchFilter_UserPreferredLoginName: + return AuthorizationUserNameQueryToModel(q.UserPreferredLoginName) + case *authorization.AuthorizationsSearchFilter_UserDisplayName: + return AuthorizationDisplayNameQueryToModel(q.UserDisplayName) + case *authorization.AuthorizationsSearchFilter_ProjectId: + return AuthorizationProjectIDQueryToModel(q.ProjectId) + case *authorization.AuthorizationsSearchFilter_ProjectName: + return AuthorizationProjectNameQueryToModel(q.ProjectName) + case *authorization.AuthorizationsSearchFilter_RoleKey: + return AuthorizationRoleKeyQueryToModel(q.RoleKey) + case *authorization.AuthorizationsSearchFilter_ProjectGrantId: + return AuthorizationProjectGrantIDQueryToModel(q.ProjectGrantId) + default: + return nil, errors.New("invalid query") + } +} + +func AuthorizationIDQueryToModel(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewUserGrantInIDsSearchQuery(q.Ids) +} + +func AuthorizationDisplayNameQueryToModel(q *authorization.UserDisplayNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantDisplayNameQuery(q.DisplayName, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationOrganizationIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantResourceOwnerSearchQuery(q.Id) +} + +func AuthorizationProjectIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantProjectIDSearchQuery(q.Id) +} + +func AuthorizationProjectNameQueryToModel(q *authorization.ProjectNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantProjectNameQuery(q.Name, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationProjectGrantIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantGrantIDSearchQuery(q.Id) +} + +func AuthorizationRoleKeyQueryToModel(q *authorization.RoleKeyQuery) (query.SearchQuery, error) { + return query.NewUserGrantRoleQuery(q.Key) +} + +func AuthorizationUserNameQueryToModel(q *authorization.UserPreferredLoginNameQuery) (query.SearchQuery, error) { + return query.NewUserGrantUsernameQuery(q.LoginName, filter.TextMethodPbToQuery(q.Method)) +} + +func AuthorizationUserUserIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantUserIDSearchQuery(q.Id) +} + +func AuthorizationUserOrganizationIDQueryToModel(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewUserGrantUserResourceOwnerSearchQuery(q.Id) +} + +func AuthorizationStateQueryToModel(q *authorization.StateQuery) (query.SearchQuery, error) { + return query.NewUserGrantStateQuery(domain.UserGrantState(q.State)) +} + +func userGrantsToPb(userGrants []*query.UserGrant) []*authorization.Authorization { + o := make([]*authorization.Authorization, len(userGrants)) + for i, grant := range userGrants { + o[i] = userGrantToPb(grant) + } + return o +} + +func userGrantToPb(userGrant *query.UserGrant) *authorization.Authorization { + var grantID, grantedOrgID *string + if userGrant.GrantID != "" { + grantID = &userGrant.GrantID + } + if userGrant.GrantedOrgID != "" { + grantedOrgID = &userGrant.GrantedOrgID + } + return &authorization.Authorization{ + Id: userGrant.ID, + ProjectId: userGrant.ProjectID, + ProjectName: userGrant.ProjectName, + ProjectOrganizationId: userGrant.ProjectResourceOwner, + ProjectGrantId: grantID, + GrantedOrganizationId: grantedOrgID, + OrganizationId: userGrant.ResourceOwner, + CreationDate: timestamppb.New(userGrant.CreationDate), + ChangeDate: timestamppb.New(userGrant.ChangeDate), + State: userGrantStateToPb(userGrant.State), + User: &authorization.User{ + Id: userGrant.UserID, + PreferredLoginName: userGrant.PreferredLoginName, + DisplayName: userGrant.DisplayName, + AvatarUrl: userGrant.AvatarURL, + OrganizationId: userGrant.UserResourceOwner, + }, + Roles: userGrant.Roles, + } +} + +func userGrantStateToPb(state domain.UserGrantState) authorization.State { + switch state { + case domain.UserGrantStateActive: + return authorization.State_STATE_ACTIVE + case domain.UserGrantStateInactive: + return authorization.State_STATE_INACTIVE + case domain.UserGrantStateUnspecified, domain.UserGrantStateRemoved: + return authorization.State_STATE_UNSPECIFIED + default: + return authorization.State_STATE_UNSPECIFIED + } +} diff --git a/internal/api/grpc/authorization/v2beta/server.go b/internal/api/grpc/authorization/v2beta/server.go new file mode 100644 index 0000000000..4d66309d2a --- /dev/null +++ b/internal/api/grpc/authorization/v2beta/server.go @@ -0,0 +1,67 @@ +package authorization + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta/authorizationconnect" +) + +var _ authorizationconnect.AuthorizationServiceHandler = (*Server)(nil) + +type Server struct { + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return authorizationconnect.NewAuthorizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return authorization.File_zitadel_authorization_v2beta_authorization_service_proto +} + +func (s *Server) AppName() string { + return authorization.AuthorizationService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return authorization.AuthorizationService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return authorization.AuthorizationService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return authorization.RegisterAuthorizationServiceHandler +} diff --git a/internal/api/grpc/feature/v2/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/filter/v2/converter.go b/internal/api/grpc/filter/v2/converter.go index f797ad4bba..98e8bdd4f8 100644 --- a/internal/api/grpc/filter/v2/converter.go +++ b/internal/api/grpc/filter/v2/converter.go @@ -9,6 +9,29 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/filter/v2" ) +func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { + switch method { + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS: + return query.TextEquals + case filter.TextFilterMethod_TEXT_FILTER_METHOD_EQUALS_IGNORE_CASE: + return query.TextEqualsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH: + return query.TextStartsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_STARTS_WITH_IGNORE_CASE: + return query.TextStartsWithIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS: + return query.TextContains + case filter.TextFilterMethod_TEXT_FILTER_METHOD_CONTAINS_IGNORE_CASE: + return query.TextContainsIgnoreCase + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH: + return query.TextEndsWith + case filter.TextFilterMethod_TEXT_FILTER_METHOD_ENDS_WITH_IGNORE_CASE: + return query.TextEndsWithIgnoreCase + default: + return -1 + } +} + func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { switch method { case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: @@ -48,26 +71,3 @@ 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/filter/v2beta/converter.go b/internal/api/grpc/filter/v2beta/converter.go index e34f9dd9d7..e15895e9d9 100644 --- a/internal/api/grpc/filter/v2beta/converter.go +++ b/internal/api/grpc/filter/v2beta/converter.go @@ -32,6 +32,23 @@ func TextMethodPbToQuery(method filter.TextFilterMethod) query.TextComparison { } } +func TimestampMethodPbToQuery(method filter.TimestampFilterMethod) query.TimestampComparison { + switch method { + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_EQUALS: + return query.TimestampEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_LESS: + return query.TimestampLess + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_GREATER: + return query.TimestampGreater + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_LESS_OR_EQUALS: + return query.TimestampLessOrEquals + case filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_GREATER_OR_EQUALS: + return query.TimestampGreaterOrEquals + default: + return -1 + } +} + func PaginationPbToQuery(defaults systemdefaults.SystemDefaults, query *filter.PaginationRequest) (offset, limit uint64, asc bool, err error) { limit = defaults.DefaultQueryLimit if query == nil { diff --git a/internal/api/grpc/gerrors/zitadel_errors.go b/internal/api/grpc/gerrors/zitadel_errors.go index d679054da6..b5d2893062 100644 --- a/internal/api/grpc/gerrors/zitadel_errors.go +++ b/internal/api/grpc/gerrors/zitadel_errors.go @@ -3,10 +3,12 @@ package gerrors import ( "errors" + "connectrpc.com/connect" "github.com/jackc/pgx/v5/pgconn" "github.com/zitadel/logging" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/protoadapt" commandErrors "github.com/zitadel/zitadel/internal/command/errors" @@ -36,6 +38,30 @@ func ZITADELToGRPCError(err error) error { return s.Err() } +func ZITADELToConnectError(err error) error { + if err == nil { + return nil + } + connectError := new(connect.Error) + if errors.As(err, &connectError) { + return err + } + code, key, id, ok := ExtractZITADELError(err) + if !ok { + return status.Convert(err).Err() + } + msg := key + msg += " (" + id + ")" + + errorInfo := getErrorInfo(id, key, err) + + cErr := connect.NewError(connect.Code(code), errors.New(msg)) + if detail, detailErr := connect.NewErrorDetail(errorInfo.(proto.Message)); detailErr == nil { + cErr.AddDetail(detail) + } + return cErr +} + func ExtractZITADELError(err error) (c codes.Code, msg, id string, ok bool) { if err == nil { return codes.OK, "", "", false diff --git a/internal/api/grpc/idp/v2/query.go b/internal/api/grpc/idp/v2/query.go index 082a94d18f..587b1687b9 100644 --- a/internal/api/grpc/idp/v2/query.go +++ b/internal/api/grpc/idp/v2/query.go @@ -3,6 +3,7 @@ package idp import ( "context" + "connectrpc.com/connect" "github.com/crewjam/saml" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/durationpb" @@ -15,12 +16,12 @@ import ( idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" ) -func (s *Server) GetIDPByID(ctx context.Context, req *idp_pb.GetIDPByIDRequest) (*idp_pb.GetIDPByIDResponse, error) { - idp, err := s.query.IDPTemplateByID(ctx, true, req.Id, false, s.checkPermission) +func (s *Server) GetIDPByID(ctx context.Context, req *connect.Request[idp_pb.GetIDPByIDRequest]) (*connect.Response[idp_pb.GetIDPByIDResponse], error) { + idp, err := s.query.IDPTemplateByID(ctx, true, req.Msg.GetId(), false, s.checkPermission) if err != nil { return nil, err } - return &idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}, nil + return connect.NewResponse(&idp_pb.GetIDPByIDResponse{Idp: idpToPb(idp)}), nil } func idpToPb(idp *query.IDPTemplate) *idp_pb.IDP { diff --git a/internal/api/grpc/idp/v2/server.go b/internal/api/grpc/idp/v2/server.go index 246e980434..666c39294d 100644 --- a/internal/api/grpc/idp/v2/server.go +++ b/internal/api/grpc/idp/v2/server.go @@ -1,7 +1,10 @@ package idp import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/idp/v2" + "github.com/zitadel/zitadel/pkg/grpc/idp/v2/idpconnect" ) -var _ idp.IdentityProviderServiceServer = (*Server)(nil) +var _ idpconnect.IdentityProviderServiceHandler = (*Server)(nil) type Server struct { - idp.UnimplementedIdentityProviderServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - idp.RegisterIdentityProviderServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return idpconnect.NewIdentityProviderServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return idp.File_zitadel_idp_v2_idp_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/instance/v2beta/domain.go b/internal/api/grpc/instance/v2beta/domain.go index 439c6e5d8d..380ebff5a7 100644 --- a/internal/api/grpc/instance/v2beta/domain.go +++ b/internal/api/grpc/instance/v2beta/domain.go @@ -3,48 +3,49 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) AddCustomDomain(ctx context.Context, req *instance.AddCustomDomainRequest) (*instance.AddCustomDomainResponse, error) { - details, err := s.command.AddInstanceDomain(ctx, req.GetDomain()) +func (s *Server) AddCustomDomain(ctx context.Context, req *connect.Request[instance.AddCustomDomainRequest]) (*connect.Response[instance.AddCustomDomainResponse], error) { + details, err := s.command.AddInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddCustomDomainResponse{ + return connect.NewResponse(&instance.AddCustomDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveCustomDomain(ctx context.Context, req *instance.RemoveCustomDomainRequest) (*instance.RemoveCustomDomainResponse, error) { - details, err := s.command.RemoveInstanceDomain(ctx, req.GetDomain()) +func (s *Server) RemoveCustomDomain(ctx context.Context, req *connect.Request[instance.RemoveCustomDomainRequest]) (*connect.Response[instance.RemoveCustomDomainResponse], error) { + details, err := s.command.RemoveInstanceDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveCustomDomainResponse{ + return connect.NewResponse(&instance.RemoveCustomDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) AddTrustedDomain(ctx context.Context, req *instance.AddTrustedDomainRequest) (*instance.AddTrustedDomainResponse, error) { - details, err := s.command.AddTrustedDomain(ctx, req.GetDomain()) +func (s *Server) AddTrustedDomain(ctx context.Context, req *connect.Request[instance.AddTrustedDomainRequest]) (*connect.Response[instance.AddTrustedDomainResponse], error) { + details, err := s.command.AddTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.AddTrustedDomainResponse{ + return connect.NewResponse(&instance.AddTrustedDomainResponse{ CreationDate: timestamppb.New(details.CreationDate), - }, nil + }), nil } -func (s *Server) RemoveTrustedDomain(ctx context.Context, req *instance.RemoveTrustedDomainRequest) (*instance.RemoveTrustedDomainResponse, error) { - details, err := s.command.RemoveTrustedDomain(ctx, req.GetDomain()) +func (s *Server) RemoveTrustedDomain(ctx context.Context, req *connect.Request[instance.RemoveTrustedDomainRequest]) (*connect.Response[instance.RemoveTrustedDomainResponse], error) { + details, err := s.command.RemoveTrustedDomain(ctx, req.Msg.GetDomain()) if err != nil { return nil, err } - return &instance.RemoveTrustedDomainResponse{ + return connect.NewResponse(&instance.RemoveTrustedDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/instance.go b/internal/api/grpc/instance/v2beta/instance.go index b1c36e74bb..b3f2d6e478 100644 --- a/internal/api/grpc/instance/v2beta/instance.go +++ b/internal/api/grpc/instance/v2beta/instance.go @@ -3,30 +3,31 @@ package instance import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) DeleteInstance(ctx context.Context, request *instance.DeleteInstanceRequest) (*instance.DeleteInstanceResponse, error) { - obj, err := s.command.RemoveInstance(ctx, request.GetInstanceId()) +func (s *Server) DeleteInstance(ctx context.Context, request *connect.Request[instance.DeleteInstanceRequest]) (*connect.Response[instance.DeleteInstanceResponse], error) { + obj, err := s.command.RemoveInstance(ctx, request.Msg.GetInstanceId()) if err != nil { return nil, err } - return &instance.DeleteInstanceResponse{ + return connect.NewResponse(&instance.DeleteInstanceResponse{ DeletionDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } -func (s *Server) UpdateInstance(ctx context.Context, request *instance.UpdateInstanceRequest) (*instance.UpdateInstanceResponse, error) { - obj, err := s.command.UpdateInstance(ctx, request.GetInstanceName()) +func (s *Server) UpdateInstance(ctx context.Context, request *connect.Request[instance.UpdateInstanceRequest]) (*connect.Response[instance.UpdateInstanceResponse], error) { + obj, err := s.command.UpdateInstance(ctx, request.Msg.GetInstanceName()) if err != nil { return nil, err } - return &instance.UpdateInstanceResponse{ + return connect.NewResponse(&instance.UpdateInstanceResponse{ ChangeDate: timestamppb.New(obj.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/query.go b/internal/api/grpc/instance/v2beta/query.go index 74f79313ea..10716ffda0 100644 --- a/internal/api/grpc/instance/v2beta/query.go +++ b/internal/api/grpc/instance/v2beta/query.go @@ -3,23 +3,25 @@ package instance import ( "context" + "connectrpc.com/connect" + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" ) -func (s *Server) GetInstance(ctx context.Context, _ *instance.GetInstanceRequest) (*instance.GetInstanceResponse, error) { +func (s *Server) GetInstance(ctx context.Context, _ *connect.Request[instance.GetInstanceRequest]) (*connect.Response[instance.GetInstanceResponse], error) { inst, err := s.query.Instance(ctx, true) if err != nil { return nil, err } - return &instance.GetInstanceResponse{ + return connect.NewResponse(&instance.GetInstanceResponse{ Instance: ToProtoObject(inst), - }, nil + }), nil } -func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesRequest) (*instance.ListInstancesResponse, error) { - queries, err := ListInstancesRequestToModel(req, s.systemDefaults) +func (s *Server) ListInstances(ctx context.Context, req *connect.Request[instance.ListInstancesRequest]) (*connect.Response[instance.ListInstancesResponse], error) { + queries, err := ListInstancesRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -29,14 +31,14 @@ func (s *Server) ListInstances(ctx context.Context, req *instance.ListInstancesR return nil, err } - return &instance.ListInstancesResponse{ + return connect.NewResponse(&instance.ListInstancesResponse{ Instances: InstancesToPb(instances.Instances), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, instances.SearchResponse), - }, nil + }), nil } -func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustomDomainsRequest) (*instance.ListCustomDomainsResponse, error) { - queries, err := ListCustomDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListCustomDomains(ctx context.Context, req *connect.Request[instance.ListCustomDomainsRequest]) (*connect.Response[instance.ListCustomDomainsResponse], error) { + queries, err := ListCustomDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -46,14 +48,14 @@ func (s *Server) ListCustomDomains(ctx context.Context, req *instance.ListCustom return nil, err } - return &instance.ListCustomDomainsResponse{ + return connect.NewResponse(&instance.ListCustomDomainsResponse{ Domains: DomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } -func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrustedDomainsRequest) (*instance.ListTrustedDomainsResponse, error) { - queries, err := ListTrustedDomainsRequestToModel(req, s.systemDefaults) +func (s *Server) ListTrustedDomains(ctx context.Context, req *connect.Request[instance.ListTrustedDomainsRequest]) (*connect.Response[instance.ListTrustedDomainsResponse], error) { + queries, err := ListTrustedDomainsRequestToModel(req.Msg, s.systemDefaults) if err != nil { return nil, err } @@ -63,8 +65,8 @@ func (s *Server) ListTrustedDomains(ctx context.Context, req *instance.ListTrust return nil, err } - return &instance.ListTrustedDomainsResponse{ + return connect.NewResponse(&instance.ListTrustedDomainsResponse{ TrustedDomain: trustedDomainsToPb(domains.Domains), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, domains.SearchResponse), - }, nil + }), nil } diff --git a/internal/api/grpc/instance/v2beta/server.go b/internal/api/grpc/instance/v2beta/server.go index aaeaa4cc8f..1fb3513dd6 100644 --- a/internal/api/grpc/instance/v2beta/server.go +++ b/internal/api/grpc/instance/v2beta/server.go @@ -1,7 +1,10 @@ package instance import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/query" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta/instanceconnect" ) -var _ instance.InstanceServiceServer = (*Server)(nil) +var _ instanceconnect.InstanceServiceHandler = (*Server)(nil) type Server struct { - instance.UnimplementedInstanceServiceServer command *command.Commands query *query.Queries systemDefaults systemdefaults.SystemDefaults @@ -39,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - instance.RegisterInstanceServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return instanceconnect.NewInstanceServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return instance.File_zitadel_instance_v2beta_instance_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/internal_permission/v2beta/administrator.go b/internal/api/grpc/internal_permission/v2beta/administrator.go new file mode 100644 index 0000000000..86ee7d9454 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/administrator.go @@ -0,0 +1,220 @@ +package internal_permission + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/zerrors" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func (s *Server) CreateAdministrator(ctx context.Context, req *connect.Request[internal_permission.CreateAdministratorRequest]) (*connect.Response[internal_permission.CreateAdministratorResponse], error) { + var creationDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.AddInstanceMember(ctx, createAdministratorInstanceToCommand(authz.GetInstance(ctx).InstanceID(), req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.AddOrgMember(ctx, createAdministratorOrganizationToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.AddProjectMember(ctx, createAdministratorProjectToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.AddProjectGrantMember(ctx, createAdministratorProjectGrantToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + creationDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-IbPp47HDP5", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.CreateAdministratorResponse{ + CreationDate: creationDate, + }), nil +} + +func createAdministratorInstanceToCommand(instanceID, userID string, roles []string) *command.AddInstanceMember { + return &command.AddInstanceMember{ + InstanceID: instanceID, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorOrganizationToCommand(req *internal_permission.ResourceType_OrganizationId, userID string, roles []string) *command.AddOrgMember { + return &command.AddOrgMember{ + OrgID: req.OrganizationId, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorProjectToCommand(req *internal_permission.ResourceType_ProjectId, userID string, roles []string) *command.AddProjectMember { + return &command.AddProjectMember{ + ProjectID: req.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func createAdministratorProjectGrantToCommand(req *internal_permission.ResourceType_ProjectGrant_, userID string, roles []string) *command.AddProjectGrantMember { + return &command.AddProjectGrantMember{ + GrantID: req.ProjectGrant.ProjectGrantId, + ProjectID: req.ProjectGrant.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func (s *Server) UpdateAdministrator(ctx context.Context, req *connect.Request[internal_permission.UpdateAdministratorRequest]) (*connect.Response[internal_permission.UpdateAdministratorResponse], error) { + var changeDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.ChangeInstanceMember(ctx, updateAdministratorInstanceToCommand(authz.GetInstance(ctx).InstanceID(), req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.ChangeOrgMember(ctx, updateAdministratorOrganizationToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.ChangeProjectMember(ctx, updateAdministratorProjectToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.ChangeProjectGrantMember(ctx, updateAdministratorProjectGrantToCommand(resource, req.Msg.UserId, req.Msg.Roles)) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + changeDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-i0V2IbdloZ", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.UpdateAdministratorResponse{ + ChangeDate: changeDate, + }), nil +} + +func updateAdministratorInstanceToCommand(instanceID, userID string, roles []string) *command.ChangeInstanceMember { + return &command.ChangeInstanceMember{ + InstanceID: instanceID, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorOrganizationToCommand(req *internal_permission.ResourceType_OrganizationId, userID string, roles []string) *command.ChangeOrgMember { + return &command.ChangeOrgMember{ + OrgID: req.OrganizationId, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorProjectToCommand(req *internal_permission.ResourceType_ProjectId, userID string, roles []string) *command.ChangeProjectMember { + return &command.ChangeProjectMember{ + ProjectID: req.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func updateAdministratorProjectGrantToCommand(req *internal_permission.ResourceType_ProjectGrant_, userID string, roles []string) *command.ChangeProjectGrantMember { + return &command.ChangeProjectGrantMember{ + GrantID: req.ProjectGrant.ProjectGrantId, + ProjectID: req.ProjectGrant.ProjectId, + UserID: userID, + Roles: roles, + } +} + +func (s *Server) DeleteAdministrator(ctx context.Context, req *connect.Request[internal_permission.DeleteAdministratorRequest]) (*connect.Response[internal_permission.DeleteAdministratorResponse], error) { + var deletionDate *timestamppb.Timestamp + + switch resource := req.Msg.GetResource().GetResource().(type) { + case *internal_permission.ResourceType_Instance: + if resource.Instance { + member, err := s.command.RemoveInstanceMember(ctx, authz.GetInstance(ctx).InstanceID(), req.Msg.UserId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + } + case *internal_permission.ResourceType_OrganizationId: + member, err := s.command.RemoveOrgMember(ctx, resource.OrganizationId, req.Msg.UserId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectId: + member, err := s.command.RemoveProjectMember(ctx, resource.ProjectId, req.Msg.UserId, "") + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + case *internal_permission.ResourceType_ProjectGrant_: + member, err := s.command.RemoveProjectGrantMember(ctx, resource.ProjectGrant.ProjectId, req.Msg.UserId, resource.ProjectGrant.ProjectGrantId) + if err != nil { + return nil, err + } + if !member.EventDate.IsZero() { + deletionDate = timestamppb.New(member.EventDate) + } + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ADMIN-3UOjLtuohh", "Errors.Invalid.Argument") + } + + return connect.NewResponse(&internal_permission.DeleteAdministratorResponse{ + DeletionDate: deletionDate, + }), nil +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go b/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go new file mode 100644 index 0000000000..4d8e1c057c --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/administrator_test.go @@ -0,0 +1,1848 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/integration" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func TestServer_CreateAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.CreateAdministratorRequest) + req *internal_permission.CreateAdministratorRequest + want + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "empty roles", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "notexisting", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{}, + }, + wantErr: true, + }, + { + name: "empty resource", + ctx: iamOwnerCtx, + req: &internal_permission.CreateAdministratorRequest{ + UserId: "notexisting", + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "already existing, error", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "instance, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, no existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.CreateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.CreateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func TestServer_CreateAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.CreateAdministratorRequest) + req *internal_permission.CreateAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.CreateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + + request.UserId = userResp.GetId() + }, + req: &internal_permission.CreateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.CreateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertCreateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertCreateAdministratorResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate bool, actualResp *internal_permission.CreateAdministratorResponse) { + if expectedCreationDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetCreationDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.CreationDate) + } +} + +func TestServer_UpdateAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type want struct { + change bool + changeDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.UpdateAdministratorRequest) + req *internal_permission.UpdateAdministratorRequest + want + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "empty roles", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "notexisting", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{}, + }, + wantErr: true, + }, + { + name: "empty resource", + ctx: iamOwnerCtx, + req: &internal_permission.UpdateAdministratorRequest{ + UserId: "notexisting", + Roles: []string{"IAM_OWNER"}, + }, + wantErr: true, + }, + { + name: "instance, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "instance, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + wantErr: true, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "org, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "org, no existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + Roles: []string{"PROJECT_OWNER"}, + }, + wantErr: true, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "project, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "project, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + want: want{ + change: true, + changeDate: true, + }, + }, + { + name: "project grant, no change", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + want: want{ + change: false, + changeDate: true, + }, + }, + { + name: "project grant, not existing roles", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + }, + req: &internal_permission.UpdateAdministratorRequest{ + Roles: []string{"notexisting"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.prepare != nil { + tt.prepare(tt.req) + } + got, err := instance.Client.InternalPermissionv2Beta.UpdateAdministrator(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + + changeDate := time.Time{} + if tt.want.change { + changeDate = time.Now().UTC() + } + assert.NoError(t, err) + assertUpdateAdministratorResponse(t, creationDate, changeDate, tt.want.changeDate, got) + }) + } +} + +func TestServer_UpdateAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.UpdateAdministratorRequest) + req *internal_permission.UpdateAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + Roles: []string{"IAM_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + Roles: []string{"ORG_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + Roles: []string{"PROJECT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + { + name: "project grant, project owner, error", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.UpdateAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.UpdateAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER_VIEWER"}, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.UpdateAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertUpdateAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertUpdateAdministratorResponse(t *testing.T, creationDate, changeDate time.Time, expectedChangeDate bool, actualResp *internal_permission.UpdateAdministratorResponse) { + if expectedChangeDate { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetChangeDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.ChangeDate) + } +} + +func TestServer_DeleteAdministrator(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) + req *internal_permission.DeleteAdministratorRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty user ID", + ctx: iamOwnerCtx, + req: &internal_permission.DeleteAdministratorRequest{ + UserId: "", + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "instance, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + instance.DeleteInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "instance, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "org, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: "notexisting", + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "org, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "org, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + instance.DeleteOrgMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantDeletionDate: true, + }, + { + name: "project, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + request.UserId = userResp.GetId() + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: "notexisting", + }, + }, + }, + wantDeletionDate: false, + }, + { + name: "project, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + instance.DeleteProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + } + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project grant, not existing", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: "notexisting", + }, + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: false, + }, + { + name: "project grant, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + return creationDate, time.Time{} + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + { + name: "project grant, already removed", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + instance.DeleteProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + + request.UserId = userResp.GetId() + request.Resource = &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + } + return creationDate, time.Now().UTC() + }, + req: &internal_permission.DeleteAdministratorRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := instance.Client.InternalPermissionv2Beta.DeleteAdministrator(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteAdministratorResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func TestServer_DeleteAdministrator_Permission(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.AppName(), gofakeit.Email()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), gofakeit.AppName(), false, false) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type want struct { + creationDate bool + } + tests := []struct { + name string + ctx context.Context + prepare func(request *internal_permission.DeleteAdministratorRequest) + req *internal_permission.DeleteAdministratorRequest + want + wantErr bool + }{ + { + name: "unauthenticated", + ctx: CTX, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, missing permission, org owner", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + wantErr: true, + }, + { + name: "instance, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateInstanceMembership(t, iamOwnerCtx, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_Instance{ + Instance: true, + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, instance owner, ok", + ctx: iamOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, org owner, ok", + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "org, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateOrgMembership(t, iamOwnerCtx, instance.DefaultOrg.Id, userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_OrganizationId{ + OrganizationId: instance.DefaultOrg.GetId(), + }, + }, + }, + wantErr: true, + }, + { + name: "project, project owner, ok", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectId{ + ProjectId: projectResp.GetId(), + }, + }, + }, + wantErr: true, + }, + { + name: "project grant, project grant owner, ok", + ctx: projectGrantOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + want: want{ + creationDate: true, + }, + }, + { + name: "project grant, missing permission", + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + wantErr: true, + }, + { + name: "project grant, project owner, error", + ctx: projectOwnerCtx, + prepare: func(request *internal_permission.DeleteAdministratorRequest) { + userResp := instance.CreateUserTypeHuman(iamOwnerCtx, gofakeit.Email()) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userResp.GetId()) + request.UserId = userResp.GetId() + }, + req: &internal_permission.DeleteAdministratorRequest{ + Resource: &internal_permission.ResourceType{ + Resource: &internal_permission.ResourceType_ProjectGrant_{ + ProjectGrant: &internal_permission.ResourceType_ProjectGrant{ + ProjectId: projectResp.GetId(), + ProjectGrantId: orgResp.GetOrganizationId(), + }, + }, + }, + }, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.prepare != nil { + tt.prepare(tt.req) + } + creationDate := time.Now().UTC() + got, err := instance.Client.InternalPermissionv2Beta.DeleteAdministrator(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteAdministratorResponse(t, creationDate, changeDate, tt.want.creationDate, got) + }) + } +} + +func assertDeleteAdministratorResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *internal_permission.DeleteAdministratorResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/query_test.go b/internal/api/grpc/internal_permission/v2beta/integration/query_test.go new file mode 100644 index 0000000000..63b07c5194 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/query_test.go @@ -0,0 +1,1173 @@ +//go:build integration + +package project_test + +import ( + "context" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + filter "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func TestServer_ListAdministrators(t *testing.T) { + iamOwnerCtx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + projectName := gofakeit.AppName() + projectResp := instance.CreateProject(iamOwnerCtx, t, instance.DefaultOrg.GetId(), projectName, false, false) + orgResp := instance.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instance.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + userProjectResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + userProjectGrantResp := instance.CreateMachineUser(iamOwnerCtx) + instance.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instance.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type args struct { + ctx context.Context + dep func(*internal_permission.ListAdministratorsRequest, *internal_permission.ListAdministratorsResponse) + req *internal_permission.ListAdministratorsRequest + } + tests := []struct { + name string + args args + want *internal_permission.ListAdministratorsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{Ids: []string{"notexisting"}}, + }, + }, + }, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin3 := createInstanceAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createOrganizationAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin2 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin2 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin3 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, instance owner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + response.Administrators[3] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, org owner", + args: args{ + ctx: instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, + }, + }, + }, + { + name: "list multiple id, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instance, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instance, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instance, t, instance.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instance.Client.InternalPermissionv2Beta.ListAdministrators(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Administrators, len(tt.want.Administrators)) { + for i := range tt.want.Administrators { + assert.EqualExportedValues(ttt, tt.want.Administrators[i], got.Administrators[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} + +func createInstanceAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateInstanceMembership(t, ctx, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Instance{ + Instance: true, + }, + Roles: []string{"IAM_OWNER"}, + } +} + +func createOrganizationAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateOrgMembership(t, ctx, instance.DefaultOrg.Id, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Organization{ + Organization: &internal_permission.Organization{ + Id: instance.DefaultOrg.GetId(), + Name: instance.DefaultOrg.GetName(), + }, + }, + Roles: []string{"ORG_OWNER"}, + } +} + +func createProjectAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, projectID, projectName string) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateProjectMembership(t, ctx, projectID, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_Project{ + Project: &internal_permission.Project{ + Id: projectID, + Name: projectName, + OrganizationId: orgID, + }, + }, + Roles: []string{"PROJECT_OWNER"}, + } +} + +func createProjectGrantAdministrator(ctx context.Context, instance *integration.Instance, t *testing.T, orgID, projectID, projectName, grantedOrgID string) *internal_permission.Administrator { + email := gofakeit.Email() + userResp := instance.CreateUserTypeHuman(ctx, email) + memberResp := instance.CreateProjectGrantMembership(t, ctx, projectID, grantedOrgID, userResp.GetId()) + return &internal_permission.Administrator{ + CreationDate: memberResp.GetCreationDate(), + ChangeDate: memberResp.GetCreationDate(), + User: &internal_permission.User{ + Id: userResp.GetId(), + PreferredLoginName: email, + DisplayName: "Mickey Mouse", + OrganizationId: instance.DefaultOrg.GetId(), + }, + Resource: &internal_permission.Administrator_ProjectGrant{ + ProjectGrant: &internal_permission.ProjectGrant{ + Id: grantedOrgID, + ProjectId: projectID, + ProjectName: projectName, + OrganizationId: orgID, + GrantedOrganizationId: grantedOrgID, + }, + }, + Roles: []string{"PROJECT_GRANT_OWNER"}, + } +} + +func TestServer_ListAdministrators_PermissionV2(t *testing.T) { + ensureFeaturePermissionV2Enabled(t, instancePermissionV2) + iamOwnerCtx := instancePermissionV2.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + projectName := gofakeit.AppName() + projectResp := instancePermissionV2.CreateProject(iamOwnerCtx, t, instancePermissionV2.DefaultOrg.GetId(), projectName, false, false) + orgResp := instancePermissionV2.CreateOrganization(iamOwnerCtx, gofakeit.Company(), gofakeit.Email()) + instancePermissionV2.CreateProjectGrant(iamOwnerCtx, t, projectResp.GetId(), orgResp.GetOrganizationId()) + + userProjectResp := instancePermissionV2.CreateMachineUser(iamOwnerCtx) + instancePermissionV2.CreateProjectMembership(t, iamOwnerCtx, projectResp.GetId(), userProjectResp.GetUserId()) + patProjectResp := instancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, userProjectResp.GetUserId()) + projectOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectResp.Token) + + userProjectGrantResp := instancePermissionV2.CreateMachineUser(iamOwnerCtx) + instancePermissionV2.CreateProjectGrantMembership(t, iamOwnerCtx, projectResp.GetId(), orgResp.GetOrganizationId(), userProjectGrantResp.GetUserId()) + patProjectGrantResp := instancePermissionV2.CreatePersonalAccessToken(iamOwnerCtx, userProjectGrantResp.GetUserId()) + projectGrantOwnerCtx := integration.WithAuthorizationToken(CTX, patProjectGrantResp.Token) + + type args struct { + ctx context.Context + dep func(*internal_permission.ListAdministratorsRequest, *internal_permission.ListAdministratorsResponse) + req *internal_permission.ListAdministratorsRequest + } + tests := []struct { + name string + args args + want *internal_permission.ListAdministratorsResponse + wantErr bool + }{ + { + name: "list by id, unauthenticated", + args: args{ + ctx: CTX, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + wantErr: true, + }, + { + name: "list by id, no permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeNoPermission), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list by id, missing permission", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + { + name: "list, not found", + args: args{ + ctx: iamOwnerCtx, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{ + { + Filter: &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{Ids: []string{"notexisting"}}, + }, + }, + }, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list single id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, instance", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, org", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin2 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list single id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, + }, + }, + }, + { + name: "list multiple id, project grant", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin2 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + admin3 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin3 + response.Administrators[1] = admin2 + response.Administrators[2] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, instance owner", + args: args{ + ctx: iamOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + response.Administrators[3] = admin1 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 4, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, org owner", + args: args{ + ctx: instancePermissionV2.WithAuthorization(CTX, integration.UserTypeOrgOwner), + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + response.Administrators[2] = admin2 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, {}, + }, + }, + }, + { + name: "list multiple id, project owner", + args: args{ + ctx: projectOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + response.Administrators[0] = admin4 + response.Administrators[1] = admin3 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 2, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{ + {}, {}, + }, + }, + }, + // TODO: correct when permission check is added for project grants https://github.com/zitadel/zitadel/issues/9972 + { + name: "list multiple id, project grant owner", + args: args{ + ctx: projectGrantOwnerCtx, + dep: func(request *internal_permission.ListAdministratorsRequest, response *internal_permission.ListAdministratorsResponse) { + admin1 := createInstanceAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin2 := createOrganizationAdministrator(iamOwnerCtx, instancePermissionV2, t) + admin3 := createProjectAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName) + admin4 := createProjectGrantAdministrator(iamOwnerCtx, instancePermissionV2, t, instancePermissionV2.DefaultOrg.GetId(), projectResp.GetId(), projectName, orgResp.GetOrganizationId()) + request.Filters[0].Filter = &internal_permission.AdministratorSearchFilter_InUserIdsFilter{ + InUserIdsFilter: &filter.InIDsFilter{ + Ids: []string{admin1.GetUser().GetId(), admin2.GetUser().GetId(), admin3.GetUser().GetId(), admin4.GetUser().GetId()}, + }, + } + // response.Administrators[0] = admin4 + }, + req: &internal_permission.ListAdministratorsRequest{ + Filters: []*internal_permission.AdministratorSearchFilter{{}}, + }, + }, + want: &internal_permission.ListAdministratorsResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 0, + AppliedLimit: 100, + }, + Administrators: []*internal_permission.Administrator{}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCtx, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := instancePermissionV2.Client.InternalPermissionv2Beta.ListAdministrators(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Administrators, len(tt.want.Administrators)) { + for i := range tt.want.Administrators { + assert.EqualExportedValues(ttt, tt.want.Administrators[i], got.Administrators[i]) + } + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/integration/server_test.go b/internal/api/grpc/internal_permission/v2beta/integration/server_test.go new file mode 100644 index 0000000000..59d9745222 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/integration/server_test.go @@ -0,0 +1,63 @@ +//go:build integration + +package project_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/muhlemmer/gu" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +var ( + CTX context.Context + instance *integration.Instance + instancePermissionV2 *integration.Instance +) + +func TestMain(m *testing.M) { + os.Exit(func() int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + CTX = ctx + instance = integration.NewInstance(ctx) + instancePermissionV2 = integration.NewInstance(CTX) + return m.Run() + }()) +} + +func ensureFeaturePermissionV2Enabled(t *testing.T, instance *integration.Instance) { + ctx := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(t, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + _, err = instance.Client.FeatureV2.SetInstanceFeatures(ctx, &feature.SetInstanceFeaturesRequest{ + PermissionCheckV2: gu.Ptr(true), + }) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(ttt *assert.CollectT) { + f, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + assert.NoError(ttt, err) + if f.PermissionCheckV2.GetEnabled() { + return + } + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/api/grpc/internal_permission/v2beta/query.go b/internal/api/grpc/internal_permission/v2beta/query.go new file mode 100644 index 0000000000..3a8de83292 --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/query.go @@ -0,0 +1,192 @@ +package internal_permission + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + filter_pb "github.com/zitadel/zitadel/pkg/grpc/filter/v2beta" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" +) + +func (s *Server) ListAdministrators(ctx context.Context, req *connect.Request[internal_permission.ListAdministratorsRequest]) (*connect.Response[internal_permission.ListAdministratorsResponse], error) { + queries, err := s.listAdministratorsRequestToModel(req.Msg) + if err != nil { + return nil, err + } + resp, err := s.query.SearchAdministrators(ctx, queries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&internal_permission.ListAdministratorsResponse{ + Administrators: administratorsToPb(resp.Administrators), + Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), + }), nil +} + +func (s *Server) listAdministratorsRequestToModel(req *internal_permission.ListAdministratorsRequest) (*query.MembershipSearchQuery, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := administratorSearchFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.MembershipSearchQuery{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: administratorFieldNameToSortingColumn(req.GetSortingColumn()), + }, + Queries: queries, + }, nil +} + +func administratorFieldNameToSortingColumn(field internal_permission.AdministratorFieldName) query.Column { + switch field { + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_CREATION_DATE: + return query.MembershipCreationDate + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_USER_ID: + return query.MembershipUserID + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_CHANGE_DATE: + return query.MembershipChangeDate + case internal_permission.AdministratorFieldName_ADMINISTRATOR_FIELD_NAME_UNSPECIFIED: + return query.MembershipCreationDate + default: + return query.MembershipCreationDate + } +} + +func administratorSearchFiltersToQuery(queries []*internal_permission.AdministratorSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, qry := range queries { + q[i], err = administratorFilterToModel(qry) + if err != nil { + return nil, err + } + } + return q, nil +} + +func administratorFilterToModel(filter *internal_permission.AdministratorSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *internal_permission.AdministratorSearchFilter_InUserIdsFilter: + return inUserIDsFilterToQuery(q.InUserIdsFilter) + case *internal_permission.AdministratorSearchFilter_CreationDate: + return creationDateFilterToQuery(q.CreationDate) + case *internal_permission.AdministratorSearchFilter_ChangeDate: + return changeDateFilterToQuery(q.ChangeDate) + case *internal_permission.AdministratorSearchFilter_UserOrganizationId: + return userResourceOwnerFilterToQuery(q.UserOrganizationId) + case *internal_permission.AdministratorSearchFilter_UserPreferredLoginName: + return userLoginNameFilterToQuery(q.UserPreferredLoginName) + case *internal_permission.AdministratorSearchFilter_UserDisplayName: + return userDisplayNameFilterToQuery(q.UserDisplayName) + case *internal_permission.AdministratorSearchFilter_Resource: + return resourceFilterToQuery(q.Resource) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "ORG-vR9nC", "List.Query.Invalid") + } +} + +func inUserIDsFilterToQuery(q *filter_pb.InIDsFilter) (query.SearchQuery, error) { + return query.NewMemberInUserIDsSearchQuery(q.GetIds()) +} + +func userResourceOwnerFilterToQuery(q *filter_pb.IDFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserResourceOwnerSearchQuery(q.GetId()) +} + +func userLoginNameFilterToQuery(q *internal_permission.UserPreferredLoginNameFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserLoginNameSearchQuery(q.GetPreferredLoginName()) +} + +func userDisplayNameFilterToQuery(q *internal_permission.UserDisplayNameFilter) (query.SearchQuery, error) { + return query.NewAdministratorUserDisplayNameSearchQuery(q.GetDisplayName()) +} + +func creationDateFilterToQuery(q *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewMembershipCreationDateQuery(q.GetTimestamp().AsTime(), filter.TimestampMethodPbToQuery(q.Method)) +} + +func changeDateFilterToQuery(q *filter_pb.TimestampFilter) (query.SearchQuery, error) { + return query.NewMembershipChangeDateQuery(q.GetTimestamp().AsTime(), filter.TimestampMethodPbToQuery(q.Method)) +} + +func resourceFilterToQuery(q *internal_permission.ResourceFilter) (query.SearchQuery, error) { + switch q.GetResource().(type) { + case *internal_permission.ResourceFilter_Instance: + if q.GetInstance() { + return query.NewMembershipIsIAMQuery() + } + case *internal_permission.ResourceFilter_OrganizationId: + return query.NewMembershipOrgIDQuery(q.GetOrganizationId()) + case *internal_permission.ResourceFilter_ProjectId: + return query.NewMembershipProjectIDQuery(q.GetProjectId()) + case *internal_permission.ResourceFilter_ProjectGrantId: + return query.NewMembershipProjectGrantIDQuery(q.GetProjectGrantId()) + } + return nil, nil +} + +func administratorsToPb(administrators []*query.Administrator) []*internal_permission.Administrator { + a := make([]*internal_permission.Administrator, len(administrators)) + for i, admin := range administrators { + a[i] = administratorToPb(admin) + } + return a +} + +func administratorToPb(admin *query.Administrator) *internal_permission.Administrator { + var resource internal_permission.Resource + if admin.Instance != nil { + resource = &internal_permission.Administrator_Instance{Instance: true} + } + if admin.Org != nil { + resource = &internal_permission.Administrator_Organization{ + Organization: &internal_permission.Organization{ + Id: admin.Org.OrgID, + Name: admin.Org.Name, + }, + } + } + if admin.Project != nil { + resource = &internal_permission.Administrator_Project{ + Project: &internal_permission.Project{ + Id: admin.Project.ProjectID, + Name: admin.Project.Name, + OrganizationId: admin.Project.ResourceOwner, + }, + } + } + if admin.ProjectGrant != nil { + resource = &internal_permission.Administrator_ProjectGrant{ + ProjectGrant: &internal_permission.ProjectGrant{ + Id: admin.ProjectGrant.GrantID, + ProjectId: admin.ProjectGrant.ProjectID, + ProjectName: admin.ProjectGrant.ProjectName, + OrganizationId: admin.ProjectGrant.ResourceOwner, + GrantedOrganizationId: admin.ProjectGrant.GrantedOrgID, + }, + } + } + + return &internal_permission.Administrator{ + CreationDate: timestamppb.New(admin.CreationDate), + ChangeDate: timestamppb.New(admin.ChangeDate), + User: &internal_permission.User{ + Id: admin.User.UserID, + PreferredLoginName: admin.User.LoginName, + DisplayName: admin.User.DisplayName, + OrganizationId: admin.User.ResourceOwner, + }, + Resource: resource, + Roles: admin.Roles, + } +} diff --git a/internal/api/grpc/internal_permission/v2beta/server.go b/internal/api/grpc/internal_permission/v2beta/server.go new file mode 100644 index 0000000000..bc1a999faa --- /dev/null +++ b/internal/api/grpc/internal_permission/v2beta/server.go @@ -0,0 +1,66 @@ +package internal_permission + +import ( + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/grpc/server" + "github.com/zitadel/zitadel/internal/command" + "github.com/zitadel/zitadel/internal/config/systemdefaults" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + internal_permission "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta/internal_permissionconnect" +) + +var _ internal_permissionconnect.InternalPermissionServiceHandler = (*Server)(nil) + +type Server struct { + systemDefaults systemdefaults.SystemDefaults + command *command.Commands + query *query.Queries + checkPermission domain.PermissionCheck +} + +type Config struct{} + +func CreateServer( + systemDefaults systemdefaults.SystemDefaults, + command *command.Commands, + query *query.Queries, + checkPermission domain.PermissionCheck, +) *Server { + return &Server{ + systemDefaults: systemDefaults, + command: command, + query: query, + checkPermission: checkPermission, + } +} + +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return internal_permissionconnect.NewInternalPermissionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return internal_permission.File_zitadel_internal_permission_v2beta_internal_permission_service_proto +} + +func (s *Server) AppName() string { + return internal_permission.InternalPermissionService_ServiceDesc.ServiceName +} + +func (s *Server) MethodPrefix() string { + return internal_permission.InternalPermissionService_ServiceDesc.ServiceName +} + +func (s *Server) AuthMethods() authz.MethodMapping { + return internal_permission.InternalPermissionService_AuthMethods +} + +func (s *Server) RegisterGateway() server.RegisterGatewayFunc { + return internal_permission.RegisterInternalPermissionServiceHandler +} diff --git a/internal/api/grpc/management/org.go b/internal/api/grpc/management/org.go index 70f509a4d7..ee4f5eb633 100644 --- a/internal/api/grpc/management/org.go +++ b/internal/api/grpc/management/org.go @@ -278,28 +278,28 @@ func (s *Server) ListOrgMembers(ctx context.Context, req *mgmt_pb.ListOrgMembers } func (s *Server) AddOrgMember(ctx context.Context, req *mgmt_pb.AddOrgMemberRequest) (*mgmt_pb.AddOrgMemberResponse, error) { - addedMember, err := s.command.AddOrgMember(ctx, authz.GetCtxData(ctx).OrgID, req.UserId, req.Roles...) + addedMember, err := s.command.AddOrgMember(ctx, AddOrgMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddOrgMemberResponse{ Details: object.AddToDetailsPb( addedMember.Sequence, - addedMember.ChangeDate, + addedMember.EventDate, addedMember.ResourceOwner, ), }, nil } func (s *Server) UpdateOrgMember(ctx context.Context, req *mgmt_pb.UpdateOrgMemberRequest) (*mgmt_pb.UpdateOrgMemberResponse, error) { - changedMember, err := s.command.ChangeOrgMember(ctx, UpdateOrgMemberRequestToDomain(ctx, req)) + changedMember, err := s.command.ChangeOrgMember(ctx, UpdateOrgMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateOrgMemberResponse{ Details: object.ChangeToDetailsPb( changedMember.Sequence, - changedMember.ChangeDate, + changedMember.EventDate, changedMember.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/org_converter.go b/internal/api/grpc/management/org_converter.go index 03de84cdf4..07c772189c 100644 --- a/internal/api/grpc/management/org_converter.go +++ b/internal/api/grpc/management/org_converter.go @@ -8,6 +8,7 @@ import ( "github.com/zitadel/zitadel/internal/api/grpc/metadata" "github.com/zitadel/zitadel/internal/api/grpc/object" org_grpc "github.com/zitadel/zitadel/internal/api/grpc/org" + "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" @@ -67,8 +68,20 @@ func SetPrimaryOrgDomainRequestToDomain(ctx context.Context, req *mgmt_pb.SetPri } } -func UpdateOrgMemberRequestToDomain(ctx context.Context, req *mgmt_pb.UpdateOrgMemberRequest) *domain.Member { - return domain.NewMember(authz.GetCtxData(ctx).OrgID, req.UserId, req.Roles...) +func AddOrgMemberRequestToCommand(req *mgmt_pb.AddOrgMemberRequest, orgID string) *command.AddOrgMember { + return &command.AddOrgMember{ + OrgID: orgID, + UserID: req.UserId, + Roles: req.Roles, + } +} + +func UpdateOrgMemberRequestToCommand(req *mgmt_pb.UpdateOrgMemberRequest, orgID string) *command.ChangeOrgMember { + return &command.ChangeOrgMember{ + OrgID: orgID, + UserID: req.UserId, + Roles: req.Roles, + } } func ListOrgMembersRequestToModel(ctx context.Context, req *mgmt_pb.ListOrgMembersRequest) (*query.OrgMembersQuery, error) { diff --git a/internal/api/grpc/management/project.go b/internal/api/grpc/management/project.go index f3af8dbf86..be196d14ce 100644 --- a/internal/api/grpc/management/project.go +++ b/internal/api/grpc/management/project.go @@ -227,7 +227,7 @@ func (s *Server) RemoveProject(ctx context.Context, req *mgmt_pb.RemoveProjectRe } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery}, - }, true) + }, true, nil) if err != nil { return nil, err } @@ -312,7 +312,7 @@ func (s *Server) RemoveProjectRole(ctx context.Context, req *mgmt_pb.RemoveProje } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, rolesQuery}, - }, false) + }, false, nil) if err != nil { return nil, err @@ -354,24 +354,28 @@ func (s *Server) ListProjectMembers(ctx context.Context, req *mgmt_pb.ListProjec } func (s *Server) AddProjectMember(ctx context.Context, req *mgmt_pb.AddProjectMemberRequest) (*mgmt_pb.AddProjectMemberResponse, error) { - member, err := s.command.AddProjectMember(ctx, AddProjectMemberRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + member, err := s.command.AddProjectMember(ctx, AddProjectMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddProjectMemberResponse{ - Details: object_grpc.AddToDetailsPb(member.Sequence, member.ChangeDate, member.ResourceOwner), + Details: object_grpc.AddToDetailsPb( + member.Sequence, + member.EventDate, + member.ResourceOwner, + ), }, nil } func (s *Server) UpdateProjectMember(ctx context.Context, req *mgmt_pb.UpdateProjectMemberRequest) (*mgmt_pb.UpdateProjectMemberResponse, error) { - member, err := s.command.ChangeProjectMember(ctx, UpdateProjectMemberRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + member, err := s.command.ChangeProjectMember(ctx, UpdateProjectMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectMemberResponse{ Details: object_grpc.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_converter.go b/internal/api/grpc/management/project_converter.go index 83a8246feb..8ea0014800 100644 --- a/internal/api/grpc/management/project_converter.go +++ b/internal/api/grpc/management/project_converter.go @@ -104,12 +104,22 @@ func ProjectGrantsToIDs(projectGrants *query.ProjectGrants) []string { return converted } -func AddProjectMemberRequestToDomain(req *mgmt_pb.AddProjectMemberRequest) *domain.Member { - return domain.NewMember(req.ProjectId, req.UserId, req.Roles...) +func AddProjectMemberRequestToCommand(req *mgmt_pb.AddProjectMemberRequest, orgID string) *command.AddProjectMember { + return &command.AddProjectMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + UserID: req.UserId, + Roles: req.Roles, + } } -func UpdateProjectMemberRequestToDomain(req *mgmt_pb.UpdateProjectMemberRequest) *domain.Member { - return domain.NewMember(req.ProjectId, req.UserId, req.Roles...) +func UpdateProjectMemberRequestToCommand(req *mgmt_pb.UpdateProjectMemberRequest, orgID string) *command.ChangeProjectMember { + return &command.ChangeProjectMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + UserID: req.UserId, + Roles: req.Roles, + } } func listProjectRequestToModel(req *mgmt_pb.ListProjectsRequest) (*query.ProjectSearchQueries, error) { diff --git a/internal/api/grpc/management/project_grant.go b/internal/api/grpc/management/project_grant.go index d84375818d..26f51f6851 100644 --- a/internal/api/grpc/management/project_grant.go +++ b/internal/api/grpc/management/project_grant.go @@ -91,7 +91,7 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *mgmt_pb.UpdateProj } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, true) + }, true, nil) if err != nil { return nil, err } @@ -139,7 +139,7 @@ func (s *Server) RemoveProjectGrant(ctx context.Context, req *mgmt_pb.RemoveProj } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } @@ -176,28 +176,28 @@ func (s *Server) ListProjectGrantMembers(ctx context.Context, req *mgmt_pb.ListP } func (s *Server) AddProjectGrantMember(ctx context.Context, req *mgmt_pb.AddProjectGrantMemberRequest) (*mgmt_pb.AddProjectGrantMemberResponse, error) { - member, err := s.command.AddProjectGrantMember(ctx, AddProjectGrantMemberRequestToDomain(req)) + member, err := s.command.AddProjectGrantMember(ctx, AddProjectGrantMemberRequestToCommand(req, authz.GetCtxData(ctx).OrgID)) if err != nil { return nil, err } return &mgmt_pb.AddProjectGrantMemberResponse{ Details: object_grpc.AddToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil } func (s *Server) UpdateProjectGrantMember(ctx context.Context, req *mgmt_pb.UpdateProjectGrantMemberRequest) (*mgmt_pb.UpdateProjectGrantMemberResponse, error) { - member, err := s.command.ChangeProjectGrantMember(ctx, UpdateProjectGrantMemberRequestToDomain(req)) + member, err := s.command.ChangeProjectGrantMember(ctx, UpdateProjectGrantMemberRequestToCommand(req)) if err != nil { return nil, err } return &mgmt_pb.UpdateProjectGrantMemberResponse{ Details: object_grpc.ChangeToDetailsPb( member.Sequence, - member.ChangeDate, + member.EventDate, member.ResourceOwner, ), }, nil diff --git a/internal/api/grpc/management/project_grant_converter.go b/internal/api/grpc/management/project_grant_converter.go index 04bc35301f..0523eed13a 100644 --- a/internal/api/grpc/management/project_grant_converter.go +++ b/internal/api/grpc/management/project_grant_converter.go @@ -7,7 +7,6 @@ import ( member_grpc "github.com/zitadel/zitadel/internal/api/grpc/member" "github.com/zitadel/zitadel/internal/api/grpc/object" "github.com/zitadel/zitadel/internal/command" - "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/internal/zerrors" @@ -146,24 +145,21 @@ func ListProjectGrantMembersRequestToModel(ctx context.Context, req *mgmt_pb.Lis }, nil } -func AddProjectGrantMemberRequestToDomain(req *mgmt_pb.AddProjectGrantMemberRequest) *domain.ProjectGrantMember { - return &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, - }, - GrantID: req.GrantId, - UserID: req.UserId, - Roles: req.Roles, +func AddProjectGrantMemberRequestToCommand(req *mgmt_pb.AddProjectGrantMemberRequest, orgID string) *command.AddProjectGrantMember { + return &command.AddProjectGrantMember{ + ResourceOwner: orgID, + ProjectID: req.ProjectId, + GrantID: req.GrantId, + UserID: req.UserId, + Roles: req.Roles, } } -func UpdateProjectGrantMemberRequestToDomain(req *mgmt_pb.UpdateProjectGrantMemberRequest) *domain.ProjectGrantMember { - return &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: req.ProjectId, - }, - GrantID: req.GrantId, - UserID: req.UserId, - Roles: req.Roles, +func UpdateProjectGrantMemberRequestToCommand(req *mgmt_pb.UpdateProjectGrantMemberRequest) *command.ChangeProjectGrantMember { + return &command.ChangeProjectGrantMember{ + ProjectID: req.ProjectId, + GrantID: req.GrantId, + UserID: req.UserId, + Roles: req.Roles, } } diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go index 09b9faa756..f40a29868e 100644 --- a/internal/api/grpc/management/user.go +++ b/internal/api/grpc/management/user.go @@ -142,7 +142,7 @@ func (s *Server) ListUserMetadata(ctx context.Context, req *mgmt_pb.ListUserMeta if err != nil { return nil, err } - res, err := s.query.SearchUserMetadata(ctx, true, req.Id, metadataQueries, false) + res, err := s.query.SearchUserMetadata(ctx, true, req.Id, metadataQueries, nil) if err != nil { return nil, err } @@ -369,7 +369,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } diff --git a/internal/api/grpc/management/user_grant.go b/internal/api/grpc/management/user_grant.go index 8589b49e30..3a894c8c29 100644 --- a/internal/api/grpc/management/user_grant.go +++ b/internal/api/grpc/management/user_grant.go @@ -33,7 +33,7 @@ func (s *Server) ListUserGrants(ctx context.Context, req *mgmt_pb.ListUserGrantR if err != nil { return nil, err } - res, err := s.query.UserGrants(ctx, queries, false) + res, err := s.query.UserGrants(ctx, queries, false, nil) if err != nil { return nil, err } @@ -44,11 +44,11 @@ func (s *Server) ListUserGrants(ctx context.Context, req *mgmt_pb.ListUserGrantR } func (s *Server) AddUserGrant(ctx context.Context, req *mgmt_pb.AddUserGrantRequest) (*mgmt_pb.AddUserGrantResponse, error) { - grant := AddUserGrantRequestToDomain(req) + grant := AddUserGrantRequestToDomain(req, authz.GetCtxData(ctx).OrgID) if err := checkExplicitProjectPermission(ctx, grant.ProjectGrantID, grant.ProjectID); err != nil { return nil, err } - grant, err := s.command.AddUserGrant(ctx, grant, authz.GetCtxData(ctx).OrgID) + grant, err := s.command.AddUserGrant(ctx, grant, nil) if err != nil { return nil, err } @@ -63,7 +63,7 @@ func (s *Server) AddUserGrant(ctx context.Context, req *mgmt_pb.AddUserGrantRequ } func (s *Server) UpdateUserGrant(ctx context.Context, req *mgmt_pb.UpdateUserGrantRequest) (*mgmt_pb.UpdateUserGrantResponse, error) { - grant, err := s.command.ChangeUserGrant(ctx, UpdateUserGrantRequestToDomain(req), authz.GetCtxData(ctx).OrgID) + grant, err := s.command.ChangeUserGrant(ctx, UpdateUserGrantRequestToDomain(req, authz.GetCtxData(ctx).OrgID), false, false, nil) if err != nil { return nil, err } @@ -77,7 +77,7 @@ func (s *Server) UpdateUserGrant(ctx context.Context, req *mgmt_pb.UpdateUserGra } func (s *Server) DeactivateUserGrant(ctx context.Context, req *mgmt_pb.DeactivateUserGrantRequest) (*mgmt_pb.DeactivateUserGrantResponse, error) { - objectDetails, err := s.command.DeactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.DeactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -87,7 +87,7 @@ func (s *Server) DeactivateUserGrant(ctx context.Context, req *mgmt_pb.Deactivat } func (s *Server) ReactivateUserGrant(ctx context.Context, req *mgmt_pb.ReactivateUserGrantRequest) (*mgmt_pb.ReactivateUserGrantResponse, error) { - objectDetails, err := s.command.ReactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.ReactivateUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, nil) if err != nil { return nil, err } @@ -97,7 +97,7 @@ func (s *Server) ReactivateUserGrant(ctx context.Context, req *mgmt_pb.Reactivat } func (s *Server) RemoveUserGrant(ctx context.Context, req *mgmt_pb.RemoveUserGrantRequest) (*mgmt_pb.RemoveUserGrantResponse, error) { - objectDetails, err := s.command.RemoveUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID) + objectDetails, err := s.command.RemoveUserGrant(ctx, req.GrantId, authz.GetCtxData(ctx).OrgID, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/management/user_grant_converter.go b/internal/api/grpc/management/user_grant_converter.go index 17d992d251..ad1b4acdf6 100644 --- a/internal/api/grpc/management/user_grant_converter.go +++ b/internal/api/grpc/management/user_grant_converter.go @@ -49,19 +49,23 @@ func shouldAppendUserGrantOwnerQuery(queries []*user.UserGrantQuery) bool { return true } -func AddUserGrantRequestToDomain(req *mgmt_pb.AddUserGrantRequest) *domain.UserGrant { +func AddUserGrantRequestToDomain(req *mgmt_pb.AddUserGrantRequest, resourceowner string) *domain.UserGrant { return &domain.UserGrant{ UserID: req.UserId, ProjectID: req.ProjectId, ProjectGrantID: req.ProjectGrantId, RoleKeys: req.RoleKeys, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: resourceowner, + }, } } -func UpdateUserGrantRequestToDomain(req *mgmt_pb.UpdateUserGrantRequest) *domain.UserGrant { +func UpdateUserGrantRequestToDomain(req *mgmt_pb.UpdateUserGrantRequest, resourceowner string) *domain.UserGrant { return &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.GrantId, + AggregateID: req.GrantId, + ResourceOwner: resourceowner, }, UserID: req.UserId, RoleKeys: req.RoleKeys, diff --git a/internal/api/grpc/metadata/v2/metadata.go b/internal/api/grpc/metadata/v2/metadata.go new file mode 100644 index 0000000000..f50ad57f64 --- /dev/null +++ b/internal/api/grpc/metadata/v2/metadata.go @@ -0,0 +1,47 @@ +package metadata + +import ( + "google.golang.org/protobuf/types/known/timestamppb" + + filter_v2 "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/zerrors" + meta_pb "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" +) + +func UserMetadataListToPb(dataList []*query.UserMetadata) []*meta_pb.Metadata { + mds := make([]*meta_pb.Metadata, len(dataList)) + for i, data := range dataList { + mds[i] = UserMetadataToPb(data) + } + return mds +} + +func UserMetadataToPb(data *query.UserMetadata) *meta_pb.Metadata { + return &meta_pb.Metadata{ + Key: data.Key, + Value: data.Value, + CreationDate: timestamppb.New(data.CreationDate), + ChangeDate: timestamppb.New(data.ChangeDate), + } +} + +func UserMetadataFiltersToQuery(queries []*meta_pb.MetadataSearchFilter) (_ []query.SearchQuery, err error) { + q := make([]query.SearchQuery, len(queries)) + for i, query := range queries { + q[i], err = UserMetadataFilterToQuery(query) + if err != nil { + return nil, err + } + } + return q, nil +} + +func UserMetadataFilterToQuery(filter *meta_pb.MetadataSearchFilter) (query.SearchQuery, error) { + switch q := filter.Filter.(type) { + case *meta_pb.MetadataSearchFilter_KeyFilter: + return query.NewUserMetadataKeySearchQuery(q.KeyFilter.Key, filter_v2.TextMethodPbToQuery(q.KeyFilter.Method)) + default: + return nil, zerrors.ThrowInvalidArgument(nil, "METAD-fdg23", "List.Query.Invalid") + } +} diff --git a/internal/api/grpc/oidc/v2/oidc.go b/internal/api/grpc/oidc/v2/oidc.go index 8612d11558..d56d6da056 100644 --- a/internal/api/grpc/oidc/v2/oidc.go +++ b/internal/api/grpc/oidc/v2/oidc.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -18,30 +19,30 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb.GetDeviceAuthorizationRequestRequest) (*oidc_pb.GetDeviceAuthorizationRequestResponse, error) { - deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.GetUserCode()) +func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *connect.Request[oidc_pb.GetDeviceAuthorizationRequestRequest]) (*connect.Response[oidc_pb.GetDeviceAuthorizationRequestResponse], error) { + deviceRequest, err := s.query.DeviceAuthRequestByUserCode(ctx, req.Msg.GetUserCode()) if err != nil { return nil, err } @@ -49,7 +50,7 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb if err != nil { return nil, err } - return &oidc_pb.GetDeviceAuthorizationRequestResponse{ + return connect.NewResponse(&oidc_pb.GetDeviceAuthorizationRequestResponse{ DeviceAuthorizationRequest: &oidc_pb.DeviceAuthorizationRequest{ Id: base64.RawURLEncoding.EncodeToString(encrypted), ClientId: deviceRequest.ClientID, @@ -57,24 +58,24 @@ func (s *Server) GetDeviceAuthorizationRequest(ctx context.Context, req *oidc_pb AppName: deviceRequest.AppName, ProjectName: deviceRequest.ProjectName, }, - }, nil + }), nil } -func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest) (*oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse, error) { - deviceCode, err := s.deviceCodeFromID(req.GetDeviceAuthorizationId()) +func (s *Server) AuthorizeOrDenyDeviceAuthorization(ctx context.Context, req *connect.Request[oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest]) (*connect.Response[oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse], error) { + deviceCode, err := s.deviceCodeFromID(req.Msg.GetDeviceAuthorizationId()) if err != nil { return nil, err } - switch req.GetDecision().(type) { + switch req.Msg.GetDecision().(type) { case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Session: - _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.GetSession().GetSessionId(), req.GetSession().GetSessionToken()) + _, err = s.command.ApproveDeviceAuthWithSession(ctx, deviceCode, req.Msg.GetSession().GetSessionId(), req.Msg.GetSession().GetSessionToken()) case *oidc_pb.AuthorizeOrDenyDeviceAuthorizationRequest_Deny: _, err = s.command.CancelDeviceAuth(ctx, deviceCode, domain.DeviceAuthCanceledDenied) } if err != nil { return nil, err } - return &oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}, nil + return connect.NewResponse(&oidc_pb.AuthorizeOrDenyDeviceAuthorizationResponse{}), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -136,7 +137,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -146,13 +147,13 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -172,10 +173,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2/server.go b/internal/api/grpc/oidc/v2/server.go index 99234ee3d7..3d8f78a8ad 100644 --- a/internal/api/grpc/oidc/v2/server.go +++ b/internal/api/grpc/oidc/v2/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/crypto" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -42,8 +45,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/oidc/v2beta/oidc.go b/internal/api/grpc/oidc/v2beta/oidc.go index 66c4bee828..432e6f833f 100644 --- a/internal/api/grpc/oidc/v2beta/oidc.go +++ b/internal/api/grpc/oidc/v2beta/oidc.go @@ -3,6 +3,7 @@ package oidc import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/oidc/v3/pkg/op" "google.golang.org/protobuf/types/known/durationpb" @@ -17,15 +18,15 @@ import ( oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" ) -func (s *Server) GetAuthRequest(ctx context.Context, req *oidc_pb.GetAuthRequestRequest) (*oidc_pb.GetAuthRequestResponse, error) { - authRequest, err := s.query.AuthRequestByID(ctx, true, req.GetAuthRequestId(), true) +func (s *Server) GetAuthRequest(ctx context.Context, req *connect.Request[oidc_pb.GetAuthRequestRequest]) (*connect.Response[oidc_pb.GetAuthRequestResponse], error) { + authRequest, err := s.query.AuthRequestByID(ctx, true, req.Msg.GetAuthRequestId(), true) if err != nil { logging.WithError(err).Error("query authRequest by ID") return nil, err } - return &oidc_pb.GetAuthRequestResponse{ + return connect.NewResponse(&oidc_pb.GetAuthRequestResponse{ AuthRequest: authRequestToPb(authRequest), - }, nil + }), nil } func authRequestToPb(a *query.AuthRequest) *oidc_pb.AuthRequest { @@ -73,18 +74,18 @@ func promptToPb(p domain.Prompt) oidc_pb.Prompt { } } -func (s *Server) CreateCallback(ctx context.Context, req *oidc_pb.CreateCallbackRequest) (*oidc_pb.CreateCallbackResponse, error) { - switch v := req.GetCallbackKind().(type) { +func (s *Server) CreateCallback(ctx context.Context, req *connect.Request[oidc_pb.CreateCallbackRequest]) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { + switch v := req.Msg.GetCallbackKind().(type) { case *oidc_pb.CreateCallbackRequest_Error: - return s.failAuthRequest(ctx, req.GetAuthRequestId(), v.Error) + return s.failAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Error) case *oidc_pb.CreateCallbackRequest_Session: - return s.linkSessionToAuthRequest(ctx, req.GetAuthRequestId(), v.Session) + return s.linkSessionToAuthRequest(ctx, req.Msg.GetAuthRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "OIDCv2-zee7A", "verification oneOf %T in method CreateCallback not implemented", v) } } -func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae *oidc_pb.AuthorizationError) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.FailAuthRequest(ctx, authRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -94,10 +95,10 @@ func (s *Server) failAuthRequest(ctx context.Context, authRequestID string, ae * if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func (s *Server) checkPermission(ctx context.Context, clientID string, userID string) error { @@ -114,7 +115,7 @@ func (s *Server) checkPermission(ctx context.Context, clientID string, userID st return nil } -func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*oidc_pb.CreateCallbackResponse, error) { +func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID string, session *oidc_pb.Session) (*connect.Response[oidc_pb.CreateCallbackResponse], error) { details, aar, err := s.command.LinkSessionToAuthRequest(ctx, authRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -130,10 +131,10 @@ func (s *Server) linkSessionToAuthRequest(ctx context.Context, authRequestID str if err != nil { return nil, err } - return &oidc_pb.CreateCallbackResponse{ + return connect.NewResponse(&oidc_pb.CreateCallbackResponse{ Details: object.DomainToDetailsPb(details), CallbackUrl: callback, - }, nil + }), nil } func errorReasonToDomain(errorReason oidc_pb.ErrorReason) domain.OIDCErrorReason { diff --git a/internal/api/grpc/oidc/v2beta/server.go b/internal/api/grpc/oidc/v2beta/server.go index 7595ae927e..5309a5093e 100644 --- a/internal/api/grpc/oidc/v2beta/server.go +++ b/internal/api/grpc/oidc/v2beta/server.go @@ -1,7 +1,10 @@ package oidc import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/oidc/v2beta/oidcconnect" ) -var _ oidc_pb.OIDCServiceServer = (*Server)(nil) +var _ oidcconnect.OIDCServiceHandler = (*Server)(nil) type Server struct { - oidc_pb.UnimplementedOIDCServiceServer command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - oidc_pb.RegisterOIDCServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return oidcconnect.NewOIDCServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return oidc_pb.File_zitadel_oidc_v2beta_oidc_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2/org.go b/internal/api/grpc/org/v2/org.go index b876826365..42832d147f 100644 --- a/internal/api/grpc/org/v2/org.go +++ b/internal/api/grpc/org/v2/org.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/api/grpc/user/v2" "github.com/zitadel/zitadel/internal/command" @@ -10,8 +12,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) AddOrganization(ctx context.Context, request *org.AddOrganizationRequest) (*org.AddOrganizationResponse, error) { - orgSetup, err := addOrganizationRequestToCommand(request) +func (s *Server) AddOrganization(ctx context.Context, request *connect.Request[org.AddOrganizationRequest]) (*connect.Response[org.AddOrganizationResponse], error) { + orgSetup, err := addOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -68,7 +70,7 @@ func addOrganizationRequestAdminToCommand(admin *org.AddOrganizationRequest_Admi } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.AddOrganizationResponse], err error) { admins := make([]*org.AddOrganizationResponse_CreatedAdmin, 0, len(createdOrg.OrgAdmins)) for _, admin := range createdOrg.OrgAdmins { admin, ok := admin.(*command.CreatedOrgAdmin) @@ -80,9 +82,9 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.AddOrganiza }) } } - return &org.AddOrganizationResponse{ + return connect.NewResponse(&org.AddOrganizationResponse{ Details: object.DomainToDetailsPb(createdOrg.ObjectDetails), OrganizationId: createdOrg.ObjectDetails.ResourceOwner, CreatedAdmins: admins, - }, nil + }), nil } diff --git a/internal/api/grpc/org/v2/org_test.go b/internal/api/grpc/org/v2/org_test.go index 37a3dca41a..564c5597ee 100644 --- a/internal/api/grpc/org/v2/org_test.go +++ b/internal/api/grpc/org/v2/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "google.golang.org/protobuf/types/known/timestamppb" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.AddOrganizationResponse + want *connect.Response[org.AddOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.AddOrganizationResponse{ + want: connect.NewResponse(&org.AddOrganizationResponse{ Details: &object.Details{ Sequence: 1, ChangeDate: timestamppb.New(now), @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { PhoneCode: gu.Ptr("phoneCode"), }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2/query.go b/internal/api/grpc/org/v2/query.go index 27f279d40e..09e2534e8d 100644 --- a/internal/api/grpc/org/v2/query.go +++ b/internal/api/grpc/org/v2/query.go @@ -3,6 +3,8 @@ package org import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" @@ -11,36 +13,36 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/org/v2" ) -func (s *Server) ListOrganizations(ctx context.Context, req *org.ListOrganizationsRequest) (*org.ListOrganizationsResponse, error) { +func (s *Server) ListOrganizations(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[org.ListOrganizationsResponse], error) { queries, err := listOrgRequestToModel(ctx, req) if err != nil { return nil, err } - orgs, err := s.query.SearchOrgs(ctx, queries, s.checkPermission) + orgs, err := s.query.SearchOrgs(ctx, queries.Msg, s.checkPermission) if err != nil { return nil, err } - return &org.ListOrganizationsResponse{ + return connect.NewResponse(&org.ListOrganizationsResponse{ Result: organizationsToPb(orgs.Orgs), Details: object.ToListDetails(orgs.SearchResponse), - }, nil + }), nil } -func listOrgRequestToModel(ctx context.Context, req *org.ListOrganizationsRequest) (*query.OrgSearchQueries, error) { - offset, limit, asc := object.ListQueryToQuery(req.Query) - queries, err := orgQueriesToQuery(ctx, req.Queries) +func listOrgRequestToModel(ctx context.Context, req *connect.Request[org.ListOrganizationsRequest]) (*connect.Response[query.OrgSearchQueries], error) { + offset, limit, asc := object.ListQueryToQuery(req.Msg.Query) + queries, err := orgQueriesToQuery(ctx, req.Msg.Queries) if err != nil { return nil, err } - return &query.OrgSearchQueries{ + return connect.NewResponse(&query.OrgSearchQueries{ SearchRequest: query.SearchRequest{ Offset: offset, Limit: limit, - SortingColumn: fieldNameToOrganizationColumn(req.SortingColumn), + SortingColumn: fieldNameToOrganizationColumn(req.Msg.SortingColumn), Asc: asc, }, Queries: queries, - }, nil + }), nil } func orgQueriesToQuery(ctx context.Context, queries []*org.SearchQuery) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/org/v2/server.go b/internal/api/grpc/org/v2/server.go index 36588f3eb7..6fd318d114 100644 --- a/internal/api/grpc/org/v2/server.go +++ b/internal/api/grpc/org/v2/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/org/v2" + "github.com/zitadel/zitadel/pkg/grpc/org/v2/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer command *command.Commands query *query.Queries checkPermission domain.PermissionCheck @@ -34,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/org/v2beta/helper.go b/internal/api/grpc/org/v2beta/helper.go index 6f47819bb4..77c3130488 100644 --- a/internal/api/grpc/org/v2beta/helper.go +++ b/internal/api/grpc/org/v2beta/helper.go @@ -3,6 +3,7 @@ package org import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" // TODO fix below @@ -71,7 +72,7 @@ func OrgStateToPb(state domain.OrgState) v2beta_org.OrgState { } } -func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrganizationResponse, err error) { +func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *connect.Response[org.CreateOrganizationResponse], err error) { admins := make([]*org.OrganizationAdmin, len(createdOrg.OrgAdmins)) for i, admin := range createdOrg.OrgAdmins { switch admin := admin.(type) { @@ -95,11 +96,11 @@ func createdOrganizationToPb(createdOrg *command.CreatedOrg) (_ *org.CreateOrgan } } } - return &org.CreateOrganizationResponse{ + return connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(createdOrg.ObjectDetails.EventDate), Id: createdOrg.ObjectDetails.ResourceOwner, OrganizationAdmins: admins, - }, nil + }), nil } func OrgViewsToPb(orgs []*query.Org) []*v2beta_org.Organization { diff --git a/internal/api/grpc/org/v2beta/org.go b/internal/api/grpc/org/v2beta/org.go index 66198757cb..35e1d72d3c 100644 --- a/internal/api/grpc/org/v2beta/org.go +++ b/internal/api/grpc/org/v2beta/org.go @@ -4,6 +4,7 @@ import ( "context" "errors" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" metadata "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2beta" @@ -17,8 +18,8 @@ import ( v2beta_org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" ) -func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.CreateOrganizationRequest) (*v2beta_org.CreateOrganizationResponse, error) { - orgSetup, err := createOrganizationRequestToCommand(request) +func (s *Server) CreateOrganization(ctx context.Context, request *connect.Request[v2beta_org.CreateOrganizationRequest]) (*connect.Response[v2beta_org.CreateOrganizationResponse], error) { + orgSetup, err := createOrganizationRequestToCommand(request.Msg) if err != nil { return nil, err } @@ -29,19 +30,19 @@ func (s *Server) CreateOrganization(ctx context.Context, request *v2beta_org.Cre return createdOrganizationToPb(createdOrg) } -func (s *Server) UpdateOrganization(ctx context.Context, request *v2beta_org.UpdateOrganizationRequest) (*v2beta_org.UpdateOrganizationResponse, error) { - org, err := s.command.ChangeOrg(ctx, request.Id, request.Name) +func (s *Server) UpdateOrganization(ctx context.Context, request *connect.Request[v2beta_org.UpdateOrganizationRequest]) (*connect.Response[v2beta_org.UpdateOrganizationResponse], error) { + org, err := s.command.ChangeOrg(ctx, request.Msg.GetId(), request.Msg.GetName()) if err != nil { return nil, err } - return &v2beta_org.UpdateOrganizationResponse{ + return connect.NewResponse(&v2beta_org.UpdateOrganizationResponse{ ChangeDate: timestamppb.New(org.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.ListOrganizationsRequest) (*v2beta_org.ListOrganizationsResponse, error) { - queries, err := listOrgRequestToModel(s.systemDefaults, request) +func (s *Server) ListOrganizations(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationsRequest]) (*connect.Response[v2beta_org.ListOrganizationsResponse], error) { + queries, err := listOrgRequestToModel(s.systemDefaults, request.Msg) if err != nil { return nil, err } @@ -49,107 +50,107 @@ func (s *Server) ListOrganizations(ctx context.Context, request *v2beta_org.List if err != nil { return nil, err } - return &v2beta_org.ListOrganizationsResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationsResponse{ Organizations: OrgViewsToPb(orgs.Orgs), Pagination: &filter.PaginationResponse{ TotalResult: orgs.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganization(ctx context.Context, request *v2beta_org.DeleteOrganizationRequest) (*v2beta_org.DeleteOrganizationResponse, error) { - details, err := s.command.RemoveOrg(ctx, request.Id) +func (s *Server) DeleteOrganization(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationRequest]) (*connect.Response[v2beta_org.DeleteOrganizationResponse], error) { + details, err := s.command.RemoveOrg(ctx, request.Msg.GetId()) if err != nil { var notFoundError *zerrors.NotFoundError if errors.As(err, ¬FoundError) { - return &v2beta_org.DeleteOrganizationResponse{}, nil + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{}), nil } return nil, err } - return &v2beta_org.DeleteOrganizationResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) SetOrganizationMetadata(ctx context.Context, request *v2beta_org.SetOrganizationMetadataRequest) (*v2beta_org.SetOrganizationMetadataResponse, error) { - result, err := s.command.BulkSetOrgMetadata(ctx, request.OrganizationId, BulkSetOrgMetadataToDomain(request)...) +func (s *Server) SetOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.SetOrganizationMetadataRequest]) (*connect.Response[v2beta_org.SetOrganizationMetadataResponse], error) { + result, err := s.command.BulkSetOrgMetadata(ctx, request.Msg.GetOrganizationId(), BulkSetOrgMetadataToDomain(request.Msg)...) if err != nil { return nil, err } - return &org.SetOrganizationMetadataResponse{ + return connect.NewResponse(&org.SetOrganizationMetadataResponse{ SetDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationMetadata(ctx context.Context, request *v2beta_org.ListOrganizationMetadataRequest) (*v2beta_org.ListOrganizationMetadataResponse, error) { - metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request) +func (s *Server) ListOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.ListOrganizationMetadataRequest]) (*connect.Response[v2beta_org.ListOrganizationMetadataResponse], error) { + metadataQueries, err := ListOrgMetadataToDomain(s.systemDefaults, request.Msg) if err != nil { return nil, err } - res, err := s.query.SearchOrgMetadata(ctx, true, request.OrganizationId, metadataQueries, false) + res, err := s.query.SearchOrgMetadata(ctx, true, request.Msg.GetOrganizationId(), metadataQueries, false) if err != nil { return nil, err } - return &v2beta_org.ListOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.ListOrganizationMetadataResponse{ Metadata: metadata.OrgMetadataListToPb(res.Metadata), Pagination: &filter.PaginationResponse{ TotalResult: res.Count, - AppliedLimit: uint64(request.GetPagination().GetLimit()), + AppliedLimit: uint64(request.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *v2beta_org.DeleteOrganizationMetadataRequest) (*v2beta_org.DeleteOrganizationMetadataResponse, error) { - result, err := s.command.BulkRemoveOrgMetadata(ctx, request.OrganizationId, request.Keys...) +func (s *Server) DeleteOrganizationMetadata(ctx context.Context, request *connect.Request[v2beta_org.DeleteOrganizationMetadataRequest]) (*connect.Response[v2beta_org.DeleteOrganizationMetadataResponse], error) { + result, err := s.command.BulkRemoveOrgMetadata(ctx, request.Msg.GetOrganizationId(), request.Msg.Keys...) if err != nil { return nil, err } - return &v2beta_org.DeleteOrganizationMetadataResponse{ + return connect.NewResponse(&v2beta_org.DeleteOrganizationMetadataResponse{ DeletionDate: timestamppb.New(result.EventDate), - }, nil + }), nil } -func (s *Server) DeactivateOrganization(ctx context.Context, request *org.DeactivateOrganizationRequest) (*org.DeactivateOrganizationResponse, error) { - objectDetails, err := s.command.DeactivateOrg(ctx, request.Id) +func (s *Server) DeactivateOrganization(ctx context.Context, request *connect.Request[org.DeactivateOrganizationRequest]) (*connect.Response[org.DeactivateOrganizationResponse], error) { + objectDetails, err := s.command.DeactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.DeactivateOrganizationResponse{ + return connect.NewResponse(&org.DeactivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } -func (s *Server) ActivateOrganization(ctx context.Context, request *org.ActivateOrganizationRequest) (*org.ActivateOrganizationResponse, error) { - objectDetails, err := s.command.ReactivateOrg(ctx, request.Id) +func (s *Server) ActivateOrganization(ctx context.Context, request *connect.Request[org.ActivateOrganizationRequest]) (*connect.Response[org.ActivateOrganizationResponse], error) { + objectDetails, err := s.command.ReactivateOrg(ctx, request.Msg.GetId()) if err != nil { return nil, err } - return &org.ActivateOrganizationResponse{ + return connect.NewResponse(&org.ActivateOrganizationResponse{ ChangeDate: timestamppb.New(objectDetails.EventDate), - }, err + }), err } -func (s *Server) AddOrganizationDomain(ctx context.Context, request *org.AddOrganizationDomainRequest) (*org.AddOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) AddOrganizationDomain(ctx context.Context, request *connect.Request[org.AddOrganizationDomainRequest]) (*connect.Response[org.AddOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.AddOrgDomain(ctx, request.OrganizationId, request.Domain, userIDs) + details, err := s.command.AddOrgDomain(ctx, request.Msg.GetOrganizationId(), request.Msg.GetDomain(), userIDs) if err != nil { return nil, err } - return &org.AddOrganizationDomainResponse{ + return connect.NewResponse(&org.AddOrganizationDomainResponse{ CreationDate: timestamppb.New(details.EventDate), - }, nil + }), nil } -func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrganizationDomainsRequest) (*org.ListOrganizationDomainsResponse, error) { - queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req) +func (s *Server) ListOrganizationDomains(ctx context.Context, req *connect.Request[org.ListOrganizationDomainsRequest]) (*connect.Response[org.ListOrganizationDomainsResponse], error) { + queries, err := ListOrgDomainsRequestToModel(s.systemDefaults, req.Msg) if err != nil { return nil, err } - orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.OrganizationId) + orgIDQuery, err := query.NewOrgDomainOrgIDSearchQuery(req.Msg.GetOrganizationId()) if err != nil { return nil, err } @@ -159,48 +160,48 @@ func (s *Server) ListOrganizationDomains(ctx context.Context, req *org.ListOrgan if err != nil { return nil, err } - return &org.ListOrganizationDomainsResponse{ + return connect.NewResponse(&org.ListOrganizationDomainsResponse{ Domains: object.DomainsToPb(domains.Domains), Pagination: &filter.PaginationResponse{ TotalResult: domains.Count, - AppliedLimit: uint64(req.GetPagination().GetLimit()), + AppliedLimit: uint64(req.Msg.GetPagination().GetLimit()), }, - }, nil + }), nil } -func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *org.DeleteOrganizationDomainRequest) (*org.DeleteOrganizationDomainResponse, error) { - details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req)) +func (s *Server) DeleteOrganizationDomain(ctx context.Context, req *connect.Request[org.DeleteOrganizationDomainRequest]) (*connect.Response[org.DeleteOrganizationDomainResponse], error) { + details, err := s.command.RemoveOrgDomain(ctx, RemoveOrgDomainRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.DeleteOrganizationDomainResponse{ + return connect.NewResponse(&org.DeleteOrganizationDomainResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, err + }), err } -func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *org.GenerateOrganizationDomainValidationRequest) (*org.GenerateOrganizationDomainValidationResponse, error) { - token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req)) +func (s *Server) GenerateOrganizationDomainValidation(ctx context.Context, req *connect.Request[org.GenerateOrganizationDomainValidationRequest]) (*connect.Response[org.GenerateOrganizationDomainValidationResponse], error) { + token, url, err := s.command.GenerateOrgDomainValidation(ctx, GenerateOrgDomainValidationRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &org.GenerateOrganizationDomainValidationResponse{ + return connect.NewResponse(&org.GenerateOrganizationDomainValidationResponse{ Token: token, Url: url, - }, nil + }), nil } -func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *org.VerifyOrganizationDomainRequest) (*org.VerifyOrganizationDomainResponse, error) { - userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Domain, request.OrganizationId) +func (s *Server) VerifyOrganizationDomain(ctx context.Context, request *connect.Request[org.VerifyOrganizationDomainRequest]) (*connect.Response[org.VerifyOrganizationDomainResponse], error) { + userIDs, err := s.getClaimedUserIDsOfOrgDomain(ctx, request.Msg.GetDomain(), request.Msg.GetOrganizationId()) if err != nil { return nil, err } - details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request), userIDs) + details, err := s.command.ValidateOrgDomain(ctx, ValidateOrgDomainRequestToDomain(ctx, request.Msg), userIDs) if err != nil { return nil, err } - return &org.VerifyOrganizationDomainResponse{ + return connect.NewResponse(&org.VerifyOrganizationDomainResponse{ ChangeDate: timestamppb.New(details.EventDate), - }, nil + }), nil } func createOrganizationRequestToCommand(request *v2beta_org.CreateOrganizationRequest) (*command.OrgSetup, error) { diff --git a/internal/api/grpc/org/v2beta/org_test.go b/internal/api/grpc/org/v2beta/org_test.go index 346d6b88c1..85dec79be4 100644 --- a/internal/api/grpc/org/v2beta/org_test.go +++ b/internal/api/grpc/org/v2beta/org_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -138,7 +139,7 @@ func Test_createdOrganizationToPb(t *testing.T) { tests := []struct { name string args args - want *org.CreateOrganizationResponse + want *connect.Response[org.CreateOrganizationResponse] wantErr error }{ { @@ -159,7 +160,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - want: &org.CreateOrganizationResponse{ + want: connect.NewResponse(&org.CreateOrganizationResponse{ CreationDate: timestamppb.New(now), Id: "orgID", OrganizationAdmins: []*org.OrganizationAdmin{ @@ -173,7 +174,7 @@ func Test_createdOrganizationToPb(t *testing.T) { }, }, }, - }, + }), }, } for _, tt := range tests { diff --git a/internal/api/grpc/org/v2beta/server.go b/internal/api/grpc/org/v2beta/server.go index b7e8d4994f..8f9091c7c3 100644 --- a/internal/api/grpc/org/v2beta/server.go +++ b/internal/api/grpc/org/v2beta/server.go @@ -1,7 +1,10 @@ package org import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -10,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" org "github.com/zitadel/zitadel/pkg/grpc/org/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/org/v2beta/orgconnect" ) -var _ org.OrganizationServiceServer = (*Server)(nil) +var _ orgconnect.OrganizationServiceHandler = (*Server)(nil) type Server struct { - org.UnimplementedOrganizationServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -38,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - org.RegisterOrganizationServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return orgconnect.NewOrganizationServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return org.File_zitadel_org_v2beta_org_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/project/v2beta/integration/query_test.go b/internal/api/grpc/project/v2beta/integration/query_test.go index b648e8c1d7..f8159226c7 100644 --- a/internal/api/grpc/project/v2beta/integration/query_test.go +++ b/internal/api/grpc/project/v2beta/integration/query_test.go @@ -389,7 +389,8 @@ func TestServer_ListProjects(t *testing.T) { resp2 := createProject(iamOwnerCtx, instance, t, orgID, true, false) resp3 := createProject(iamOwnerCtx, instance, t, orgResp.GetOrganizationId(), false, true) request.Filters[0].Filter = &project.ProjectSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{resp1.GetId(), resp2.GetId(), resp3.GetId(), projectResp.GetId()}}, } response.Projects[0] = grantedProjectResp response.Projects[1] = projectResp @@ -1225,7 +1226,8 @@ func TestServer_ListProjectGrants(t *testing.T) { project2Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name2, false, false) project3Resp := instance.CreateProject(iamOwnerCtx, t, orgResp.GetOrganizationId(), name3, false, false) request.Filters[0].Filter = &project.ProjectGrantSearchFilter_InProjectIdsFilter{ - InProjectIdsFilter: &filter.InIDsFilter{Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}}, + InProjectIdsFilter: &filter.InIDsFilter{ + Ids: []string{project1Resp.GetId(), project2Resp.GetId(), project3Resp.GetId(), projectResp.GetId()}}, } createProjectGrant(iamOwnerCtx, instance, t, orgID, project1Resp.GetId(), name1) diff --git a/internal/api/grpc/project/v2beta/project.go b/internal/api/grpc/project/v2beta/project.go index 01b478f5be..95f08ea61e 100644 --- a/internal/api/grpc/project/v2beta/project.go +++ b/internal/api/grpc/project/v2beta/project.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,8 +14,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjectRequest) (*project_pb.CreateProjectResponse, error) { - add := projectCreateToCommand(req) +func (s *Server) CreateProject(ctx context.Context, req *connect.Request[project_pb.CreateProjectRequest]) (*connect.Response[project_pb.CreateProjectResponse], error) { + add := projectCreateToCommand(req.Msg) project, err := s.command.AddProject(ctx, add) if err != nil { return nil, err @@ -23,10 +24,10 @@ func (s *Server) CreateProject(ctx context.Context, req *project_pb.CreateProjec if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectResponse{ + return connect.NewResponse(&project_pb.CreateProjectResponse{ Id: add.AggregateID, CreationDate: creationDate, - }, nil + }), nil } func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { @@ -60,8 +61,8 @@ func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) d } } -func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjectRequest) (*project_pb.UpdateProjectResponse, error) { - project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req)) +func (s *Server) UpdateProject(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRequest]) (*connect.Response[project_pb.UpdateProjectResponse], error) { + project, err := s.command.ChangeProject(ctx, projectUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -69,9 +70,9 @@ func (s *Server) UpdateProject(ctx context.Context, req *project_pb.UpdateProjec if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectResponse{ + return connect.NewResponse(&project_pb.UpdateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.ChangeProject { @@ -91,13 +92,13 @@ func projectUpdateToCommand(req *project_pb.UpdateProjectRequest) *command.Chang } } -func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjectRequest) (*project_pb.DeleteProjectResponse, error) { - userGrantIDs, err := s.userGrantsFromProject(ctx, req.Id) +func (s *Server) DeleteProject(ctx context.Context, req *connect.Request[project_pb.DeleteProjectRequest]) (*connect.Response[project_pb.DeleteProjectResponse], error) { + userGrantIDs, err := s.userGrantsFromProject(ctx, req.Msg.GetId()) if err != nil { return nil, err } - deletedAt, err := s.command.DeleteProject(ctx, req.Id, "", userGrantIDs...) + deletedAt, err := s.command.DeleteProject(ctx, req.Msg.GetId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -105,9 +106,9 @@ func (s *Server) DeleteProject(ctx context.Context, req *project_pb.DeleteProjec if !deletedAt.IsZero() { deletionDate = timestamppb.New(deletedAt) } - return &project_pb.DeleteProjectResponse{ + return connect.NewResponse(&project_pb.DeleteProjectResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([]string, error) { @@ -117,15 +118,15 @@ func (s *Server) userGrantsFromProject(ctx context.Context, projectID string) ([ } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } return userGrantsToIDs(userGrants.UserGrants), nil } -func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.DeactivateProjectRequest) (*project_pb.DeactivateProjectResponse, error) { - details, err := s.command.DeactivateProject(ctx, req.Id, "") +func (s *Server) DeactivateProject(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectRequest]) (*connect.Response[project_pb.DeactivateProjectResponse], error) { + details, err := s.command.DeactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -133,13 +134,13 @@ func (s *Server) DeactivateProject(ctx context.Context, req *project_pb.Deactiva if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivateProjectRequest) (*project_pb.ActivateProjectResponse, error) { - details, err := s.command.ReactivateProject(ctx, req.Id, "") +func (s *Server) ActivateProject(ctx context.Context, req *connect.Request[project_pb.ActivateProjectRequest]) (*connect.Response[project_pb.ActivateProjectResponse], error) { + details, err := s.command.ReactivateProject(ctx, req.Msg.GetId(), "") if err != nil { return nil, err } @@ -147,9 +148,9 @@ func (s *Server) ActivateProject(ctx context.Context, req *project_pb.ActivatePr if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectResponse{ + return connect.NewResponse(&project_pb.ActivateProjectResponse{ ChangeDate: changeDate, - }, nil + }), nil } func userGrantsToIDs(userGrants []*query.UserGrant) []string { diff --git a/internal/api/grpc/project/v2beta/project_grant.go b/internal/api/grpc/project/v2beta/project_grant.go index 6c3b195c66..c2ccef1751 100644 --- a/internal/api/grpc/project/v2beta/project_grant.go +++ b/internal/api/grpc/project/v2beta/project_grant.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateProjectGrantRequest) (*project_pb.CreateProjectGrantResponse, error) { - add := projectGrantCreateToCommand(req) +func (s *Server) CreateProjectGrant(ctx context.Context, req *connect.Request[project_pb.CreateProjectGrantRequest]) (*connect.Response[project_pb.CreateProjectGrantResponse], error) { + add := projectGrantCreateToCommand(req.Msg) project, err := s.command.AddProjectGrant(ctx, add) if err != nil { return nil, err @@ -21,9 +22,9 @@ func (s *Server) CreateProjectGrant(ctx context.Context, req *project_pb.CreateP if !project.EventDate.IsZero() { creationDate = timestamppb.New(project.EventDate) } - return &project_pb.CreateProjectGrantResponse{ + return connect.NewResponse(&project_pb.CreateProjectGrantResponse{ CreationDate: creationDate, - }, nil + }), nil } func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *command.AddProjectGrant { @@ -37,8 +38,8 @@ func projectGrantCreateToCommand(req *project_pb.CreateProjectGrantRequest) *com } } -func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateProjectGrantRequest) (*project_pb.UpdateProjectGrantResponse, error) { - project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req)) +func (s *Server) UpdateProjectGrant(ctx context.Context, req *connect.Request[project_pb.UpdateProjectGrantRequest]) (*connect.Response[project_pb.UpdateProjectGrantResponse], error) { + project, err := s.command.ChangeProjectGrant(ctx, projectGrantUpdateToCommand(req.Msg)) if err != nil { return nil, err } @@ -46,9 +47,9 @@ func (s *Server) UpdateProjectGrant(ctx context.Context, req *project_pb.UpdateP if !project.EventDate.IsZero() { changeDate = timestamppb.New(project.EventDate) } - return &project_pb.UpdateProjectGrantResponse{ + return connect.NewResponse(&project_pb.UpdateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *command.ChangeProjectGrant { @@ -61,8 +62,8 @@ func projectGrantUpdateToCommand(req *project_pb.UpdateProjectGrantRequest) *com } } -func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.DeactivateProjectGrantRequest) (*project_pb.DeactivateProjectGrantResponse, error) { - details, err := s.command.DeactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) DeactivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeactivateProjectGrantRequest]) (*connect.Response[project_pb.DeactivateProjectGrantResponse], error) { + details, err := s.command.DeactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -70,13 +71,13 @@ func (s *Server) DeactivateProjectGrant(ctx context.Context, req *project_pb.Dea if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.DeactivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeactivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.ActivateProjectGrantRequest) (*project_pb.ActivateProjectGrantResponse, error) { - details, err := s.command.ReactivateProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "") +func (s *Server) ActivateProjectGrant(ctx context.Context, req *connect.Request[project_pb.ActivateProjectGrantRequest]) (*connect.Response[project_pb.ActivateProjectGrantResponse], error) { + details, err := s.command.ReactivateProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "") if err != nil { return nil, err } @@ -84,17 +85,17 @@ func (s *Server) ActivateProjectGrant(ctx context.Context, req *project_pb.Activ if !details.EventDate.IsZero() { changeDate = timestamppb.New(details.EventDate) } - return &project_pb.ActivateProjectGrantResponse{ + return connect.NewResponse(&project_pb.ActivateProjectGrantResponse{ ChangeDate: changeDate, - }, nil + }), nil } -func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteProjectGrantRequest) (*project_pb.DeleteProjectGrantResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.ProjectId, req.GrantedOrganizationId) +func (s *Server) DeleteProjectGrant(ctx context.Context, req *connect.Request[project_pb.DeleteProjectGrantRequest]) (*connect.Response[project_pb.DeleteProjectGrantResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectGrant(ctx, req.Msg.GetProjectId(), req.Msg.GetGrantedOrganizationId()) if err != nil { return nil, err } - details, err := s.command.DeleteProjectGrant(ctx, req.ProjectId, "", req.GrantedOrganizationId, "", userGrantIDs...) + details, err := s.command.DeleteProjectGrant(ctx, req.Msg.GetProjectId(), "", req.Msg.GetGrantedOrganizationId(), "", userGrantIDs...) if err != nil { return nil, err } @@ -102,9 +103,9 @@ func (s *Server) DeleteProjectGrant(ctx context.Context, req *project_pb.DeleteP if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.DeleteProjectGrantResponse{ + return connect.NewResponse(&project_pb.DeleteProjectGrantResponse{ DeletionDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, grantedOrganizationID string) ([]string, error) { @@ -118,7 +119,7 @@ func (s *Server) userGrantsFromProjectGrant(ctx context.Context, projectID, gran } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, grantQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/project_role.go b/internal/api/grpc/project/v2beta/project_role.go index 07fc4e9eac..b7105b38c6 100644 --- a/internal/api/grpc/project/v2beta/project_role.go +++ b/internal/api/grpc/project/v2beta/project_role.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" @@ -11,8 +12,8 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectRoleRequest) (*project_pb.AddProjectRoleResponse, error) { - role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req)) +func (s *Server) AddProjectRole(ctx context.Context, req *connect.Request[project_pb.AddProjectRoleRequest]) (*connect.Response[project_pb.AddProjectRoleResponse], error) { + role, err := s.command.AddProjectRole(ctx, addProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -20,9 +21,9 @@ func (s *Server) AddProjectRole(ctx context.Context, req *project_pb.AddProjectR if !role.EventDate.IsZero() { creationDate = timestamppb.New(role.EventDate) } - return &project_pb.AddProjectRoleResponse{ + return connect.NewResponse(&project_pb.AddProjectRoleResponse{ CreationDate: creationDate, - }, nil + }), nil } func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *command.AddProjectRole { @@ -41,8 +42,8 @@ func addProjectRoleRequestToCommand(req *project_pb.AddProjectRoleRequest) *comm } } -func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdateProjectRoleRequest) (*project_pb.UpdateProjectRoleResponse, error) { - role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req)) +func (s *Server) UpdateProjectRole(ctx context.Context, req *connect.Request[project_pb.UpdateProjectRoleRequest]) (*connect.Response[project_pb.UpdateProjectRoleResponse], error) { + role, err := s.command.ChangeProjectRole(ctx, updateProjectRoleRequestToCommand(req.Msg)) if err != nil { return nil, err } @@ -50,9 +51,9 @@ func (s *Server) UpdateProjectRole(ctx context.Context, req *project_pb.UpdatePr if !role.EventDate.IsZero() { changeDate = timestamppb.New(role.EventDate) } - return &project_pb.UpdateProjectRoleResponse{ + return connect.NewResponse(&project_pb.UpdateProjectRoleResponse{ ChangeDate: changeDate, - }, nil + }), nil } func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) *command.ChangeProjectRole { @@ -75,16 +76,16 @@ func updateProjectRoleRequestToCommand(req *project_pb.UpdateProjectRoleRequest) } } -func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemoveProjectRoleRequest) (*project_pb.RemoveProjectRoleResponse, error) { - userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) +func (s *Server) RemoveProjectRole(ctx context.Context, req *connect.Request[project_pb.RemoveProjectRoleRequest]) (*connect.Response[project_pb.RemoveProjectRoleResponse], error) { + userGrantIDs, err := s.userGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.ProjectId, req.RoleKey) + projectGrantIDs, err := s.projectGrantsFromProjectAndRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey()) if err != nil { return nil, err } - details, err := s.command.RemoveProjectRole(ctx, req.ProjectId, req.RoleKey, "", projectGrantIDs, userGrantIDs...) + details, err := s.command.RemoveProjectRole(ctx, req.Msg.GetProjectId(), req.Msg.GetRoleKey(), "", projectGrantIDs, userGrantIDs...) if err != nil { return nil, err } @@ -92,9 +93,9 @@ func (s *Server) RemoveProjectRole(ctx context.Context, req *project_pb.RemovePr if !details.EventDate.IsZero() { deletionDate = timestamppb.New(details.EventDate) } - return &project_pb.RemoveProjectRoleResponse{ + return connect.NewResponse(&project_pb.RemoveProjectRoleResponse{ RemovalDate: deletionDate, - }, nil + }), nil } func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, roleKey string) ([]string, error) { @@ -108,7 +109,7 @@ func (s *Server) userGrantsFromProjectAndRole(ctx context.Context, projectID, ro } userGrants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{projectQuery, rolesQuery}, - }, false) + }, false, nil) if err != nil { return nil, err } diff --git a/internal/api/grpc/project/v2beta/query.go b/internal/api/grpc/project/v2beta/query.go index 42b69a480e..c736c5a086 100644 --- a/internal/api/grpc/project/v2beta/query.go +++ b/internal/api/grpc/project/v2beta/query.go @@ -3,6 +3,7 @@ package project import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" filter "github.com/zitadel/zitadel/internal/api/grpc/filter/v2beta" @@ -13,18 +14,18 @@ import ( project_pb "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" ) -func (s *Server) GetProject(ctx context.Context, req *project_pb.GetProjectRequest) (*project_pb.GetProjectResponse, error) { - project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Id, s.checkPermission) +func (s *Server) GetProject(ctx context.Context, req *connect.Request[project_pb.GetProjectRequest]) (*connect.Response[project_pb.GetProjectResponse], error) { + project, err := s.query.GetProjectByIDWithPermission(ctx, true, req.Msg.GetId(), s.checkPermission) if err != nil { return nil, err } - return &project_pb.GetProjectResponse{ + return connect.NewResponse(&project_pb.GetProjectResponse{ Project: projectToPb(project), - }, nil + }), nil } -func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsRequest) (*project_pb.ListProjectsResponse, error) { - queries, err := s.listProjectRequestToModel(req) +func (s *Server) ListProjects(ctx context.Context, req *connect.Request[project_pb.ListProjectsRequest]) (*connect.Response[project_pb.ListProjectsResponse], error) { + queries, err := s.listProjectRequestToModel(req.Msg) if err != nil { return nil, err } @@ -32,10 +33,10 @@ func (s *Server) ListProjects(ctx context.Context, req *project_pb.ListProjectsR if err != nil { return nil, err } - return &project_pb.ListProjectsResponse{ + return connect.NewResponse(&project_pb.ListProjectsResponse{ Projects: grantedProjectsToPb(resp.GrantedProjects), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRequestToModel(req *project_pb.ListProjectsRequest) (*query.ProjectAndGrantedProjectSearchQueries, error) { @@ -213,8 +214,8 @@ func privateLabelingSettingToPb(setting domain.PrivateLabelingSetting) project_p } } -func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProjectGrantsRequest) (*project_pb.ListProjectGrantsResponse, error) { - queries, err := s.listProjectGrantsRequestToModel(req) +func (s *Server) ListProjectGrants(ctx context.Context, req *connect.Request[project_pb.ListProjectGrantsRequest]) (*connect.Response[project_pb.ListProjectGrantsResponse], error) { + queries, err := s.listProjectGrantsRequestToModel(req.Msg) if err != nil { return nil, err } @@ -222,10 +223,10 @@ func (s *Server) ListProjectGrants(ctx context.Context, req *project_pb.ListProj if err != nil { return nil, err } - return &project_pb.ListProjectGrantsResponse{ + return connect.NewResponse(&project_pb.ListProjectGrantsResponse{ ProjectGrants: projectGrantsToPb(resp.ProjectGrants), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, resp.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectGrantsRequestToModel(req *project_pb.ListProjectGrantsRequest) (*query.ProjectGrantSearchQueries, error) { @@ -329,12 +330,12 @@ func projectGrantStateToPb(state domain.ProjectGrantState) project_pb.ProjectGra } } -func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProjectRolesRequest) (*project_pb.ListProjectRolesResponse, error) { - queries, err := s.listProjectRolesRequestToModel(req) +func (s *Server) ListProjectRoles(ctx context.Context, req *connect.Request[project_pb.ListProjectRolesRequest]) (*connect.Response[project_pb.ListProjectRolesResponse], error) { + queries, err := s.listProjectRolesRequestToModel(req.Msg) if err != nil { return nil, err } - err = queries.AppendProjectIDQuery(req.ProjectId) + err = queries.AppendProjectIDQuery(req.Msg.GetProjectId()) if err != nil { return nil, err } @@ -342,10 +343,10 @@ func (s *Server) ListProjectRoles(ctx context.Context, req *project_pb.ListProje if err != nil { return nil, err } - return &project_pb.ListProjectRolesResponse{ + return connect.NewResponse(&project_pb.ListProjectRolesResponse{ ProjectRoles: roleViewsToPb(roles.ProjectRoles), Pagination: filter.QueryToPaginationPb(queries.SearchRequest, roles.SearchResponse), - }, nil + }), nil } func (s *Server) listProjectRolesRequestToModel(req *project_pb.ListProjectRolesRequest) (*query.ProjectRoleSearchQueries, error) { diff --git a/internal/api/grpc/project/v2beta/server.go b/internal/api/grpc/project/v2beta/server.go index fe197f9688..12c18ae4c6 100644 --- a/internal/api/grpc/project/v2beta/server.go +++ b/internal/api/grpc/project/v2beta/server.go @@ -1,21 +1,23 @@ package project import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" - "github.com/zitadel/zitadel/internal/api/grpc/server" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/config/systemdefaults" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" project "github.com/zitadel/zitadel/pkg/grpc/project/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/project/v2beta/projectconnect" ) -var _ project.ProjectServiceServer = (*Server)(nil) +var _ projectconnect.ProjectServiceHandler = (*Server)(nil) type Server struct { - project.UnimplementedProjectServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -39,8 +41,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - project.RegisterProjectServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return projectconnect.NewProjectServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return project.File_zitadel_project_v2beta_project_service_proto } func (s *Server) AppName() string { @@ -54,7 +60,3 @@ func (s *Server) MethodPrefix() string { func (s *Server) AuthMethods() authz.MethodMapping { return project.ProjectService_AuthMethods } - -func (s *Server) RegisterGateway() server.RegisterGatewayFunc { - return project.RegisterProjectServiceHandler -} diff --git a/internal/api/grpc/saml/v2/saml.go b/internal/api/grpc/saml/v2/saml.go index 43eae5feb1..5491a5e04b 100644 --- a/internal/api/grpc/saml/v2/saml.go +++ b/internal/api/grpc/saml/v2/saml.go @@ -3,6 +3,7 @@ package saml import ( "context" + "connectrpc.com/connect" "github.com/zitadel/logging" "github.com/zitadel/saml/pkg/provider" "google.golang.org/protobuf/types/known/timestamppb" @@ -16,15 +17,15 @@ import ( saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" ) -func (s *Server) GetSAMLRequest(ctx context.Context, req *saml_pb.GetSAMLRequestRequest) (*saml_pb.GetSAMLRequestResponse, error) { - authRequest, err := s.query.SamlRequestByID(ctx, true, req.GetSamlRequestId(), true) +func (s *Server) GetSAMLRequest(ctx context.Context, req *connect.Request[saml_pb.GetSAMLRequestRequest]) (*connect.Response[saml_pb.GetSAMLRequestResponse], error) { + authRequest, err := s.query.SamlRequestByID(ctx, true, req.Msg.GetSamlRequestId(), true) if err != nil { logging.WithError(err).Error("query samlRequest by ID") return nil, err } - return &saml_pb.GetSAMLRequestResponse{ + return connect.NewResponse(&saml_pb.GetSAMLRequestResponse{ SamlRequest: samlRequestToPb(authRequest), - }, nil + }), nil } func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { @@ -34,18 +35,18 @@ func samlRequestToPb(a *query.SamlRequest) *saml_pb.SAMLRequest { } } -func (s *Server) CreateResponse(ctx context.Context, req *saml_pb.CreateResponseRequest) (*saml_pb.CreateResponseResponse, error) { - switch v := req.GetResponseKind().(type) { +func (s *Server) CreateResponse(ctx context.Context, req *connect.Request[saml_pb.CreateResponseRequest]) (*connect.Response[saml_pb.CreateResponseResponse], error) { + switch v := req.Msg.GetResponseKind().(type) { case *saml_pb.CreateResponseRequest_Error: - return s.failSAMLRequest(ctx, req.GetSamlRequestId(), v.Error) + return s.failSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Error) case *saml_pb.CreateResponseRequest_Session: - return s.linkSessionToSAMLRequest(ctx, req.GetSamlRequestId(), v.Session) + return s.linkSessionToSAMLRequest(ctx, req.Msg.GetSamlRequestId(), v.Session) default: return nil, zerrors.ThrowUnimplementedf(nil, "SAMLv2-0Tfak3fBS0", "verification oneOf %T in method CreateResponse not implemented", v) } } -func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae *saml_pb.AuthorizationError) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.FailSAMLRequest(ctx, samlRequestID, errorReasonToDomain(ae.GetError())) if err != nil { return nil, err @@ -55,7 +56,7 @@ func (s *Server) failSAMLRequest(ctx context.Context, samlRequestID string, ae * if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func (s *Server) checkPermission(ctx context.Context, issuer string, userID string) error { @@ -72,7 +73,7 @@ func (s *Server) checkPermission(ctx context.Context, issuer string, userID stri return nil } -func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*saml_pb.CreateResponseResponse, error) { +func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID string, session *saml_pb.Session) (*connect.Response[saml_pb.CreateResponseResponse], error) { details, aar, err := s.command.LinkSessionToSAMLRequest(ctx, samlRequestID, session.GetSessionId(), session.GetSessionToken(), true, s.checkPermission) if err != nil { return nil, err @@ -87,7 +88,7 @@ func (s *Server) linkSessionToSAMLRequest(ctx context.Context, samlRequestID str if err != nil { return nil, err } - return createCallbackResponseFromBinding(details, url, body, authReq.RelayState), nil + return connect.NewResponse(createCallbackResponseFromBinding(details, url, body, authReq.RelayState)), nil } func createCallbackResponseFromBinding(details *domain.ObjectDetails, url string, body string, relayState string) *saml_pb.CreateResponseResponse { diff --git a/internal/api/grpc/saml/v2/server.go b/internal/api/grpc/saml/v2/server.go index 62299d88c5..312a7c356a 100644 --- a/internal/api/grpc/saml/v2/server.go +++ b/internal/api/grpc/saml/v2/server.go @@ -1,7 +1,10 @@ package saml import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,9 +12,10 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" saml_pb "github.com/zitadel/zitadel/pkg/grpc/saml/v2" + "github.com/zitadel/zitadel/pkg/grpc/saml/v2/samlconnect" ) -var _ saml_pb.SAMLServiceServer = (*Server)(nil) +var _ samlconnect.SAMLServiceHandler = (*Server)(nil) type Server struct { saml_pb.UnimplementedSAMLServiceServer @@ -38,8 +42,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - saml_pb.RegisterSAMLServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return samlconnect.NewSAMLServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return saml_pb.File_zitadel_saml_v2_saml_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/server/connect_middleware/access_interceptor.go b/internal/api/grpc/server/connect_middleware/access_interceptor.go new file mode 100644 index 0000000000..a08df59860 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/access_interceptor.go @@ -0,0 +1,57 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + http_util "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AccessStorageInterceptor(svc *logstore.Service[*record.AccessLog]) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + resp, handlerErr := handler(ctx, req) + + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + var respStatus uint32 + if code := connect.CodeOf(handlerErr); code != connect.CodeUnknown { + respStatus = uint32(code) + } + + respHeader := http.Header{} + if resp != nil { + respHeader = resp.Header() + } + instance := authz.GetInstance(ctx) + domainCtx := http_util.DomainContext(ctx) + + r := &record.AccessLog{ + LogDate: time.Now(), + Protocol: record.GRPC, + RequestURL: req.Spec().Procedure, + ResponseStatus: respStatus, + RequestHeaders: req.Header(), + ResponseHeaders: respHeader, + InstanceID: instance.InstanceID(), + ProjectID: instance.ProjectID(), + RequestedDomain: domainCtx.RequestedDomain(), + RequestedHost: domainCtx.RequestedHost(), + } + + svc.Handle(interceptorCtx, r) + return resp, handlerErr + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/activity_interceptor.go b/internal/api/grpc/server/connect_middleware/activity_interceptor.go new file mode 100644 index 0000000000..4ba6044645 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/activity_interceptor.go @@ -0,0 +1,52 @@ +package connect_middleware + +import ( + "context" + "net/http" + "slices" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/activity" + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + ainfo "github.com/zitadel/zitadel/internal/api/info" +) + +func ActivityInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = activityInfoFromGateway(ctx, req.Header()).SetMethod(req.Spec().Procedure).IntoContext(ctx) + resp, err := handler(ctx, req) + if isResourceAPI(req.Spec().Procedure) { + code, _, _, _ := gerrors.ExtractZITADELError(err) + ctx = ainfo.ActivityInfoFromContext(ctx).SetGRPCStatus(code).IntoContext(ctx) + activity.TriggerGRPCWithContext(ctx, activity.ResourceAPI) + } + return resp, err + } + } +} + +var resourcePrefixes = []string{ + "/zitadel.management.v1.ManagementService/", + "/zitadel.admin.v1.AdminService/", + "/zitadel.user.v2.UserService/", + "/zitadel.settings.v2.SettingsService/", + "/zitadel.user.v2beta.UserService/", + "/zitadel.settings.v2beta.SettingsService/", + "/zitadel.auth.v1.AuthService/", +} + +func isResourceAPI(method string) bool { + return slices.ContainsFunc(resourcePrefixes, func(prefix string) bool { + return strings.HasPrefix(method, prefix) + }) +} + +func activityInfoFromGateway(ctx context.Context, headers http.Header) *ainfo.ActivityInfo { + info := ainfo.ActivityInfoFromContext(ctx) + path := headers.Get(activity.PathKey) + requestMethod := headers.Get(activity.RequestMethodKey) + return info.SetPath(path).SetRequestMethod(requestMethod) +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor.go b/internal/api/grpc/server/connect_middleware/auth_interceptor.go new file mode 100644 index 0000000000..9e500601d0 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "errors" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func AuthorizationInterceptor(verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return authorize(ctx, req, handler, verifier, systemUserPermissions, authConfig) + } + } +} + +func authorize(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.APITokenVerifier, systemUserPermissions authz.Config, authConfig authz.Config) (_ connect.AnyResponse, err error) { + authOpt, needsToken := verifier.CheckAuthMethod(req.Spec().Procedure) + if !needsToken { + return handler(ctx, req) + } + + authCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + authToken := req.Header().Get(http.Authorization) + if authToken == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("auth header missing")) + } + + orgID, orgDomain := orgIDAndDomainFromRequest(req) + ctxSetter, err := authz.CheckUserAuthorization(authCtx, req, authToken, orgID, orgDomain, verifier, systemUserPermissions.RolePermissionMappings, authConfig.RolePermissionMappings, authOpt, req.Spec().Procedure) + if err != nil { + return nil, err + } + span.End() + return handler(ctxSetter(ctx), req) +} + +func orgIDAndDomainFromRequest(req connect.AnyRequest) (id, domain string) { + orgID := req.Header().Get(http.ZitadelOrgID) + oz, ok := req.Any().(OrganizationFromRequest) + if ok { + id = oz.OrganizationFromRequestConnect().ID + domain = oz.OrganizationFromRequestConnect().Domain + if id != "" || domain != "" { + return id, domain + } + } + return orgID, domain +} + +type Organization struct { + ID string + Domain string +} + +type OrganizationFromRequest interface { + OrganizationFromRequestConnect() *Organization +} diff --git a/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go new file mode 100644 index 0000000000..06e716c140 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/auth_interceptor_test.go @@ -0,0 +1,318 @@ +package connect_middleware + +import ( + "context" + "errors" + "net/http" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +const anAPIRole = "AN_API_ROLE" + +type authzRepoMock struct{} + +func (v *authzRepoMock) VerifyAccessToken(ctx context.Context, token, clientID, projectID string) (string, string, string, string, string, error) { + return "", "", "", "", "", nil +} + +func (v *authzRepoMock) SearchMyMemberships(ctx context.Context, orgID string, _ bool) ([]*authz.Membership, error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeOrganization, + AggregateID: orgID, + Roles: []string{anAPIRole}, + }}, nil +} + +func (v *authzRepoMock) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (string, []string, error) { + return "", nil, nil +} + +func (v *authzRepoMock) ExistsOrg(ctx context.Context, orgID, domain string) (string, error) { + return orgID, nil +} + +func (v *authzRepoMock) VerifierClientID(ctx context.Context, appName string) (string, string, error) { + return "", "", nil +} + +var ( + accessTokenOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "user1", "", "", "", "org1", nil + }) + accessTokenNOK = authz.AccessTokenVerifierFunc(func(ctx context.Context, token string) (userID string, clientID string, agentID string, prefLan string, resourceOwner string, err error) { + return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "TEST-fQHDI", "unauthenticaded") + }) + systemTokenNOK = authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return nil, "", errors.New("system token error") + }) +) + +type mockOrgFromRequest struct { + id string +} + +func (m *mockOrgFromRequest) OrganizationFromRequestConnect() *Organization { + return &Organization{ + ID: m.id, + Domain: "", + } +} + +func Test_authorize(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + verifier func() authz.APITokenVerifier + authConfig authz.Config + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no token needed ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/no/token/needed"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{}) + return verifier + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "auth header missing error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication"}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "unauthorized error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"wrong"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + nil, + true, + }, + }, + { + "authorized ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "authorized ok, org by request", + args{ + ctx: context.Background(), + req: &mockReq[mockOrgFromRequest]{ + Request: connect.Request[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + procedure: "/need/authentication", + header: http.Header{"Authorization": []string{"Bearer token"}}, + }, + handler: emptyMockHandler(&connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, authz.CtxData{ + UserID: "user1", + OrgID: "id", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "authenticated"}}) + return verifier + }, + authConfig: authz.Config{}, + }, + res{ + &connect.Response[mockOrgFromRequest]{Msg: &mockOrgFromRequest{"id"}}, + false, + }, + }, + { + "permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "permission ok", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "user1", + OrgID: "org1", + ResourceOwner: "org1", + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenOK, systemTokenNOK) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: anAPIRole, + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something.else"}, + }}, + }, + }, + res{ + nil, + true, + }, + }, + { + "system token permission denied error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{procedure: "/need/authentication", header: http.Header{"Authorization": []string{"Bearer token"}}}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{ + UserID: "systemuser", + SystemMemberships: authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, + SystemUserPermissions: []authz.SystemUserPermissions{{ + MemberType: authz.MemberTypeSystem, + Permissions: []string{"to.do.something"}, + }}, + }), + verifier: func() authz.APITokenVerifier { + verifier := authz.StartAPITokenVerifier(&authzRepoMock{}, accessTokenNOK, authz.SystemTokenVerifierFunc(func(ctx context.Context, token string, orgID string) (memberships authz.Memberships, userID string, err error) { + return authz.Memberships{{ + MemberType: authz.MemberTypeSystem, + Roles: []string{"A_SYSTEM_ROLE"}, + }}, "systemuser", nil + })) + verifier.RegisterServer("need", "need", authz.MethodMapping{"/need/authentication": authz.Option{Permission: "to.do.something"}}) + return verifier + }, + authConfig: authz.Config{ + RolePermissionMappings: []authz.RoleMapping{{ + Role: "A_SYSTEM_ROLE", + Permissions: []string{"to.do.something"}, + }}, + }, + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := authorize(tt.args.ctx, tt.args.req, tt.args.handler(t), tt.args.verifier(), tt.args.authConfig, tt.args.authConfig) + if (err != nil) != tt.res.wantErr { + t.Errorf("authorize() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("authorize() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/cache_interceptor.go b/internal/api/grpc/server/connect_middleware/cache_interceptor.go new file mode 100644 index 0000000000..60ba0032f1 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/cache_interceptor.go @@ -0,0 +1,31 @@ +package connect_middleware + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + + _ "github.com/zitadel/zitadel/internal/statik" +) + +func NoCacheInterceptor() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + headers := map[string]string{ + "cache-control": "no-store", + "expires": time.Now().UTC().Format(http.TimeFormat), + "pragma": "no-cache", + } + resp, err := handler(ctx, req) + if err != nil { + return nil, err + } + for key, value := range headers { + resp.Header().Set(key, value) + } + return resp, err + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/call_interceptor.go b/internal/api/grpc/server/connect_middleware/call_interceptor.go new file mode 100644 index 0000000000..cc74e10f85 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/call_interceptor.go @@ -0,0 +1,18 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/call" +) + +func CallDurationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + ctx = call.WithTimestamp(ctx) + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor.go b/internal/api/grpc/server/connect_middleware/error_interceptor.go new file mode 100644 index 0000000000..9aef95bc6d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor.go @@ -0,0 +1,23 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/grpc/gerrors" + _ "github.com/zitadel/zitadel/internal/statik" +) + +func ErrorHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return toConnectError(ctx, req, handler) + } + } +} + +func toConnectError(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + return resp, gerrors.ZITADELToConnectError(err) // TODO ! +} diff --git a/internal/api/grpc/server/connect_middleware/error_interceptor_test.go b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go new file mode 100644 index 0000000000..954f2fd58f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/error_interceptor_test.go @@ -0,0 +1,65 @@ +package connect_middleware + +import ( + "context" + "reflect" + "testing" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" +) + +func Test_toGRPCError(t *testing.T) { + type args struct { + ctx context.Context + req connect.AnyRequest + handler func(t *testing.T) connect.UnaryFunc + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "no error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: emptyMockHandler(&connect.Response[struct{}]{}, authz.CtxData{}), + }, + res{ + &connect.Response[struct{}]{}, + false, + }, + }, + { + "error", + args{ + ctx: context.Background(), + req: &mockReq[struct{}]{}, + handler: errorMockHandler(), + }, + res{ + nil, + true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := toConnectError(tt.args.ctx, tt.args.req, tt.args.handler(t)) + if (err != nil) != tt.res.wantErr { + t.Errorf("toGRPCError() error = %v, wantErr %v", err, tt.res.wantErr) + return + } + if !reflect.DeepEqual(got, tt.res.want) { + t.Errorf("toGRPCError() got = %v, want %v", got, tt.res.want) + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor.go b/internal/api/grpc/server/connect_middleware/execution_interceptor.go new file mode 100644 index 0000000000..879496a33f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor.go @@ -0,0 +1,160 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + + "connectrpc.com/connect" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/execution" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func ExecutionHandler(queries *query.Queries) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + requestTargets, responseTargets := execution.QueryExecutionTargetsForRequestAndResponse(ctx, queries, req.Spec().Procedure) + + // call targets otherwise return req + handledReq, err := executeTargetsForRequest(ctx, requestTargets, req.Spec().Procedure, req) + if err != nil { + return nil, err + } + + response, err := handler(ctx, handledReq) + if err != nil { + return nil, err + } + + return executeTargetsForResponse(ctx, responseTargets, req.Spec().Procedure, handledReq, response) + } + } +} + +func executeTargetsForRequest(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest) (_ connect.AnyRequest, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return req, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoRequest{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return req, nil +} + +func executeTargetsForResponse(ctx context.Context, targets []execution.Target, fullMethod string, req connect.AnyRequest, resp connect.AnyResponse) (_ connect.AnyResponse, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + // if no targets are found, return without any calls + if len(targets) == 0 { + return resp, nil + } + + ctxData := authz.GetCtxData(ctx) + info := &ContextInfoResponse{ + FullMethod: fullMethod, + InstanceID: authz.GetInstance(ctx).InstanceID(), + ProjectID: ctxData.ProjectID, + OrgID: ctxData.OrgID, + UserID: ctxData.UserID, + Request: Message{req.Any().(proto.Message)}, + Response: Message{resp.Any().(proto.Message)}, + } + + _, err = execution.CallTargets(ctx, targets, info) + if err != nil { + return nil, err + } + return resp, nil +} + +var _ execution.ContextInfo = &ContextInfoRequest{} + +type ContextInfoRequest struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` +} + +type Message struct { + proto.Message +} + +func (r *Message) MarshalJSON() ([]byte, error) { + data, err := protojson.Marshal(r.Message) + if err != nil { + return nil, err + } + return data, nil +} + +func (r *Message) UnmarshalJSON(data []byte) error { + return protojson.Unmarshal(data, r.Message) +} + +func (c *ContextInfoRequest) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoRequest) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Request) +} + +func (c *ContextInfoRequest) GetContent() interface{} { + return c.Request.Message +} + +var _ execution.ContextInfo = &ContextInfoResponse{} + +type ContextInfoResponse struct { + FullMethod string `json:"fullMethod,omitempty"` + InstanceID string `json:"instanceID,omitempty"` + OrgID string `json:"orgID,omitempty"` + ProjectID string `json:"projectID,omitempty"` + UserID string `json:"userID,omitempty"` + Request Message `json:"request,omitempty"` + Response Message `json:"response,omitempty"` +} + +func (c *ContextInfoResponse) GetHTTPRequestBody() []byte { + data, err := json.Marshal(c) + if err != nil { + return nil + } + return data +} + +func (c *ContextInfoResponse) SetHTTPResponseBody(resp []byte) error { + return json.Unmarshal(resp, &c.Response) +} + +func (c *ContextInfoResponse) GetContent() interface{} { + return c.Response.Message +} diff --git a/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go new file mode 100644 index 0000000000..d910824f21 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/execution_interceptor_test.go @@ -0,0 +1,815 @@ +package connect_middleware + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "testing" + "time" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/execution" +) + +var _ execution.Target = &mockExecutionTarget{} + +type mockExecutionTarget struct { + InstanceID string + ExecutionID string + TargetID string + TargetType domain.TargetType + Endpoint string + Timeout time.Duration + InterruptOnError bool + SigningKey string +} + +func (e *mockExecutionTarget) SetEndpoint(endpoint string) { + e.Endpoint = endpoint +} +func (e *mockExecutionTarget) IsInterruptOnError() bool { + return e.InterruptOnError +} +func (e *mockExecutionTarget) GetEndpoint() string { + return e.Endpoint +} +func (e *mockExecutionTarget) GetTargetType() domain.TargetType { + return e.TargetType +} +func (e *mockExecutionTarget) GetTimeout() time.Duration { + return e.Timeout +} +func (e *mockExecutionTarget) GetTargetID() string { + return e.TargetID +} +func (e *mockExecutionTarget) GetExecutionID() string { + return e.ExecutionID +} +func (e *mockExecutionTarget) GetSigningKey() string { + return e.SigningKey +} + +func newMockContentRequest(content string) *connect.Request[structpb.Struct] { + return connect.NewRequest(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContentResponse(content string) *connect.Response[structpb.Struct] { + return connect.NewResponse(&structpb.Struct{ + Fields: map[string]*structpb.Value{ + "content": { + Kind: &structpb.Value_StringValue{StringValue: content}, + }, + }, + }) +} + +func newMockContextInfoRequest(fullMethod, request string) *ContextInfoRequest { + return &ContextInfoRequest{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + } +} + +func newMockContextInfoResponse(fullMethod, request, response string) *ContextInfoResponse { + return &ContextInfoResponse{ + FullMethod: fullMethod, + Request: Message{Message: newMockContentRequest(request).Msg}, + Response: Message{Message: newMockContentResponse(response).Msg}, + } +} + +func Test_executeTargetsForGRPCFullMethod_request(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + }, + res{ + want: newMockContentRequest("request"), + }, + }, + { + "target, not reachable", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + }, + }, + targets: []target{}, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, error without interrupt", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, wrong request", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + {reqBody: newMockContextInfoRequest("/service/method", "wrong")}, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content1"), + }, + }, + { + "target async, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Second, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "target async, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeAsync, + Timeout: time.Minute, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "webhook, error", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + sleep: 0, + statusCode: http.StatusInternalServerError, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "webhook, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeWebhook, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + want: newMockContentRequest("content"), + }, + }, + { + "with includes, interruptOnError", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 0, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + { + "with includes, timeout", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target1", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target2", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target3", + TargetType: domain.TargetTypeCall, + Timeout: time.Second, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse("content1"), + sleep: 0, + statusCode: http.StatusOK, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content1"), + respBody: newMockContentResponse("content2"), + sleep: 5 * time.Second, + statusCode: http.StatusBadRequest, + }, + { + reqBody: newMockContextInfoRequest("/service/method", "content2"), + respBody: newMockContentResponse("content3"), + sleep: 5 * time.Second, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("content"), + }, + res{ + wantErr: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForRequest( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} + +func testServerCall( + reqBody interface{}, + sleep time.Duration, + statusCode int, + respBody connect.AnyResponse, +) (string, func()) { + handler := func(w http.ResponseWriter, r *http.Request) { + data, err := json.Marshal(reqBody) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + sentBody, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if !reflect.DeepEqual(data, sentBody) { + http.Error(w, "error", http.StatusInternalServerError) + return + } + + if statusCode != http.StatusOK { + http.Error(w, "error", statusCode) + return + } + + time.Sleep(sleep) + + w.Header().Set("Content-Type", "application/json") + resp, err := protojson.Marshal(respBody.Any().(proto.Message)) + if err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + if _, err := w.Write(resp); err != nil { + http.Error(w, "error", http.StatusInternalServerError) + return + } + } + + server := httptest.NewServer(http.HandlerFunc(handler)) + + return server.URL, server.Close +} + +func Test_executeTargetsForGRPCFullMethod_response(t *testing.T) { + type target struct { + reqBody execution.ContextInfo + sleep time.Duration + statusCode int + respBody connect.AnyResponse + } + type args struct { + ctx context.Context + + executionTargets []execution.Target + targets []target + fullMethod string + req connect.AnyRequest + resp connect.AnyResponse + } + type res struct { + want interface{} + wantErr bool + } + tests := []struct { + name string + args args + res res + }{ + { + "target, executionTargets nil", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: nil, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, executionTargets empty", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{}, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response"), + }, + }, + { + "target, empty response", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "request./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoRequest("/service/method", "content"), + respBody: newMockContentResponse(""), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest(""), + resp: newMockContentResponse(""), + }, + res{ + wantErr: true, + }, + }, + { + "target, ok", + args{ + ctx: context.Background(), + fullMethod: "/service/method", + executionTargets: []execution.Target{ + &mockExecutionTarget{ + InstanceID: "instance", + ExecutionID: "response./zitadel.session.v2.SessionService/SetSession", + TargetID: "target", + TargetType: domain.TargetTypeCall, + Timeout: time.Minute, + InterruptOnError: true, + SigningKey: "signingkey", + }, + }, + targets: []target{ + { + reqBody: newMockContextInfoResponse("/service/method", "request", "response"), + respBody: newMockContentResponse("response1"), + sleep: 0, + statusCode: http.StatusOK, + }, + }, + req: newMockContentRequest("request"), + resp: newMockContentResponse("response"), + }, + res{ + want: newMockContentResponse("response1"), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + closeFuncs := make([]func(), len(tt.args.targets)) + for i, target := range tt.args.targets { + url, closeF := testServerCall( + target.reqBody, + target.sleep, + target.statusCode, + target.respBody, + ) + + et := tt.args.executionTargets[i].(*mockExecutionTarget) + et.SetEndpoint(url) + closeFuncs[i] = closeF + } + + resp, err := executeTargetsForResponse( + tt.args.ctx, + tt.args.executionTargets, + tt.args.fullMethod, + tt.args.req, + tt.args.resp, + ) + + if tt.res.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.EqualExportedValues(t, tt.res.want, resp) + + for _, closeF := range closeFuncs { + closeF() + } + }) + } +} diff --git a/internal/api/grpc/server/connect_middleware/instance_interceptor.go b/internal/api/grpc/server/connect_middleware/instance_interceptor.go new file mode 100644 index 0000000000..27f59313f8 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/instance_interceptor.go @@ -0,0 +1,107 @@ +package connect_middleware + +import ( + "context" + "errors" + "fmt" + "strings" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + "golang.org/x/text/language" + + "github.com/zitadel/zitadel/internal/api/authz" + zitadel_http "github.com/zitadel/zitadel/internal/api/http" + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" + object_v3 "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" +) + +func InstanceInterceptor(verifier authz.InstanceVerifier, externalDomain string, explicitInstanceIdServices ...string) connect.UnaryInterceptorFunc { + translator, err := i18n.NewZitadelTranslator(language.English) + logging.OnError(err).Panic("unable to get translator") + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return setInstance(ctx, req, handler, verifier, externalDomain, translator, explicitInstanceIdServices...) + } + } +} + +func setInstance(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, externalDomain string, translator *i18n.Translator, idFromRequestsServices ...string) (_ connect.AnyResponse, err error) { + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + for _, service := range idFromRequestsServices { + if !strings.HasPrefix(service, "/") { + service = "/" + service + } + if strings.HasPrefix(req.Spec().Procedure, service) { + withInstanceIDProperty, ok := req.Any().(interface { + GetInstanceId() string + }) + if !ok { + return handler(ctx, req) + } + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, withInstanceIDProperty.GetInstanceId()) + } + } + explicitInstanceRequest, ok := req.Any().(interface { + GetInstance() *object_v3.Instance + }) + if ok { + instance := explicitInstanceRequest.GetInstance() + if id := instance.GetId(); id != "" { + return addInstanceByID(interceptorCtx, req, handler, verifier, translator, id) + } + if domain := instance.GetDomain(); domain != "" { + return addInstanceByDomain(interceptorCtx, req, handler, verifier, translator, domain) + } + } + return addInstanceByRequestedHost(interceptorCtx, req, handler, verifier, translator, externalDomain) +} + +func addInstanceByID(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, id string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByID(ctx, id) + if err != nil { + notFoundErr := new(zerrors.ZitadelError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using id %s: %w", id, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByDomain(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, domain string) (connect.AnyResponse, error) { + instance, err := verifier.InstanceByHost(ctx, domain, "") + if err != nil { + notFoundErr := new(zerrors.NotFoundError) + if errors.As(err, ¬FoundErr) { + notFoundErr.Message = translator.LocalizeFromCtx(ctx, notFoundErr.GetMessage(), nil) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using domain %s: %w", domain, notFoundErr)) + } + return handler(authz.WithInstance(ctx, instance), req) +} + +func addInstanceByRequestedHost(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, verifier authz.InstanceVerifier, translator *i18n.Translator, externalDomain string) (connect.AnyResponse, error) { + requestContext := zitadel_http.DomainContext(ctx) + if requestContext.InstanceHost == "" { + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).Error("unable to set instance") + return nil, connect.NewError(connect.CodeNotFound, errors.New("no instanceHost specified")) + } + instance, err := verifier.InstanceByHost(ctx, requestContext.InstanceHost, requestContext.PublicHost) + if err != nil { + origin := zitadel_http.DomainContext(ctx) + logging.WithFields("origin", requestContext.Origin(), "externalDomain", externalDomain).WithError(err).Error("unable to set instance") + zErr := new(zerrors.ZitadelError) + if errors.As(err, &zErr) { + zErr.SetMessage(translator.LocalizeFromCtx(ctx, zErr.GetMessage(), nil)) + zErr.Parent = err + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s): %s", origin, externalDomain, zErr.Error())) + } + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("unable to set instance using origin %s (ExternalDomain is %s)", origin, externalDomain)) + } + return handler(authz.WithInstance(ctx, instance), req) +} diff --git a/internal/api/grpc/server/connect_middleware/limits_interceptor.go b/internal/api/grpc/server/connect_middleware/limits_interceptor.go new file mode 100644 index 0000000000..abf7e5f0aa --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/limits_interceptor.go @@ -0,0 +1,34 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func LimitsInterceptor(ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + instance := authz.GetInstance(ctx) + if block := instance.Block(); block != nil && *block { + return nil, zerrors.ThrowResourceExhausted(nil, "LIMITS-molsj", "Errors.Limits.Instance.Blocked") + } + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/metrics_interceptor.go b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go new file mode 100644 index 0000000000..552fa5658d --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/metrics_interceptor.go @@ -0,0 +1,96 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + "github.com/grpc-ecosystem/grpc-gateway/runtime" + "github.com/zitadel/logging" + "go.opentelemetry.io/otel/attribute" + "google.golang.org/grpc/codes" + + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/metrics" +) + +const ( + GrpcMethod = "grpc_method" + ReturnCode = "return_code" + GrpcRequestCounter = "grpc.server.request_counter" + GrpcRequestCounterDescription = "Grpc request counter" + TotalGrpcRequestCounter = "grpc.server.total_request_counter" + TotalGrpcRequestCounterDescription = "Total grpc request counter" + GrpcStatusCodeCounter = "grpc.server.grpc_status_code" + GrpcStatusCodeCounterDescription = "Grpc status code counter" +) + +func MetricsHandler(metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return RegisterMetrics(ctx, req, handler, metricTypes, ignoredMethodSuffixes...) + } + } +} + +func RegisterMetrics(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc, metricTypes []metrics.MetricType, ignoredMethodSuffixes ...string) (_ connect.AnyResponse, err error) { + if len(metricTypes) == 0 { + return handler(ctx, req) + } + + for _, ignore := range ignoredMethodSuffixes { + if strings.HasSuffix(req.Spec().Procedure, ignore) { + return handler(ctx, req) + } + } + + resp, err := handler(ctx, req) + if containsMetricsMethod(metrics.MetricTypeRequestCount, metricTypes) { + RegisterGrpcRequestCounter(ctx, req.Spec().Procedure) + } + if containsMetricsMethod(metrics.MetricTypeTotalCount, metricTypes) { + RegisterGrpcTotalRequestCounter(ctx) + } + if containsMetricsMethod(metrics.MetricTypeStatusCode, metricTypes) { + RegisterGrpcRequestCodeCounter(ctx, req.Spec().Procedure, err) + } + return resp, err +} + +func RegisterGrpcRequestCounter(ctx context.Context, path string) { + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + } + err := metrics.RegisterCounter(GrpcRequestCounter, GrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register grpc request counter") + err = metrics.AddCount(ctx, GrpcRequestCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc request count") +} + +func RegisterGrpcTotalRequestCounter(ctx context.Context) { + err := metrics.RegisterCounter(TotalGrpcRequestCounter, TotalGrpcRequestCounterDescription) + logging.OnError(err).Warn("failed to register total grpc request counter") + err = metrics.AddCount(ctx, TotalGrpcRequestCounter, 1, nil) + logging.OnError(err).Warn("failed to add total grpc request count") +} + +func RegisterGrpcRequestCodeCounter(ctx context.Context, path string, err error) { + statusCode := connect.CodeOf(err) + var labels = map[string]attribute.Value{ + GrpcMethod: attribute.StringValue(path), + ReturnCode: attribute.IntValue(runtime.HTTPStatusFromCode(codes.Code(statusCode))), + } + err = metrics.RegisterCounter(GrpcStatusCodeCounter, GrpcStatusCodeCounterDescription) + logging.OnError(err).Warn("failed to register grpc status code counter") + err = metrics.AddCount(ctx, GrpcStatusCodeCounter, 1, labels) + logging.OnError(err).Warn("failed to add grpc status code count") +} + +func containsMetricsMethod(metricType metrics.MetricType, metricTypes []metrics.MetricType) bool { + for _, m := range metricTypes { + if m == metricType { + return true + } + } + return false +} diff --git a/internal/api/grpc/server/connect_middleware/mock_test.go b/internal/api/grpc/server/connect_middleware/mock_test.go new file mode 100644 index 0000000000..abd996b01f --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/mock_test.go @@ -0,0 +1,50 @@ +package connect_middleware + +import ( + "context" + "net/http" + "testing" + + "connectrpc.com/connect" + "github.com/stretchr/testify/assert" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func emptyMockHandler(resp connect.AnyResponse, expectedCtxData authz.CtxData) func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) { + assert.Equal(t, expectedCtxData, authz.GetCtxData(ctx)) + return resp, nil + } + } +} + +func errorMockHandler() func(*testing.T) connect.UnaryFunc { + return func(t *testing.T) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return nil, zerrors.ThrowInternal(nil, "test", "error") + } + } +} + +type mockReq[t any] struct { + connect.Request[t] + + procedure string + header http.Header +} + +func (m *mockReq[T]) Spec() connect.Spec { + return connect.Spec{ + Procedure: m.procedure, + } +} + +func (m *mockReq[T]) Header() http.Header { + if m.header == nil { + m.header = make(http.Header) + } + return m.header +} diff --git a/internal/api/grpc/server/connect_middleware/quota_interceptor.go b/internal/api/grpc/server/connect_middleware/quota_interceptor.go new file mode 100644 index 0000000000..caa32511e4 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/quota_interceptor.go @@ -0,0 +1,53 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/logstore" + "github.com/zitadel/zitadel/internal/logstore/record" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +func QuotaExhaustedInterceptor(svc *logstore.Service[*record.AccessLog], ignoreService ...string) connect.UnaryInterceptorFunc { + for idx, service := range ignoreService { + if !strings.HasPrefix(service, "/") { + ignoreService[idx] = "/" + service + } + } + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (_ connect.AnyResponse, err error) { + if !svc.Enabled() { + return handler(ctx, req) + } + interceptorCtx, span := tracing.NewServerInterceptorSpan(ctx) + defer func() { span.EndWithError(err) }() + + // The auth interceptor will ensure that only authorized or public requests are allowed. + // So if there's no authorization context, we don't need to check for limitation + // Also, we don't limit calls with system user tokens + ctxData := authz.GetCtxData(ctx) + if ctxData.IsZero() || ctxData.SystemMemberships != nil { + return handler(ctx, req) + } + + for _, service := range ignoreService { + if strings.HasPrefix(req.Spec().Procedure, service) { + return handler(ctx, req) + } + } + + instance := authz.GetInstance(ctx) + remaining := svc.Limit(interceptorCtx, instance.InstanceID()) + if remaining != nil && *remaining == 0 { + return nil, zerrors.ThrowResourceExhausted(nil, "QUOTA-vjAy8", "Quota.Access.Exhausted") + } + span.End() + return handler(ctx, req) + } + } +} diff --git a/internal/api/grpc/server/connect_middleware/service_interceptor.go b/internal/api/grpc/server/connect_middleware/service_interceptor.go new file mode 100644 index 0000000000..c5cf798ce5 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/service_interceptor.go @@ -0,0 +1,45 @@ +package connect_middleware + +import ( + "context" + "strings" + + "connectrpc.com/connect" + + "github.com/zitadel/zitadel/internal/api/service" + _ "github.com/zitadel/zitadel/internal/statik" +) + +const ( + unknown = "UNKNOWN" +) + +func ServiceHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + serviceName, _ := serviceAndMethod(req.Spec().Procedure) + if serviceName != unknown { + return handler(ctx, req) + } + ctx = service.WithService(ctx, serviceName) + return handler(ctx, req) + } + } +} + +// serviceAndMethod returns the service and method from a procedure. +func serviceAndMethod(procedure string) (string, string) { + procedure = strings.TrimPrefix(procedure, "/") + serviceName, method := unknown, unknown + if strings.Contains(procedure, "/") { + long := strings.Split(procedure, "/")[0] + if strings.Contains(long, ".") { + split := strings.Split(long, ".") + serviceName = split[len(split)-1] + } + } + if strings.Contains(procedure, "/") { + method = strings.Split(procedure, "/")[1] + } + return serviceName, method +} diff --git a/internal/api/grpc/server/connect_middleware/translation_interceptor.go b/internal/api/grpc/server/connect_middleware/translation_interceptor.go new file mode 100644 index 0000000000..f01b1c85ab --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translation_interceptor.go @@ -0,0 +1,48 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + "github.com/zitadel/logging" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/i18n" + _ "github.com/zitadel/zitadel/internal/statik" + "github.com/zitadel/zitadel/internal/telemetry/tracing" +) + +func TranslationHandler() connect.UnaryInterceptorFunc { + + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + resp, err := handler(ctx, req) + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + if err != nil { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + return resp, translateError(ctx, err, translator) + } + if loc, ok := resp.Any().(localizers); ok { + translator, translatorError := getTranslator(ctx) + if translatorError != nil { + return resp, err + } + translateFields(ctx, loc, translator) + } + return resp, nil + } + } +} + +func getTranslator(ctx context.Context) (*i18n.Translator, error) { + translator, err := i18n.NewZitadelTranslator(authz.GetInstance(ctx).DefaultLanguage()) + if err != nil { + logging.New().WithError(err).Error("could not load translator") + } + return translator, err +} diff --git a/internal/api/grpc/server/connect_middleware/translator.go b/internal/api/grpc/server/connect_middleware/translator.go new file mode 100644 index 0000000000..6d61b1d772 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/translator.go @@ -0,0 +1,37 @@ +package connect_middleware + +import ( + "context" + "errors" + + "github.com/zitadel/zitadel/internal/i18n" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type localizers interface { + Localizers() []Localizer +} +type Localizer interface { + LocalizationKey() string + SetLocalizedMessage(string) +} + +func translateFields(ctx context.Context, object localizers, translator *i18n.Translator) { + if translator == nil || object == nil { + return + } + for _, field := range object.Localizers() { + field.SetLocalizedMessage(translator.LocalizeFromCtx(ctx, field.LocalizationKey(), nil)) + } +} + +func translateError(ctx context.Context, err error, translator *i18n.Translator) error { + if translator == nil || err == nil { + return err + } + caosErr := new(zerrors.ZitadelError) + if errors.As(err, &caosErr) { + caosErr.SetMessage(translator.LocalizeFromCtx(ctx, caosErr.GetMessage(), nil)) + } + return err +} diff --git a/internal/api/grpc/server/connect_middleware/validation_interceptor.go b/internal/api/grpc/server/connect_middleware/validation_interceptor.go new file mode 100644 index 0000000000..8441886114 --- /dev/null +++ b/internal/api/grpc/server/connect_middleware/validation_interceptor.go @@ -0,0 +1,36 @@ +package connect_middleware + +import ( + "context" + + "connectrpc.com/connect" + // import to make sure go.mod does not lose it + // because dependency is only needed for generated code + _ "github.com/envoyproxy/protoc-gen-validate/validate" +) + +func ValidationHandler() connect.UnaryInterceptorFunc { + return func(handler connect.UnaryFunc) connect.UnaryFunc { + return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { + return validate(ctx, req, handler) + } + } +} + +// validator interface needed for github.com/envoyproxy/protoc-gen-validate +// (it does not expose an interface itself) +type validator interface { + Validate() error +} + +func validate(ctx context.Context, req connect.AnyRequest, handler connect.UnaryFunc) (connect.AnyResponse, error) { + validate, ok := req.Any().(validator) + if !ok { + return handler(ctx, req) + } + err := validate.Validate() + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return handler(ctx, req) +} diff --git a/internal/api/grpc/server/gateway.go b/internal/api/grpc/server/gateway.go index ca7579ee89..b20819b850 100644 --- a/internal/api/grpc/server/gateway.go +++ b/internal/api/grpc/server/gateway.go @@ -171,7 +171,7 @@ func CreateGateway( }, nil } -func RegisterGateway(ctx context.Context, gateway *Gateway, server Server) error { +func RegisterGateway(ctx context.Context, gateway *Gateway, server WithGateway) error { err := server.RegisterGateway()(ctx, gateway.mux, gateway.connection) if err != nil { return fmt.Errorf("failed to register grpc gateway: %w", err) diff --git a/internal/api/grpc/server/server.go b/internal/api/grpc/server/server.go index b686d3add9..0c02087c89 100644 --- a/internal/api/grpc/server/server.go +++ b/internal/api/grpc/server/server.go @@ -2,11 +2,14 @@ package server import ( "crypto/tls" + "net/http" + "connectrpc.com/connect" grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware" "google.golang.org/grpc" "google.golang.org/grpc/credentials" healthpb "google.golang.org/grpc/health/grpc_health_v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" grpc_api "github.com/zitadel/zitadel/internal/api/grpc" @@ -19,21 +22,36 @@ import ( ) type Server interface { - RegisterServer(*grpc.Server) - RegisterGateway() RegisterGatewayFunc AppName() string MethodPrefix() string AuthMethods() authz.MethodMapping } +type GrpcServer interface { + Server + RegisterServer(*grpc.Server) +} + +type WithGateway interface { + Server + RegisterGateway() RegisterGatewayFunc +} + // WithGatewayPrefix extends the server interface with a prefix for the grpc gateway // // it's used for the System, Admin, Mgmt and Auth API type WithGatewayPrefix interface { - Server + GrpcServer + WithGateway GatewayPathPrefix() string } +type ConnectServer interface { + Server + RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) + FileDescriptor() protoreflect.FileDescriptor +} + func CreateServer( verifier authz.APITokenVerifier, systemAuthz authz.Config, diff --git a/internal/api/grpc/session/v2/query.go b/internal/api/grpc/session/v2/query.go index 73303dd9e8..78d8623ee7 100644 --- a/internal/api/grpc/session/v2/query.go +++ b/internal/api/grpc/session/v2/query.go @@ -4,6 +4,7 @@ import ( "context" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -26,18 +27,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -45,10 +46,10 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } func listSessionsRequestToQuery(ctx context.Context, req *session.ListSessionsRequest) (*query.SessionsSearchQueries, error) { diff --git a/internal/api/grpc/session/v2/server.go b/internal/api/grpc/session/v2/server.go index ee534cb26c..8f06cb3fb0 100644 --- a/internal/api/grpc/session/v2/server.go +++ b/internal/api/grpc/session/v2/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/session/v2" + "github.com/zitadel/zitadel/pkg/grpc/session/v2/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2/session.go b/internal/api/grpc/session/v2/session.go index 08f19368ef..94f686a72c 100644 --- a/internal/api/grpc/session/v2/session.go +++ b/internal/api/grpc/session/v2/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -17,12 +18,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/session/v2" ) -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -32,43 +33,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) createSessionRequestToCommand(ctx context.Context, req *session.CreateSessionRequest) ([]command.SessionCommand, map[string][]byte, *domain.UserAgent, time.Duration, error) { diff --git a/internal/api/grpc/session/v2beta/server.go b/internal/api/grpc/session/v2beta/server.go index cf0d0c27f0..e659b406eb 100644 --- a/internal/api/grpc/session/v2beta/server.go +++ b/internal/api/grpc/session/v2beta/server.go @@ -1,7 +1,10 @@ package session import ( - "google.golang.org/grpc" + "net/http" + + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -9,12 +12,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" session "github.com/zitadel/zitadel/pkg/grpc/session/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/session/v2beta/sessionconnect" ) -var _ session.SessionServiceServer = (*Server)(nil) +var _ sessionconnect.SessionServiceHandler = (*Server)(nil) type Server struct { - session.UnimplementedSessionServiceServer command *command.Commands query *query.Queries @@ -35,8 +38,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - session.RegisterSessionServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return sessionconnect.NewSessionServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return session.File_zitadel_session_v2beta_session_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/session/v2beta/session.go b/internal/api/grpc/session/v2beta/session.go index 3b36b8ba83..459cf77f05 100644 --- a/internal/api/grpc/session/v2beta/session.go +++ b/internal/api/grpc/session/v2beta/session.go @@ -6,6 +6,7 @@ import ( "net/http" "time" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" @@ -31,18 +32,18 @@ var ( } ) -func (s *Server) GetSession(ctx context.Context, req *session.GetSessionRequest) (*session.GetSessionResponse, error) { - res, err := s.query.SessionByID(ctx, true, req.GetSessionId(), req.GetSessionToken(), s.checkPermission) +func (s *Server) GetSession(ctx context.Context, req *connect.Request[session.GetSessionRequest]) (*connect.Response[session.GetSessionResponse], error) { + res, err := s.query.SessionByID(ctx, true, req.Msg.GetSessionId(), req.Msg.GetSessionToken(), s.checkPermission) if err != nil { return nil, err } - return &session.GetSessionResponse{ + return connect.NewResponse(&session.GetSessionResponse{ Session: sessionToPb(res), - }, nil + }), nil } -func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequest) (*session.ListSessionsResponse, error) { - queries, err := listSessionsRequestToQuery(ctx, req) +func (s *Server) ListSessions(ctx context.Context, req *connect.Request[session.ListSessionsRequest]) (*connect.Response[session.ListSessionsResponse], error) { + queries, err := listSessionsRequestToQuery(ctx, req.Msg) if err != nil { return nil, err } @@ -50,18 +51,18 @@ func (s *Server) ListSessions(ctx context.Context, req *session.ListSessionsRequ if err != nil { return nil, err } - return &session.ListSessionsResponse{ + return connect.NewResponse(&session.ListSessionsResponse{ Details: object.ToListDetails(sessions.SearchResponse), Sessions: sessionsToPb(sessions.Sessions), - }, nil + }), nil } -func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRequest) (*session.CreateSessionResponse, error) { - checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req) +func (s *Server) CreateSession(ctx context.Context, req *connect.Request[session.CreateSessionRequest]) (*connect.Response[session.CreateSessionResponse], error) { + checks, metadata, userAgent, lifetime, err := s.createSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } @@ -71,43 +72,43 @@ func (s *Server) CreateSession(ctx context.Context, req *session.CreateSessionRe return nil, err } - return &session.CreateSessionResponse{ + return connect.NewResponse(&session.CreateSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionId: set.ID, SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) SetSession(ctx context.Context, req *session.SetSessionRequest) (*session.SetSessionResponse, error) { - checks, err := s.setSessionRequestToCommand(ctx, req) +func (s *Server) SetSession(ctx context.Context, req *connect.Request[session.SetSessionRequest]) (*connect.Response[session.SetSessionResponse], error) { + checks, err := s.setSessionRequestToCommand(ctx, req.Msg) if err != nil { return nil, err } - challengeResponse, cmds, err := s.challengesToCommand(req.GetChallenges(), checks) + challengeResponse, cmds, err := s.challengesToCommand(req.Msg.GetChallenges(), checks) if err != nil { return nil, err } - set, err := s.command.UpdateSession(ctx, req.GetSessionId(), cmds, req.GetMetadata(), req.GetLifetime().AsDuration()) + set, err := s.command.UpdateSession(ctx, req.Msg.GetSessionId(), cmds, req.Msg.GetMetadata(), req.Msg.GetLifetime().AsDuration()) if err != nil { return nil, err } - return &session.SetSessionResponse{ + return connect.NewResponse(&session.SetSessionResponse{ Details: object.DomainToDetailsPb(set.ObjectDetails), SessionToken: set.NewToken, Challenges: challengeResponse, - }, nil + }), nil } -func (s *Server) DeleteSession(ctx context.Context, req *session.DeleteSessionRequest) (*session.DeleteSessionResponse, error) { - details, err := s.command.TerminateSession(ctx, req.GetSessionId(), req.GetSessionToken()) +func (s *Server) DeleteSession(ctx context.Context, req *connect.Request[session.DeleteSessionRequest]) (*connect.Response[session.DeleteSessionResponse], error) { + details, err := s.command.TerminateSession(ctx, req.Msg.GetSessionId(), req.Msg.GetSessionToken()) if err != nil { return nil, err } - return &session.DeleteSessionResponse{ + return connect.NewResponse(&session.DeleteSessionResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func sessionsToPb(sessions []*query.Session) []*session.Session { diff --git a/internal/api/grpc/settings/v2/query.go b/internal/api/grpc/settings/v2/query.go index b8994ccb87..d522424040 100644 --- a/internal/api/grpc/settings/v2/query.go +++ b/internal/api/grpc/settings/v2/query.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,24 +124,24 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - queries, err := activeIdentityProvidersToQuery(req) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + queries, err := activeIdentityProvidersToQuery(req.Msg) if err != nil { return nil, err } - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{Queries: queries}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequest) (_ []query.SearchQuery, err error) { @@ -180,30 +181,30 @@ func activeIdentityProvidersToQuery(req *settings.GetActiveIdentityProvidersRequ return q, nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *settings.GetHostedLoginTranslationRequest) (*settings.GetHostedLoginTranslationResponse, error) { - translation, err := s.query.GetHostedLoginTranslation(ctx, req) +func (s *Server) GetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.GetHostedLoginTranslationRequest]) (*connect.Response[settings.GetHostedLoginTranslationResponse], error) { + translation, err := s.query.GetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return translation, nil + return connect.NewResponse(translation), nil } diff --git a/internal/api/grpc/settings/v2/server.go b/internal/api/grpc/settings/v2/server.go index 9cae50824f..bfaec17fc2 100644 --- a/internal/api/grpc/settings/v2/server.go +++ b/internal/api/grpc/settings/v2/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2/settings.go b/internal/api/grpc/settings/v2/settings.go index 09ee6b27c8..c7db200211 100644 --- a/internal/api/grpc/settings/v2/settings.go +++ b/internal/api/grpc/settings/v2/settings.go @@ -3,25 +3,27 @@ package settings import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/settings/v2" ) -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *settings.SetHostedLoginTranslationRequest) (*settings.SetHostedLoginTranslationResponse, error) { - res, err := s.command.SetHostedLoginTranslation(ctx, req) +func (s *Server) SetHostedLoginTranslation(ctx context.Context, req *connect.Request[settings.SetHostedLoginTranslationRequest]) (*connect.Response[settings.SetHostedLoginTranslationResponse], error) { + res, err := s.command.SetHostedLoginTranslation(ctx, req.Msg) if err != nil { return nil, err } - return res, nil + return connect.NewResponse(res), nil } diff --git a/internal/api/grpc/settings/v2beta/server.go b/internal/api/grpc/settings/v2beta/server.go index 24c8f7774a..a8200a7216 100644 --- a/internal/api/grpc/settings/v2beta/server.go +++ b/internal/api/grpc/settings/v2beta/server.go @@ -2,8 +2,10 @@ package settings import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/assets" "github.com/zitadel/zitadel/internal/api/authz" @@ -11,12 +13,12 @@ import ( "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/query" settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" + "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta/settingsconnect" ) -var _ settings.SettingsServiceServer = (*Server)(nil) +var _ settingsconnect.SettingsServiceHandler = (*Server)(nil) type Server struct { - settings.UnimplementedSettingsServiceServer command *command.Commands query *query.Queries assetsAPIDomain func(context.Context) string @@ -35,8 +37,12 @@ func CreateServer( } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - settings.RegisterSettingsServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return settingsconnect.NewSettingsServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return settings.File_zitadel_settings_v2beta_settings_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/settings/v2beta/settings.go b/internal/api/grpc/settings/v2beta/settings.go index 6193f129ba..53d2c37c32 100644 --- a/internal/api/grpc/settings/v2beta/settings.go +++ b/internal/api/grpc/settings/v2beta/settings.go @@ -3,6 +3,7 @@ package settings import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/authz" @@ -14,12 +15,12 @@ import ( settings "github.com/zitadel/zitadel/pkg/grpc/settings/v2beta" ) -func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSettingsRequest) (*settings.GetLoginSettingsResponse, error) { - current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLoginSettings(ctx context.Context, req *connect.Request[settings.GetLoginSettingsRequest]) (*connect.Response[settings.GetLoginSettingsResponse], error) { + current, err := s.query.LoginPolicyByID(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLoginSettingsResponse{ + return connect.NewResponse(&settings.GetLoginSettingsResponse{ Settings: loginSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -27,15 +28,15 @@ func (s *Server) GetLoginSettings(ctx context.Context, req *settings.GetLoginSet ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.OrgID, }, - }, nil + }), nil } -func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *settings.GetPasswordComplexitySettingsRequest) (*settings.GetPasswordComplexitySettingsResponse, error) { - current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *connect.Request[settings.GetPasswordComplexitySettingsRequest]) (*connect.Response[settings.GetPasswordComplexitySettingsResponse], error) { + current, err := s.query.PasswordComplexityPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordComplexitySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordComplexitySettingsResponse{ Settings: passwordComplexitySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -43,15 +44,15 @@ func (s *Server) GetPasswordComplexitySettings(ctx context.Context, req *setting ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.GetPasswordExpirySettingsRequest) (*settings.GetPasswordExpirySettingsResponse, error) { - current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *connect.Request[settings.GetPasswordExpirySettingsRequest]) (*connect.Response[settings.GetPasswordExpirySettingsResponse], error) { + current, err := s.query.PasswordAgePolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetPasswordExpirySettingsResponse{ + return connect.NewResponse(&settings.GetPasswordExpirySettingsResponse{ Settings: passwordExpirySettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -59,15 +60,15 @@ func (s *Server) GetPasswordExpirySettings(ctx context.Context, req *settings.Ge ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrandingSettingsRequest) (*settings.GetBrandingSettingsResponse, error) { - current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetBrandingSettings(ctx context.Context, req *connect.Request[settings.GetBrandingSettingsRequest]) (*connect.Response[settings.GetBrandingSettingsResponse], error) { + current, err := s.query.ActiveLabelPolicyByOrg(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetBrandingSettingsResponse{ + return connect.NewResponse(&settings.GetBrandingSettingsResponse{ Settings: brandingSettingsToPb(current, s.assetsAPIDomain(ctx)), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -75,15 +76,15 @@ func (s *Server) GetBrandingSettings(ctx context.Context, req *settings.GetBrand ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainSettingsRequest) (*settings.GetDomainSettingsResponse, error) { - current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetDomainSettings(ctx context.Context, req *connect.Request[settings.GetDomainSettingsRequest]) (*connect.Response[settings.GetDomainSettingsResponse], error) { + current, err := s.query.DomainPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetDomainSettingsResponse{ + return connect.NewResponse(&settings.GetDomainSettingsResponse{ Settings: domainSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -91,15 +92,15 @@ func (s *Server) GetDomainSettings(ctx context.Context, req *settings.GetDomainS ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.GetLegalAndSupportSettingsRequest) (*settings.GetLegalAndSupportSettingsResponse, error) { - current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx()), false) +func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *connect.Request[settings.GetLegalAndSupportSettingsRequest]) (*connect.Response[settings.GetLegalAndSupportSettingsResponse], error) { + current, err := s.query.PrivacyPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), false) if err != nil { return nil, err } - return &settings.GetLegalAndSupportSettingsResponse{ + return connect.NewResponse(&settings.GetLegalAndSupportSettingsResponse{ Settings: legalAndSupportSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -107,15 +108,15 @@ func (s *Server) GetLegalAndSupportSettings(ctx context.Context, req *settings.G ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockoutSettingsRequest) (*settings.GetLockoutSettingsResponse, error) { - current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.GetCtx())) +func (s *Server) GetLockoutSettings(ctx context.Context, req *connect.Request[settings.GetLockoutSettingsRequest]) (*connect.Response[settings.GetLockoutSettingsResponse], error) { + current, err := s.query.LockoutPolicyByOrg(ctx, true, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx())) if err != nil { return nil, err } - return &settings.GetLockoutSettingsResponse{ + return connect.NewResponse(&settings.GetLockoutSettingsResponse{ Settings: lockoutSettingsToPb(current), Details: &object_pb.Details{ Sequence: current.Sequence, @@ -123,46 +124,46 @@ func (s *Server) GetLockoutSettings(ctx context.Context, req *settings.GetLockou ChangeDate: timestamppb.New(current.ChangeDate), ResourceOwner: current.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *settings.GetActiveIdentityProvidersRequest) (*settings.GetActiveIdentityProvidersResponse, error) { - links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) +func (s *Server) GetActiveIdentityProviders(ctx context.Context, req *connect.Request[settings.GetActiveIdentityProvidersRequest]) (*connect.Response[settings.GetActiveIdentityProvidersResponse], error) { + links, err := s.query.IDPLoginPolicyLinks(ctx, object.ResourceOwnerFromReq(ctx, req.Msg.GetCtx()), &query.IDPLoginPolicyLinksSearchQuery{}, false) if err != nil { return nil, err } - return &settings.GetActiveIdentityProvidersResponse{ + return connect.NewResponse(&settings.GetActiveIdentityProvidersResponse{ Details: object.ToListDetails(links.SearchResponse), IdentityProviders: identityProvidersToPb(links.Links), - }, nil + }), nil } -func (s *Server) GetGeneralSettings(ctx context.Context, _ *settings.GetGeneralSettingsRequest) (*settings.GetGeneralSettingsResponse, error) { +func (s *Server) GetGeneralSettings(ctx context.Context, _ *connect.Request[settings.GetGeneralSettingsRequest]) (*connect.Response[settings.GetGeneralSettingsResponse], error) { instance := authz.GetInstance(ctx) - return &settings.GetGeneralSettingsResponse{ + return connect.NewResponse(&settings.GetGeneralSettingsResponse{ SupportedLanguages: domain.LanguagesToStrings(i18n.SupportedLanguages()), DefaultOrgId: instance.DefaultOrganisationID(), DefaultLanguage: instance.DefaultLanguage().String(), - }, nil + }), nil } -func (s *Server) GetSecuritySettings(ctx context.Context, req *settings.GetSecuritySettingsRequest) (*settings.GetSecuritySettingsResponse, error) { +func (s *Server) GetSecuritySettings(ctx context.Context, req *connect.Request[settings.GetSecuritySettingsRequest]) (*connect.Response[settings.GetSecuritySettingsResponse], error) { policy, err := s.query.SecurityPolicy(ctx) if err != nil { return nil, err } - return &settings.GetSecuritySettingsResponse{ + return connect.NewResponse(&settings.GetSecuritySettingsResponse{ Settings: securityPolicyToSettingsPb(policy), - }, nil + }), nil } -func (s *Server) SetSecuritySettings(ctx context.Context, req *settings.SetSecuritySettingsRequest) (*settings.SetSecuritySettingsResponse, error) { - details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req)) +func (s *Server) SetSecuritySettings(ctx context.Context, req *connect.Request[settings.SetSecuritySettingsRequest]) (*connect.Response[settings.SetSecuritySettingsResponse], error) { + details, err := s.command.SetSecurityPolicy(ctx, securitySettingsToCommand(req.Msg)) if err != nil { return nil, err } - return &settings.SetSecuritySettingsResponse{ + return connect.NewResponse(&settings.SetSecuritySettingsResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/email.go b/internal/api/grpc/user/v2/email.go index 4b247ef10f..df68e58c7d 100644 --- a/internal/api/grpc/user/v2/email.go +++ b/internal/api/grpc/user/v2/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,26 +58,26 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeRequest) (resp *user.SendEmailCodeResponse, err error) { +func (s *Server) SendEmailCode(ctx context.Context, req *connect.Request[user.SendEmailCodeRequest]) (resp *connect.Response[user.SendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SendEmailCodeRequest_SendCode: - email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.SendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SendEmailCodeRequest_ReturnCode: - email, err = s.command.SendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.SendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.SendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method SendEmailCode not implemented", v) } @@ -84,30 +85,30 @@ func (s *Server) SendEmailCode(ctx context.Context, req *user.SendEmailCodeReque return nil, err } - return &user.SendEmailCodeResponse{ + return connect.NewResponse(&user.SendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go index d8a0891396..03af0f75be 100644 --- a/internal/api/grpc/user/v2/human.go +++ b/internal/api/grpc/user/v2/human.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/timestamppb" @@ -14,7 +15,14 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*user.CreateUserResponse, error) { +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*connect.Response[user.CreateUserResponse], error) { + metadataEntries := make([]*user.SetMetadataEntry, len(humanPb.Metadata)) + for i, metadataEntry := range humanPb.Metadata { + metadataEntries[i] = &user.SetMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } addHumanPb := &user.AddHumanUserRequest{ Username: userName, UserId: userId, @@ -26,6 +34,7 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs Phone: humanPb.Phone, IdpLinks: humanPb.IdpLinks, TotpSecret: humanPb.TotpSecret, + Metadata: metadataEntries, } switch pwType := humanPb.GetPasswordType().(type) { case *user.CreateUserRequest_Human_HashedPassword: @@ -52,15 +61,15 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs ); err != nil { return nil, err } - return &user.CreateUserResponse{ + return connect.NewResponse(&user.CreateUserResponse{ Id: newHuman.ID, CreationDate: timestamppb.New(newHuman.Details.EventDate), EmailCode: newHuman.EmailCode, PhoneCode: newHuman.PhoneCode, - }, nil + }), nil } -func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*user.UpdateUserResponse, error) { +func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) { cmd, err := updateHumanUserToCommand(userId, userName, humanPb) if err != nil { return nil, err @@ -68,11 +77,11 @@ func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUs if err = s.command.ChangeUserHuman(ctx, cmd, s.userCodeAlg); err != nil { return nil, err } - return &user.UpdateUserResponse{ + return connect.NewResponse(&user.UpdateUserResponse{ ChangeDate: timestamppb.New(cmd.Details.EventDate), EmailCode: cmd.EmailCode, PhoneCode: cmd.PhoneCode, - }, nil + }), nil } func updateHumanUserToCommand(userId string, userName *string, human *user.UpdateUserRequest_Human) (*command.ChangeHuman, error) { diff --git a/internal/api/grpc/user/v2/idp_link.go b/internal/api/grpc/user/v2/idp_link.go index bef40617cf..0b1e7ab998 100644 --- a/internal/api/grpc/user/v2/idp_link.go +++ b/internal/api/grpc/user/v2/idp_link.go @@ -3,6 +3,8 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/internal/domain" @@ -11,22 +13,22 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest) (_ *user.ListIDPLinksResponse, err error) { - queries, err := ListLinkedIDPsRequestToQuery(req) +func (s *Server) ListIDPLinks(ctx context.Context, req *connect.Request[user.ListIDPLinksRequest]) (_ *connect.Response[user.ListIDPLinksResponse], err error) { + queries, err := ListLinkedIDPsRequestToQuery(req.Msg) if err != nil { return nil, err } @@ -34,10 +36,10 @@ func (s *Server) ListIDPLinks(ctx context.Context, req *user.ListIDPLinksRequest if err != nil { return nil, err } - return &user.ListIDPLinksResponse{ + return connect.NewResponse(&user.ListIDPLinksResponse{ Result: IDPLinksToPb(res.Links), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func ListLinkedIDPsRequestToQuery(req *user.ListIDPLinksRequest) (*query.IDPUserLinksSearchQuery, error) { @@ -72,14 +74,14 @@ func IDPLinkToPb(link *query.IDPUserLink) *user.IDPLink { } } -func (s *Server) RemoveIDPLink(ctx context.Context, req *user.RemoveIDPLinkRequest) (*user.RemoveIDPLinkResponse, error) { - objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req)) +func (s *Server) RemoveIDPLink(ctx context.Context, req *connect.Request[user.RemoveIDPLinkRequest]) (*connect.Response[user.RemoveIDPLinkResponse], error) { + objectDetails, err := s.command.RemoveUserIDPLink(ctx, RemoveIDPLinkRequestToDomain(ctx, req.Msg)) if err != nil { return nil, err } - return &user.RemoveIDPLinkResponse{ + return connect.NewResponse(&user.RemoveIDPLinkResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } func RemoveIDPLinkRequestToDomain(ctx context.Context, req *user.RemoveIDPLinkRequest) *domain.UserIDPLink { diff --git a/internal/api/grpc/user/v2/integration_test/email_test.go b/internal/api/grpc/user/v2/integration_test/email_test.go index ad68ef5c5a..87f8a04dd8 100644 --- a/internal/api/grpc/user/v2/integration_test/email_test.go +++ b/internal/api/grpc/user/v2/integration_test/email_test.go @@ -10,7 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" - + "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" diff --git a/internal/api/grpc/user/v2/integration_test/key_test.go b/internal/api/grpc/user/v2/integration_test/key_test.go index e85903b2cb..bb4f8657fa 100644 --- a/internal/api/grpc/user/v2/integration_test/key_test.go +++ b/internal/api/grpc/user/v2/integration_test/key_test.go @@ -22,7 +22,7 @@ import ( ) func TestServer_AddKey(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -108,7 +108,7 @@ abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 ExpirationDate: expirationDate, }, func(request *user.AddKeyRequest) error { - resp := Instance.CreateUserTypeHuman(IamCTX) + resp := Instance.CreateUserTypeHuman(IamCTX, gofakeit.Email()) request.UserId = resp.Id return nil }, @@ -220,7 +220,7 @@ func TestServer_AddKey_Permission(t *testing.T) { } func TestServer_RemoveKey(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -388,7 +388,7 @@ func TestServer_ListKeys(t *testing.T) { }) require.NoError(t, err) otherOrgUserId := otherOrgUser.GetId() - otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX, Instance.DefaultOrg.Id).GetId() onlySinceTestStartFilter := &user.KeysSearchFilter{Filter: &user.KeysSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ Timestamp: timestamppb.Now(), Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, diff --git a/internal/api/grpc/user/v2/integration_test/metadata_test.go b/internal/api/grpc/user/v2/integration_test/metadata_test.go new file mode 100644 index 0000000000..c1e3bced0a --- /dev/null +++ b/internal/api/grpc/user/v2/integration_test/metadata_test.go @@ -0,0 +1,403 @@ +//go:build integration + +package user_test + +import ( + "context" + "encoding/base64" + "testing" + "time" + + "github.com/brianvoe/gofakeit/v6" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/internal/integration" + "github.com/zitadel/zitadel/pkg/grpc/filter/v2" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func TestServer_SetUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + dep func(request *user.SetUserMetadataRequest) + req *user.SetUserMetadataRequest + setDate bool + wantErr bool + }{ + { + name: "missing permission", + ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + wantErr: true, + }, + { + name: "set user metadata", + ctx: iamOwnerCTX, + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + setDate: true, + }, + { + name: "set user metadata, multiple", + ctx: iamOwnerCTX, + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + }, + }, + setDate: true, + }, + { + name: "set user metadata on non existent user", + ctx: iamOwnerCTX, + req: &user.SetUserMetadataRequest{ + UserId: "notexisting", + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + wantErr: true, + }, + { + name: "update user metadata", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}}, + }, + setDate: true, + }, + { + name: "update user metadata with same value", + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, + }, + setDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + creationDate := time.Now().UTC() + if tt.dep != nil { + tt.dep(tt.req) + } + got, err := Client.SetUserMetadata(tt.ctx, tt.req) + changeDate := time.Now().UTC() + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + + assertSetUserMetadataResponse(t, creationDate, changeDate, tt.setDate, got) + }) + } +} + +func assertSetUserMetadataResponse(t *testing.T, creationDate, changeDate time.Time, expectedSetDat bool, actualResp *user.SetUserMetadataResponse) { + if expectedSetDat { + if !changeDate.IsZero() { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, changeDate) + } else { + assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.SetDate) + } +} + +func TestServer_ListUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + type args struct { + ctx context.Context + dep func(context.Context, *user.ListUserMetadataRequest, *user.ListUserMetadataResponse) + req *user.ListUserMetadataRequest + } + + tests := []struct { + name string + args args + want *user.ListUserMetadataResponse + wantErr bool + }{ + { + name: "missing permission", + args: args{ + ctx: Instance.WithAuthorization(context.Background(), integration.UserTypeNoPermission), + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + Instance.SetUserMetadata(iamOwnerCTX, userID, "key1", "value1") + }, + req: &user.ListUserMetadataRequest{}, + }, + wantErr: true, + }, + { + name: "list request", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + metadataResp := Instance.SetUserMetadata(iamOwnerCTX, userID, "key1", "value1") + + response.Metadata[0] = &metadata.Metadata{ + CreationDate: metadataResp.GetSetDate(), + ChangeDate: metadataResp.GetSetDate(), + Key: "key1", + Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), + } + }, + req: &user.ListUserMetadataRequest{}, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, + }, + }, + }, + { + name: "list request single key", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + response.Metadata[0] = setUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.SetUserMetadata(iamOwnerCTX, userID, "key2", "value2") + Instance.SetUserMetadata(iamOwnerCTX, userID, "key3", "value3") + request.Filters[0] = &metadata.MetadataSearchFilter{ + Filter: &metadata.MetadataSearchFilter_KeyFilter{KeyFilter: &metadata.MetadataKeyFilter{Key: key}}, + } + }, + req: &user.ListUserMetadataRequest{ + Filters: []*metadata.MetadataSearchFilter{{}}, + }, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 1, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, + }, + }, + }, + { + name: "list multiple keys", + args: args{ + ctx: iamOwnerCTX, + dep: func(ctx context.Context, request *user.ListUserMetadataRequest, response *user.ListUserMetadataResponse) { + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + + response.Metadata[2] = setUserMetadata(iamOwnerCTX, userID, "key1", "value1") + response.Metadata[1] = setUserMetadata(iamOwnerCTX, userID, "key2", "value2") + response.Metadata[0] = setUserMetadata(iamOwnerCTX, userID, "key3", "value3") + }, + req: &user.ListUserMetadataRequest{}, + }, + want: &user.ListUserMetadataResponse{ + Pagination: &filter.PaginationResponse{ + TotalResult: 3, + AppliedLimit: 100, + }, + Metadata: []*metadata.Metadata{ + {}, {}, {}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.args.dep != nil { + tt.args.dep(tt.args.ctx, tt.args.req, tt.want) + } + + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(iamOwnerCTX, time.Minute) + require.EventuallyWithT(t, func(ttt *assert.CollectT) { + got, listErr := Instance.Client.UserV2.ListUserMetadata(tt.args.ctx, tt.args.req) + if tt.wantErr { + require.Error(ttt, listErr) + return + } + require.NoError(ttt, listErr) + // always first check length, otherwise its failed anyway + if assert.Len(ttt, got.Metadata, len(tt.want.Metadata)) { + assert.EqualExportedValues(ttt, got.Metadata, tt.want.Metadata) + } + assertPaginationResponse(ttt, tt.want.Pagination, got.Pagination) + }, retryDuration, tick, "timeout waiting for expected execution result") + }) + } +} + +func setUserMetadata(ctx context.Context, userID, key, value string) *metadata.Metadata { + metadataResp := Instance.SetUserMetadata(ctx, userID, key, value) + return &metadata.Metadata{ + CreationDate: metadataResp.GetSetDate(), + ChangeDate: metadataResp.GetSetDate(), + Key: key, + Value: []byte(base64.StdEncoding.EncodeToString([]byte(value))), + } +} + +func assertPaginationResponse(t *assert.CollectT, expected *filter.PaginationResponse, actual *filter.PaginationResponse) { + assert.Equal(t, expected.AppliedLimit, actual.AppliedLimit) + assert.Equal(t, expected.TotalResult, actual.TotalResult) +} + +func TestServer_DeleteUserMetadata(t *testing.T) { + iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner) + + tests := []struct { + name string + ctx context.Context + prepare func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) + req *user.DeleteUserMetadataRequest + wantDeletionDate bool + wantErr bool + }{ + { + name: "empty id", + ctx: iamOwnerCTX, + req: &user.DeleteUserMetadataRequest{ + UserId: "", + }, + wantErr: true, + }, + { + name: "delete, user not existing", + ctx: iamOwnerCTX, + req: &user.DeleteUserMetadataRequest{ + UserId: "notexisting", + }, + wantErr: true, + }, + { + name: "delete", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + request.Keys = []string{key} + return creationDate, time.Time{} + }, + req: &user.DeleteUserMetadataRequest{}, + wantDeletionDate: true, + }, + { + name: "delete, empty list", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) + return creationDate, time.Now().UTC() + }, + req: &user.DeleteUserMetadataRequest{}, + wantErr: true, + }, + { + name: "delete, already removed", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") + Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) + request.Keys = []string{key} + return creationDate, time.Now().UTC() + }, + req: &user.DeleteUserMetadataRequest{}, + wantErr: true, + }, + { + name: "delete, multiple", + ctx: iamOwnerCTX, + prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { + creationDate := time.Now().UTC() + userID := Instance.CreateUserTypeHuman(iamOwnerCTX, gofakeit.Email()).GetId() + request.UserId = userID + key1 := "key1" + Instance.SetUserMetadata(iamOwnerCTX, userID, key1, "value1") + key2 := "key2" + Instance.SetUserMetadata(iamOwnerCTX, userID, key2, "value1") + key3 := "key3" + Instance.SetUserMetadata(iamOwnerCTX, userID, key3, "value1") + request.Keys = []string{key1, key2, key3} + return creationDate, time.Time{} + }, + req: &user.DeleteUserMetadataRequest{}, + wantDeletionDate: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var creationDate, deletionDate time.Time + if tt.prepare != nil { + creationDate, deletionDate = tt.prepare(tt.req) + } + got, err := Instance.Client.UserV2.DeleteUserMetadata(tt.ctx, tt.req) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assertDeleteProjectResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + }) + } +} + +func assertDeleteProjectResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *user.DeleteUserMetadataResponse) { + if expectedDeletionDate { + if !deletionDate.IsZero() { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + } else { + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + } + } else { + assert.Nil(t, actualResp.DeletionDate) + } +} diff --git a/internal/api/grpc/user/v2/integration_test/pat_test.go b/internal/api/grpc/user/v2/integration_test/pat_test.go index ce974e0407..8ca6d80139 100644 --- a/internal/api/grpc/user/v2/integration_test/pat_test.go +++ b/internal/api/grpc/user/v2/integration_test/pat_test.go @@ -22,7 +22,7 @@ import ( ) func TestServer_AddPersonalAccessToken(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -64,7 +64,7 @@ func TestServer_AddPersonalAccessToken(t *testing.T) { ExpirationDate: expirationDate, }, func(request *user.AddPersonalAccessTokenRequest) error { - resp := Instance.CreateUserTypeHuman(IamCTX) + resp := Instance.CreateUserTypeHuman(IamCTX, gofakeit.Email()) request.UserId = resp.Id return nil }, @@ -172,7 +172,7 @@ func TestServer_AddPersonalAccessToken_Permission(t *testing.T) { } func TestServer_RemovePersonalAccessToken(t *testing.T) { - resp := Instance.CreateUserTypeMachine(IamCTX) + resp := Instance.CreateUserTypeMachine(IamCTX, Instance.DefaultOrg.Id) userId := resp.GetId() expirationDate := timestamppb.New(time.Now().Add(time.Hour * 24)) type args struct { @@ -339,7 +339,7 @@ func TestServer_ListPersonalAccessTokens(t *testing.T) { }) require.NoError(t, err) otherOrgUserId := otherOrgUser.GetId() - otherUserId := Instance.CreateUserTypeMachine(SystemCTX).GetId() + otherUserId := Instance.CreateUserTypeMachine(SystemCTX, Instance.DefaultOrg.Id).GetId() onlySinceTestStartFilter := &user.PersonalAccessTokensSearchFilter{Filter: &user.PersonalAccessTokensSearchFilter_CreatedDateFilter{CreatedDateFilter: &filter.TimestampFilter{ Timestamp: timestamppb.Now(), Method: filter.TimestampFilterMethod_TIMESTAMP_FILTER_METHOD_AFTER_OR_EQUALS, diff --git a/internal/api/grpc/user/v2/integration_test/secret_test.go b/internal/api/grpc/user/v2/integration_test/secret_test.go index 8ff537b1fd..4296e8e599 100644 --- a/internal/api/grpc/user/v2/integration_test/secret_test.go +++ b/internal/api/grpc/user/v2/integration_test/secret_test.go @@ -43,7 +43,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -55,7 +55,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -67,7 +67,7 @@ func TestServer_AddSecret(t *testing.T) { CTX, &user.AddSecretRequest{}, func(request *user.AddSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() _, err := Client.AddSecret(CTX, &user.AddSecretRequest{ UserId: resp.GetId(), @@ -202,7 +202,7 @@ func TestServer_RemoveSecret(t *testing.T) { CTX, &user.RemoveSecretRequest{}, func(request *user.RemoveSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() return nil }, @@ -215,7 +215,7 @@ func TestServer_RemoveSecret(t *testing.T) { CTX, &user.RemoveSecretRequest{}, func(request *user.RemoveSecretRequest) error { - resp := Instance.CreateUserTypeMachine(CTX) + resp := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id) request.UserId = resp.GetId() _, err := Instance.Client.UserV2.AddSecret(CTX, &user.AddSecretRequest{ UserId: resp.GetId(), diff --git a/internal/api/grpc/user/v2/integration_test/user_test.go b/internal/api/grpc/user/v2/integration_test/user_test.go index 1776c57fcb..452de6720c 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -4,6 +4,7 @@ package user_test import ( "context" + "encoding/base64" "fmt" "net/url" "os" @@ -1818,7 +1819,7 @@ func TestServer_DeleteUser(t *testing.T) { request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Instance.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateOrgMembership(t, CTX, Instance.DefaultOrg.Id, request.UserId) return CTX }, }, @@ -3945,6 +3946,44 @@ func TestServer_CreateUser(t *testing.T) { } }, }, + { + name: "with metadata", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + CTX, + &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + Verification: &user.SetHumanEmail_IsVerified{ + IsVerified: true, + }, + }, + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + }, + }, + }, + }, + }, + want: &user.CreateUserResponse{ + Id: "is generated", + }, + } + }, + }, { name: "with idp", testCase: func(runId string) testCase { @@ -4890,7 +4929,7 @@ func TestServer_UpdateUserTypeHuman(t *testing.T) { t.Run(tt.name, func(t *testing.T) { now := time.Now() runId := fmt.Sprint(now.UnixNano() + int64(i)) - userId := Instance.CreateUserTypeHuman(CTX).GetId() + userId := Instance.CreateUserTypeHuman(CTX, gofakeit.Email()).GetId() test := tt.testCase(runId, userId) got, err := Client.UpdateUser(test.args.ctx, test.args.req) if test.wantErr { @@ -4972,7 +5011,7 @@ func TestServer_UpdateUserTypeMachine(t *testing.T) { t.Run(tt.name, func(t *testing.T) { now := time.Now() runId := fmt.Sprint(now.UnixNano() + int64(i)) - userId := Instance.CreateUserTypeMachine(CTX).GetId() + userId := Instance.CreateUserTypeMachine(CTX, Instance.DefaultOrg.Id).GetId() test := tt.testCase(runId, userId) got, err := Client.UpdateUser(test.args.ctx, test.args.req) if test.wantErr { diff --git a/internal/api/grpc/user/v2/intent.go b/internal/api/grpc/user/v2/intent.go index 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/metadata.go b/internal/api/grpc/user/v2/metadata.go new file mode 100644 index 0000000000..338ce9fc45 --- /dev/null +++ b/internal/api/grpc/user/v2/metadata.go @@ -0,0 +1,80 @@ +package user + +import ( + "context" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" + "github.com/zitadel/zitadel/internal/api/grpc/metadata/v2" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/query" + "github.com/zitadel/zitadel/pkg/grpc/user/v2" +) + +func (s *Server) ListUserMetadata(ctx context.Context, req *connect.Request[user.ListUserMetadataRequest]) (*connect.Response[user.ListUserMetadataResponse], error) { + metadataQueries, err := s.listUserMetadataRequestToModel(req.Msg) + if err != nil { + return nil, err + } + res, err := s.query.SearchUserMetadata(ctx, true, req.Msg.UserId, metadataQueries, s.checkPermission) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.ListUserMetadataResponse{ + Metadata: metadata.UserMetadataListToPb(res.Metadata), + Pagination: filter.QueryToPaginationPb(metadataQueries.SearchRequest, res.SearchResponse), + }), nil +} + +func (s *Server) listUserMetadataRequestToModel(req *user.ListUserMetadataRequest) (*query.UserMetadataSearchQueries, error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) + if err != nil { + return nil, err + } + queries, err := metadata.UserMetadataFiltersToQuery(req.Filters) + if err != nil { + return nil, err + } + return &query.UserMetadataSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + SortingColumn: query.UserMetadataCreationDateCol, + }, + Queries: queries, + }, nil +} + +func (s *Server) SetUserMetadata(ctx context.Context, req *connect.Request[user.SetUserMetadataRequest]) (*connect.Response[user.SetUserMetadataResponse], error) { + result, err := s.command.BulkSetUserMetadata(ctx, req.Msg.UserId, "", setUserMetadataToDomain(req.Msg)...) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.SetUserMetadataResponse{ + SetDate: timestamppb.New(result.EventDate), + }), nil +} + +func setUserMetadataToDomain(req *user.SetUserMetadataRequest) []*domain.Metadata { + metadata := make([]*domain.Metadata, len(req.Metadata)) + for i, data := range req.Metadata { + metadata[i] = &domain.Metadata{ + Key: data.Key, + Value: data.Value, + } + } + return metadata +} + +func (s *Server) DeleteUserMetadata(ctx context.Context, req *connect.Request[user.DeleteUserMetadataRequest]) (*connect.Response[user.DeleteUserMetadataResponse], error) { + result, err := s.command.BulkRemoveUserMetadata(ctx, req.Msg.UserId, "", req.Msg.Keys...) + if err != nil { + return nil, err + } + return connect.NewResponse(&user.DeleteUserMetadataResponse{ + DeletionDate: timestamppb.New(result.EventDate), + }), nil +} diff --git a/internal/api/grpc/user/v2/otp.go b/internal/api/grpc/user/v2/otp.go index fd76cf2b93..2f04f438dd 100644 --- a/internal/api/grpc/user/v2/otp.go +++ b/internal/api/grpc/user/v2/otp.go @@ -3,39 +3,41 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddOTPSMS(ctx context.Context, req *user.AddOTPSMSRequest) (*user.AddOTPSMSResponse, error) { - details, err := s.command.AddHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) AddOTPSMS(ctx context.Context, req *connect.Request[user.AddOTPSMSRequest]) (*connect.Response[user.AddOTPSMSResponse], error) { + details, err := s.command.AddHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPSMSResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPSMS(ctx context.Context, req *user.RemoveOTPSMSRequest) (*user.RemoveOTPSMSResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPSMS(ctx context.Context, req *connect.Request[user.RemoveOTPSMSRequest]) (*connect.Response[user.RemoveOTPSMSResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPSMS(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPSMSResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } -func (s *Server) AddOTPEmail(ctx context.Context, req *user.AddOTPEmailRequest) (*user.AddOTPEmailResponse, error) { - details, err := s.command.AddHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) AddOTPEmail(ctx context.Context, req *connect.Request[user.AddOTPEmailRequest]) (*connect.Response[user.AddOTPEmailResponse], error) { + details, err := s.command.AddHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}, nil + return connect.NewResponse(&user.AddOTPEmailResponse{Details: object.DomainToDetailsPb(details)}), nil } -func (s *Server) RemoveOTPEmail(ctx context.Context, req *user.RemoveOTPEmailRequest) (*user.RemoveOTPEmailResponse, error) { - objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.GetUserId(), "") +func (s *Server) RemoveOTPEmail(ctx context.Context, req *connect.Request[user.RemoveOTPEmailRequest]) (*connect.Response[user.RemoveOTPEmailResponse], error) { + objectDetails, err := s.command.RemoveHumanOTPEmail(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveOTPEmailResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/passkey.go b/internal/api/grpc/user/v2/passkey.go index 145c1e5716..90c6d72d13 100644 --- a/internal/api/grpc/user/v2/passkey.go +++ b/internal/api/grpc/user/v2/passkey.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/structpb" "github.com/zitadel/zitadel/internal/api/grpc/object/v2" @@ -13,17 +14,17 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterPasskey(ctx context.Context, req *user.RegisterPasskeyRequest) (resp *user.RegisterPasskeyResponse, err error) { +func (s *Server) RegisterPasskey(ctx context.Context, req *connect.Request[user.RegisterPasskeyRequest]) (resp *connect.Response[user.RegisterPasskeyResponse], err error) { var ( - authenticator = passkeyAuthenticatorToDomain(req.GetAuthenticator()) + authenticator = passkeyAuthenticatorToDomain(req.Msg.GetAuthenticator()) ) - if code := req.GetCode(); code != nil { + if code := req.Msg.GetCode(); code != nil { return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskeyWithCode(ctx, req.GetUserId(), "", authenticator, code.Id, code.Code, req.GetDomain(), s.userCodeAlg), + s.command.RegisterUserPasskeyWithCode(ctx, req.Msg.GetUserId(), "", authenticator, code.Id, code.Code, req.Msg.GetDomain(), s.userCodeAlg), ) } return passkeyRegistrationDetailsToPb( - s.command.RegisterUserPasskey(ctx, req.GetUserId(), "", req.GetDomain(), authenticator), + s.command.RegisterUserPasskey(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain(), authenticator), ) } @@ -51,86 +52,86 @@ func webAuthNRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails return object.DomainToDetailsPb(details.ObjectDetails), options, nil } -func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterPasskeyResponse, error) { +func passkeyRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterPasskeyResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterPasskeyResponse{ + return connect.NewResponse(&user.RegisterPasskeyResponse{ Details: objectDetails, PasskeyId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *user.VerifyPasskeyRegistrationRequest) (*user.VerifyPasskeyRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyPasskeyRegistration(ctx context.Context, req *connect.Request[user.VerifyPasskeyRegistrationRequest]) (*connect.Response[user.VerifyPasskeyRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-Pha2o", "Errors.Internal") } - objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.GetUserId(), "", req.GetPasskeyName(), "", pkc) + objectDetails, err := s.command.HumanHumanPasswordlessSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetPasskeyName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyPasskeyRegistrationResponse{ + return connect.NewResponse(&user.VerifyPasskeyRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *user.CreatePasskeyRegistrationLinkRequest) (resp *user.CreatePasskeyRegistrationLinkResponse, err error) { - switch medium := req.Medium.(type) { +func (s *Server) CreatePasskeyRegistrationLink(ctx context.Context, req *connect.Request[user.CreatePasskeyRegistrationLinkRequest]) (resp *connect.Response[user.CreatePasskeyRegistrationLinkResponse], err error) { + switch medium := req.Msg.Medium.(type) { case nil: return passkeyDetailsToPb( - s.command.AddUserPasskeyCode(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCode(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) case *user.CreatePasskeyRegistrationLinkRequest_SendLink: return passkeyDetailsToPb( - s.command.AddUserPasskeyCodeURLTemplate(ctx, req.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), + s.command.AddUserPasskeyCodeURLTemplate(ctx, req.Msg.GetUserId(), "", s.userCodeAlg, medium.SendLink.GetUrlTemplate()), ) case *user.CreatePasskeyRegistrationLinkRequest_ReturnCode: return passkeyCodeDetailsToPb( - s.command.AddUserPasskeyCodeReturn(ctx, req.GetUserId(), "", s.userCodeAlg), + s.command.AddUserPasskeyCodeReturn(ctx, req.Msg.GetUserId(), "", s.userCodeAlg), ) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-gaD8y", "verification oneOf %T in method CreatePasskeyRegistrationLink not implemented", medium) } } -func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyDetailsToPb(details *domain.ObjectDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*user.CreatePasskeyRegistrationLinkResponse, error) { +func passkeyCodeDetailsToPb(details *domain.PasskeyCodeDetails, err error) (*connect.Response[user.CreatePasskeyRegistrationLinkResponse], error) { if err != nil { return nil, err } - return &user.CreatePasskeyRegistrationLinkResponse{ + return connect.NewResponse(&user.CreatePasskeyRegistrationLinkResponse{ Details: object.DomainToDetailsPb(details.ObjectDetails), Code: &user.PasskeyRegistrationCode{ Id: details.CodeID, Code: details.Code, }, - }, nil + }), nil } -func (s *Server) RemovePasskey(ctx context.Context, req *user.RemovePasskeyRequest) (*user.RemovePasskeyResponse, error) { - objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.GetUserId(), req.GetPasskeyId(), "") +func (s *Server) RemovePasskey(ctx context.Context, req *connect.Request[user.RemovePasskeyRequest]) (*connect.Response[user.RemovePasskeyResponse], error) { + objectDetails, err := s.command.HumanRemovePasswordless(ctx, req.Msg.GetUserId(), req.Msg.GetPasskeyId(), "") if err != nil { return nil, err } - return &user.RemovePasskeyResponse{ + return connect.NewResponse(&user.RemovePasskeyResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest) (*user.ListPasskeysResponse, error) { +func (s *Server) ListPasskeys(ctx context.Context, req *connect.Request[user.ListPasskeysRequest]) (*connect.Response[user.ListPasskeysResponse], error) { query := new(query.UserAuthMethodSearchQueries) - err := query.AppendUserIDQuery(req.UserId) + err := query.AppendUserIDQuery(req.Msg.UserId) if err != nil { return nil, err } @@ -146,10 +147,10 @@ func (s *Server) ListPasskeys(ctx context.Context, req *user.ListPasskeysRequest if err != nil { return nil, err } - return &user.ListPasskeysResponse{ + return connect.NewResponse(&user.ListPasskeysResponse{ Details: object.ToListDetails(authMethods.SearchResponse), Result: authMethodsToPasskeyPb(authMethods), - }, nil + }), nil } func authMethodsToPasskeyPb(methods *query.AuthMethods) []*user.Passkey { diff --git a/internal/api/grpc/user/v2/passkey_test.go b/internal/api/grpc/user/v2/passkey_test.go index 9263012b98..6429dd7ce6 100644 --- a/internal/api/grpc/user/v2/passkey_test.go +++ b/internal/api/grpc/user/v2/passkey_test.go @@ -123,11 +123,11 @@ func Test_passkeyRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } @@ -181,7 +181,9 @@ func Test_passkeyDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) + if tt.want != nil { + assert.Equal(t, tt.want, got.Msg) + } }) } } @@ -242,9 +244,9 @@ func Test_passkeyCodeDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := passkeyCodeDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.args.err) - assert.Equal(t, tt.want, got) if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + assert.Equal(t, tt.want, got.Msg) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/password.go b/internal/api/grpc/user/v2/password.go index 55cf225c4b..a256a00355 100644 --- a/internal/api/grpc/user/v2/password.go +++ b/internal/api/grpc/user/v2/password.go @@ -3,23 +3,25 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetRequest) (_ *user.PasswordResetResponse, err error) { +func (s *Server) PasswordReset(ctx context.Context, req *connect.Request[user.PasswordResetRequest]) (_ *connect.Response[user.PasswordResetResponse], err error) { var details *domain.ObjectDetails var code *string - switch m := req.GetMedium().(type) { + switch m := req.Msg.GetMedium().(type) { case *user.PasswordResetRequest_SendLink: - details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) + details, code, err = s.command.RequestPasswordResetURLTemplate(ctx, req.Msg.GetUserId(), m.SendLink.GetUrlTemplate(), notificationTypeToDomain(m.SendLink.GetNotificationType())) case *user.PasswordResetRequest_ReturnCode: - details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordResetReturnCode(ctx, req.Msg.GetUserId()) case nil: - details, code, err = s.command.RequestPasswordReset(ctx, req.GetUserId()) + details, code, err = s.command.RequestPasswordReset(ctx, req.Msg.GetUserId()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SDeeg", "verification oneOf %T in method RequestPasswordReset not implemented", m) } @@ -27,10 +29,10 @@ func (s *Server) PasswordReset(ctx context.Context, req *user.PasswordResetReque return nil, err } - return &user.PasswordResetResponse{ + return connect.NewResponse(&user.PasswordResetResponse{ Details: object.DomainToDetailsPb(details), VerificationCode: code, - }, nil + }), nil } func notificationTypeToDomain(notificationType user.NotificationType) domain.NotificationType { @@ -46,16 +48,16 @@ func notificationTypeToDomain(notificationType user.NotificationType) domain.Not } } -func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) (_ *user.SetPasswordResponse, err error) { +func (s *Server) SetPassword(ctx context.Context, req *connect.Request[user.SetPasswordRequest]) (_ *connect.Response[user.SetPasswordResponse], err error) { var details *domain.ObjectDetails - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPasswordRequest_CurrentPassword: - details, err = s.command.ChangePassword(ctx, "", req.GetUserId(), v.CurrentPassword, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.ChangePassword(ctx, "", req.Msg.GetUserId(), v.CurrentPassword, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case *user.SetPasswordRequest_VerificationCode: - details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.GetUserId(), v.VerificationCode, req.GetNewPassword().GetPassword(), "", req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPasswordWithVerifyCode(ctx, "", req.Msg.GetUserId(), v.VerificationCode, req.Msg.GetNewPassword().GetPassword(), "", req.Msg.GetNewPassword().GetChangeRequired()) case nil: - details, err = s.command.SetPassword(ctx, "", req.GetUserId(), req.GetNewPassword().GetPassword(), req.GetNewPassword().GetChangeRequired()) + details, err = s.command.SetPassword(ctx, "", req.Msg.GetUserId(), req.Msg.GetNewPassword().GetPassword(), req.Msg.GetNewPassword().GetChangeRequired()) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-SFdf2", "verification oneOf %T in method SetPasswordRequest not implemented", v) } @@ -63,7 +65,7 @@ func (s *Server) SetPassword(ctx context.Context, req *user.SetPasswordRequest) return nil, err } - return &user.SetPasswordResponse{ + return connect.NewResponse(&user.SetPasswordResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat.go b/internal/api/grpc/user/v2/pat.go index 54f6e99367..0c90eeaebd 100644 --- a/internal/api/grpc/user/v2/pat.go +++ b/internal/api/grpc/user/v2/pat.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/zitadel/oidc/v3/pkg/oidc" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,13 +14,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPersonalAccessTokenRequest) (*user.AddPersonalAccessTokenResponse, error) { +func (s *Server) AddPersonalAccessToken(ctx context.Context, req *connect.Request[user.AddPersonalAccessTokenRequest]) (*connect.Response[user.AddPersonalAccessTokenResponse], error) { newPat := &command.PersonalAccessToken{ ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), - ExpirationDate: req.ExpirationDate.AsTime(), + ExpirationDate: req.Msg.GetExpirationDate().AsTime(), Scopes: []string{ oidc.ScopeOpenID, oidc.ScopeProfile, @@ -32,25 +33,25 @@ func (s *Server) AddPersonalAccessToken(ctx context.Context, req *user.AddPerson if err != nil { return nil, err } - return &user.AddPersonalAccessTokenResponse{ + return connect.NewResponse(&user.AddPersonalAccessTokenResponse{ CreationDate: timestamppb.New(details.EventDate), TokenId: newPat.TokenID, Token: newPat.Token, - }, nil + }), nil } -func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *user.RemovePersonalAccessTokenRequest) (*user.RemovePersonalAccessTokenResponse, error) { +func (s *Server) RemovePersonalAccessToken(ctx context.Context, req *connect.Request[user.RemovePersonalAccessTokenRequest]) (*connect.Response[user.RemovePersonalAccessTokenResponse], error) { objectDetails, err := s.command.RemovePersonalAccessToken(ctx, &command.PersonalAccessToken{ - TokenID: req.TokenId, + TokenID: req.Msg.GetTokenId(), ObjectRoot: models.ObjectRoot{ - AggregateID: req.UserId, + AggregateID: req.Msg.GetUserId(), }, PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), }) if err != nil { return nil, err } - return &user.RemovePersonalAccessTokenResponse{ + return connect.NewResponse(&user.RemovePersonalAccessTokenResponse{ DeletionDate: timestamppb.New(objectDetails.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/pat_query.go b/internal/api/grpc/user/v2/pat_query.go index 6bbd44d511..64231c1d93 100644 --- a/internal/api/grpc/user/v2/pat_query.go +++ b/internal/api/grpc/user/v2/pat_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/api/grpc/filter/v2" @@ -12,12 +13,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPersonalAccessTokensRequest) (*user.ListPersonalAccessTokensResponse, error) { - offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Pagination) +func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *connect.Request[user.ListPersonalAccessTokensRequest]) (*connect.Response[user.ListPersonalAccessTokensResponse], error) { + offset, limit, asc, err := filter.PaginationPbToQuery(s.systemDefaults, req.Msg.GetPagination()) if err != nil { return nil, err } - filters, err := patFiltersToQueries(req.Filters) + filters, err := patFiltersToQueries(req.Msg.GetFilters()) if err != nil { return nil, err } @@ -26,7 +27,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer Offset: offset, Limit: limit, Asc: asc, - SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.SortingColumn), + SortingColumn: authnPersonalAccessTokenFieldNameToSortingColumn(req.Msg.SortingColumn), }, Queries: filters, } @@ -48,7 +49,7 @@ func (s *Server) ListPersonalAccessTokens(ctx context.Context, req *user.ListPer ExpirationDate: timestamppb.New(pat.Expiration), } } - return resp, nil + return connect.NewResponse(resp), nil } func patFiltersToQueries(filters []*user.PersonalAccessTokensSearchFilter) (_ []query.SearchQuery, err error) { diff --git a/internal/api/grpc/user/v2/phone.go b/internal/api/grpc/user/v2/phone.go index fdd5a140c1..4be616f7ea 100644 --- a/internal/api/grpc/user/v2/phone.go +++ b/internal/api/grpc/user/v2/phone.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp *user.SetPhoneResponse, err error) { +func (s *Server) SetPhone(ctx context.Context, req *connect.Request[user.SetPhoneRequest]) (resp *connect.Response[user.SetPhoneResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetPhoneRequest_SendCode: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_ReturnCode: - phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhoneReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) case *user.SetPhoneRequest_IsVerified: - phone, err = s.command.ChangeUserPhoneVerified(ctx, req.GetUserId(), req.GetPhone()) + phone, err = s.command.ChangeUserPhoneVerified(ctx, req.Msg.GetUserId(), req.Msg.GetPhone()) case nil: - phone, err = s.command.ChangeUserPhone(ctx, req.GetUserId(), req.GetPhone(), s.userCodeAlg) + phone, err = s.command.ChangeUserPhone(ctx, req.Msg.GetUserId(), req.Msg.GetPhone(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetPhone not implemented", v) } @@ -30,42 +31,42 @@ func (s *Server) SetPhone(ctx context.Context, req *user.SetPhoneRequest) (resp return nil, err } - return &user.SetPhoneResponse{ + return connect.NewResponse(&user.SetPhoneResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) RemovePhone(ctx context.Context, req *user.RemovePhoneRequest) (resp *user.RemovePhoneResponse, err error) { +func (s *Server) RemovePhone(ctx context.Context, req *connect.Request[user.RemovePhoneRequest]) (resp *connect.Response[user.RemovePhoneResponse], err error) { details, err := s.command.RemoveUserPhone(ctx, - req.GetUserId(), + req.Msg.GetUserId(), ) if err != nil { return nil, err } - return &user.RemovePhoneResponse{ + return connect.NewResponse(&user.RemovePhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } -func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeRequest) (resp *user.ResendPhoneCodeResponse, err error) { +func (s *Server) ResendPhoneCode(ctx context.Context, req *connect.Request[user.ResendPhoneCodeRequest]) (resp *connect.Response[user.ResendPhoneCodeResponse], err error) { var phone *domain.Phone - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendPhoneCodeRequest_SendCode: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case *user.ResendPhoneCodeRequest_ReturnCode: - phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCodeReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - phone, err = s.command.ResendUserPhoneCode(ctx, req.GetUserId(), s.userCodeAlg) + phone, err = s.command.ResendUserPhoneCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-ResendUserPhoneCode", "verification oneOf %T in method SetPhone not implemented", v) } @@ -73,30 +74,30 @@ func (s *Server) ResendPhoneCode(ctx context.Context, req *user.ResendPhoneCodeR return nil, err } - return &user.ResendPhoneCodeResponse{ + return connect.NewResponse(&user.ResendPhoneCodeResponse{ Details: &object.Details{ Sequence: phone.Sequence, ChangeDate: timestamppb.New(phone.ChangeDate), ResourceOwner: phone.ResourceOwner, }, VerificationCode: phone.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyPhone(ctx context.Context, req *user.VerifyPhoneRequest) (*user.VerifyPhoneResponse, error) { +func (s *Server) VerifyPhone(ctx context.Context, req *connect.Request[user.VerifyPhoneRequest]) (*connect.Response[user.VerifyPhoneResponse], error) { details, err := s.command.VerifyUserPhone(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyPhoneResponse{ + return connect.NewResponse(&user.VerifyPhoneResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/secret.go b/internal/api/grpc/user/v2/secret.go index 1d54e1dde8..acc7aef8cb 100644 --- a/internal/api/grpc/user/v2/secret.go +++ b/internal/api/grpc/user/v2/secret.go @@ -3,37 +3,38 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/command" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddSecret(ctx context.Context, req *user.AddSecretRequest) (*user.AddSecretResponse, error) { +func (s *Server) AddSecret(ctx context.Context, req *connect.Request[user.AddSecretRequest]) (*connect.Response[user.AddSecretResponse], error) { newSecret := &command.GenerateMachineSecret{ PermissionCheck: s.command.NewPermissionCheckUserWrite(ctx), } - details, err := s.command.GenerateMachineSecret(ctx, req.UserId, "", newSecret) + details, err := s.command.GenerateMachineSecret(ctx, req.Msg.GetUserId(), "", newSecret) if err != nil { return nil, err } - return &user.AddSecretResponse{ + return connect.NewResponse(&user.AddSecretResponse{ CreationDate: timestamppb.New(details.EventDate), ClientSecret: newSecret.ClientSecret, - }, nil + }), nil } -func (s *Server) RemoveSecret(ctx context.Context, req *user.RemoveSecretRequest) (*user.RemoveSecretResponse, error) { +func (s *Server) RemoveSecret(ctx context.Context, req *connect.Request[user.RemoveSecretRequest]) (*connect.Response[user.RemoveSecretResponse], error) { details, err := s.command.RemoveMachineSecret( ctx, - req.UserId, + req.Msg.GetUserId(), "", s.command.NewPermissionCheckUserWrite(ctx), ) if err != nil { return nil, err } - return &user.RemoveSecretResponse{ + return connect.NewResponse(&user.RemoveSecretResponse{ DeletionDate: timestamppb.New(details.EventDate), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/server.go b/internal/api/grpc/user/v2/server.go index e3c7e8011e..e89d0a7d60 100644 --- a/internal/api/grpc/user/v2/server.go +++ b/internal/api/grpc/user/v2/server.go @@ -2,8 +2,10 @@ package user import ( "context" + "net/http" - "google.golang.org/grpc" + "connectrpc.com/connect" + "google.golang.org/protobuf/reflect/protoreflect" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/api/grpc/server" @@ -13,12 +15,12 @@ import ( "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query" "github.com/zitadel/zitadel/pkg/grpc/user/v2" + "github.com/zitadel/zitadel/pkg/grpc/user/v2/userconnect" ) -var _ user.UserServiceServer = (*Server)(nil) +var _ userconnect.UserServiceHandler = (*Server)(nil) type Server struct { - user.UnimplementedUserServiceServer systemDefaults systemdefaults.SystemDefaults command *command.Commands query *query.Queries @@ -35,9 +37,9 @@ type Server struct { type Config struct{} func CreateServer( - systemDefaults systemdefaults.SystemDefaults, command *command.Commands, query *query.Queries, + systemDefaults systemdefaults.SystemDefaults, userCodeAlg crypto.EncryptionAlgorithm, idpAlg crypto.EncryptionAlgorithm, idpCallback func(ctx context.Context) string, @@ -46,7 +48,6 @@ func CreateServer( checkPermission domain.PermissionCheck, ) *Server { return &Server{ - systemDefaults: systemDefaults, command: command, query: query, userCodeAlg: userCodeAlg, @@ -55,11 +56,16 @@ func CreateServer( samlRootURL: samlRootURL, assetAPIPrefix: assetAPIPrefix, checkPermission: checkPermission, + systemDefaults: systemDefaults, } } -func (s *Server) RegisterServer(grpcServer *grpc.Server) { - user.RegisterUserServiceServer(grpcServer, s) +func (s *Server) RegisterConnectServer(interceptors ...connect.Interceptor) (string, http.Handler) { + return userconnect.NewUserServiceHandler(s, connect.WithInterceptors(interceptors...)) +} + +func (s *Server) FileDescriptor() protoreflect.FileDescriptor { + return user.File_zitadel_user_v2_user_service_proto } func (s *Server) AppName() string { diff --git a/internal/api/grpc/user/v2/totp.go b/internal/api/grpc/user/v2/totp.go index 9e2d028d72..51b615dac5 100644 --- a/internal/api/grpc/user/v2/totp.go +++ b/internal/api/grpc/user/v2/totp.go @@ -3,42 +3,44 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterTOTP(ctx context.Context, req *user.RegisterTOTPRequest) (*user.RegisterTOTPResponse, error) { +func (s *Server) RegisterTOTP(ctx context.Context, req *connect.Request[user.RegisterTOTPRequest]) (*connect.Response[user.RegisterTOTPResponse], error) { return totpDetailsToPb( - s.command.AddUserTOTP(ctx, req.GetUserId(), ""), + s.command.AddUserTOTP(ctx, req.Msg.GetUserId(), ""), ) } -func totpDetailsToPb(totp *domain.TOTP, err error) (*user.RegisterTOTPResponse, error) { +func totpDetailsToPb(totp *domain.TOTP, err error) (*connect.Response[user.RegisterTOTPResponse], error) { if err != nil { return nil, err } - return &user.RegisterTOTPResponse{ + return connect.NewResponse(&user.RegisterTOTPResponse{ Details: object.DomainToDetailsPb(totp.ObjectDetails), Uri: totp.URI, Secret: totp.Secret, - }, nil + }), nil } -func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *user.VerifyTOTPRegistrationRequest) (*user.VerifyTOTPRegistrationResponse, error) { - objectDetails, err := s.command.CheckUserTOTP(ctx, req.GetUserId(), req.GetCode(), "") +func (s *Server) VerifyTOTPRegistration(ctx context.Context, req *connect.Request[user.VerifyTOTPRegistrationRequest]) (*connect.Response[user.VerifyTOTPRegistrationResponse], error) { + objectDetails, err := s.command.CheckUserTOTP(ctx, req.Msg.GetUserId(), req.Msg.GetCode(), "") if err != nil { return nil, err } - return &user.VerifyTOTPRegistrationResponse{ + return connect.NewResponse(&user.VerifyTOTPRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveTOTP(ctx context.Context, req *user.RemoveTOTPRequest) (*user.RemoveTOTPResponse, error) { - objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.GetUserId(), "") +func (s *Server) RemoveTOTP(ctx context.Context, req *connect.Request[user.RemoveTOTPRequest]) (*connect.Response[user.RemoveTOTPResponse], error) { + objectDetails, err := s.command.HumanRemoveTOTP(ctx, req.Msg.GetUserId(), "") if err != nil { return nil, err } - return &user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}, nil + return connect.NewResponse(&user.RemoveTOTPResponse{Details: object.DomainToDetailsPb(objectDetails)}), nil } diff --git a/internal/api/grpc/user/v2/totp_test.go b/internal/api/grpc/user/v2/totp_test.go index 27ce6fb469..259f5ab5c6 100644 --- a/internal/api/grpc/user/v2/totp_test.go +++ b/internal/api/grpc/user/v2/totp_test.go @@ -63,7 +63,7 @@ func Test_totpDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := totpDetailsToPb(tt.args.otp, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("RegisterTOTPResponse =\n%v\nwant\n%v", got, tt.want) } }) diff --git a/internal/api/grpc/user/v2/u2f.go b/internal/api/grpc/user/v2/u2f.go index 60c0f5ab07..bd12ea0dac 100644 --- a/internal/api/grpc/user/v2/u2f.go +++ b/internal/api/grpc/user/v2/u2f.go @@ -3,50 +3,52 @@ package user import ( "context" + "connectrpc.com/connect" + "github.com/zitadel/zitadel/internal/api/grpc/object/v2" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/zerrors" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) RegisterU2F(ctx context.Context, req *user.RegisterU2FRequest) (*user.RegisterU2FResponse, error) { +func (s *Server) RegisterU2F(ctx context.Context, req *connect.Request[user.RegisterU2FRequest]) (*connect.Response[user.RegisterU2FResponse], error) { return u2fRegistrationDetailsToPb( - s.command.RegisterUserU2F(ctx, req.GetUserId(), "", req.GetDomain()), + s.command.RegisterUserU2F(ctx, req.Msg.GetUserId(), "", req.Msg.GetDomain()), ) } -func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*user.RegisterU2FResponse, error) { +func u2fRegistrationDetailsToPb(details *domain.WebAuthNRegistrationDetails, err error) (*connect.Response[user.RegisterU2FResponse], error) { objectDetails, options, err := webAuthNRegistrationDetailsToPb(details, err) if err != nil { return nil, err } - return &user.RegisterU2FResponse{ + return connect.NewResponse(&user.RegisterU2FResponse{ Details: objectDetails, U2FId: details.ID, PublicKeyCredentialCreationOptions: options, - }, nil + }), nil } -func (s *Server) VerifyU2FRegistration(ctx context.Context, req *user.VerifyU2FRegistrationRequest) (*user.VerifyU2FRegistrationResponse, error) { - pkc, err := req.GetPublicKeyCredential().MarshalJSON() +func (s *Server) VerifyU2FRegistration(ctx context.Context, req *connect.Request[user.VerifyU2FRegistrationRequest]) (*connect.Response[user.VerifyU2FRegistrationResponse], error) { + pkc, err := req.Msg.GetPublicKeyCredential().MarshalJSON() if err != nil { return nil, zerrors.ThrowInternal(err, "USERv2-IeTh4", "Errors.Internal") } - objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.GetUserId(), "", req.GetTokenName(), "", pkc) + objectDetails, err := s.command.HumanVerifyU2FSetup(ctx, req.Msg.GetUserId(), "", req.Msg.GetTokenName(), "", pkc) if err != nil { return nil, err } - return &user.VerifyU2FRegistrationResponse{ + return connect.NewResponse(&user.VerifyU2FRegistrationResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } -func (s *Server) RemoveU2F(ctx context.Context, req *user.RemoveU2FRequest) (*user.RemoveU2FResponse, error) { - objectDetails, err := s.command.HumanRemoveU2F(ctx, req.GetUserId(), req.GetU2FId(), "") +func (s *Server) RemoveU2F(ctx context.Context, req *connect.Request[user.RemoveU2FRequest]) (*connect.Response[user.RemoveU2FResponse], error) { + objectDetails, err := s.command.HumanRemoveU2F(ctx, req.Msg.GetUserId(), req.Msg.GetU2FId(), "") if err != nil { return nil, err } - return &user.RemoveU2FResponse{ + return connect.NewResponse(&user.RemoveU2FResponse{ Details: object.DomainToDetailsPb(objectDetails), - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2/u2f_test.go b/internal/api/grpc/user/v2/u2f_test.go index fae3ba1cdb..f6798a6f89 100644 --- a/internal/api/grpc/user/v2/u2f_test.go +++ b/internal/api/grpc/user/v2/u2f_test.go @@ -92,11 +92,11 @@ func Test_u2fRegistrationDetailsToPb(t *testing.T) { t.Run(tt.name, func(t *testing.T) { got, err := u2fRegistrationDetailsToPb(tt.args.details, tt.args.err) require.ErrorIs(t, err, tt.wantErr) - if !proto.Equal(tt.want, got) { + if tt.want != nil && !proto.Equal(tt.want, got.Msg) { t.Errorf("Not equal:\nExpected\n%s\nActual:%s", tt.want, got) } if tt.want != nil { - grpc.AllFieldsSet(t, got.ProtoReflect()) + grpc.AllFieldsSet(t, got.Msg.ProtoReflect()) } }) } diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index 6b4b2da75b..3eeda8da5f 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -4,6 +4,7 @@ import ( "context" "io" + "connectrpc.com/connect" "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" @@ -15,8 +16,8 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -24,12 +25,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -117,8 +118,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := updateHumanUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := updateHumanUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -126,51 +127,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -182,18 +183,18 @@ func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { return &pVal } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -203,7 +204,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } @@ -268,35 +269,35 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *user.ListAuthenticationMethodTypesRequest) (*user.ListAuthenticationMethodTypesResponse, error) { - authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.GetUserId(), true, req.GetDomainQuery().GetIncludeWithoutDomain(), req.GetDomainQuery().GetDomain()) +func (s *Server) ListAuthenticationMethodTypes(ctx context.Context, req *connect.Request[user.ListAuthenticationMethodTypesRequest]) (*connect.Response[user.ListAuthenticationMethodTypesResponse], error) { + authMethods, err := s.query.ListUserAuthMethodTypes(ctx, req.Msg.GetUserId(), true, req.Msg.GetDomainQuery().GetIncludeWithoutDomain(), req.Msg.GetDomainQuery().GetDomain()) if err != nil { return nil, err } - return &user.ListAuthenticationMethodTypesResponse{ + return connect.NewResponse(&user.ListAuthenticationMethodTypesResponse{ Details: object.ToListDetails(authMethods.SearchResponse), AuthMethodTypes: authMethodTypesToPb(authMethods.AuthMethodTypes), - }, nil + }), nil } -func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAuthenticationFactorsRequest) (*user.ListAuthenticationFactorsResponse, error) { +func (s *Server) ListAuthenticationFactors(ctx context.Context, req *connect.Request[user.ListAuthenticationFactorsRequest]) (*connect.Response[user.ListAuthenticationFactorsResponse], error) { query := new(query.UserAuthMethodSearchQueries) - if err := query.AppendUserIDQuery(req.UserId); err != nil { + if err := query.AppendUserIDQuery(req.Msg.GetUserId()); err != nil { return nil, err } authMethodsType := []domain.UserAuthMethodType{domain.UserAuthMethodTypeU2F, domain.UserAuthMethodTypeTOTP, domain.UserAuthMethodTypeOTPSMS, domain.UserAuthMethodTypeOTPEmail} - if len(req.GetAuthFactors()) > 0 { - authMethodsType = object.AuthFactorsToPb(req.GetAuthFactors()) + if len(req.Msg.GetAuthFactors()) > 0 { + authMethodsType = object.AuthFactorsToPb(req.Msg.GetAuthFactors()) } if err := query.AppendAuthMethodsQuery(authMethodsType...); err != nil { return nil, err } states := []domain.MFAState{domain.MFAStateReady} - if len(req.GetStates()) > 0 { - states = object.AuthFactorStatesToPb(req.GetStates()) + if len(req.Msg.GetStates()) > 0 { + states = object.AuthFactorStatesToPb(req.Msg.GetStates()) } if err := query.AppendStatesQuery(states...); err != nil { return nil, err @@ -307,9 +308,9 @@ func (s *Server) ListAuthenticationFactors(ctx context.Context, req *user.ListAu return nil, err } - return &user.ListAuthenticationFactorsResponse{ + return connect.NewResponse(&user.ListAuthenticationFactorsResponse{ Result: object.AuthMethodsToPb(authMethods), - }, nil + }), nil } func authMethodTypesToPb(methodTypes []domain.UserAuthMethodType) []user.AuthenticationMethodType { @@ -343,8 +344,8 @@ func authMethodTypeToPb(methodType domain.UserAuthMethodType) user.Authenticatio } } -func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCodeRequest) (*user.CreateInviteCodeResponse, error) { - invite, err := createInviteCodeRequestToCommand(req) +func (s *Server) CreateInviteCode(ctx context.Context, req *connect.Request[user.CreateInviteCodeRequest]) (*connect.Response[user.CreateInviteCodeResponse], error) { + invite, err := createInviteCodeRequestToCommand(req.Msg) if err != nil { return nil, err } @@ -352,30 +353,30 @@ func (s *Server) CreateInviteCode(ctx context.Context, req *user.CreateInviteCod if err != nil { return nil, err } - return &user.CreateInviteCodeResponse{ + return connect.NewResponse(&user.CreateInviteCodeResponse{ Details: object.DomainToDetailsPb(details), InviteCode: code, - }, nil + }), nil } -func (s *Server) ResendInviteCode(ctx context.Context, req *user.ResendInviteCodeRequest) (*user.ResendInviteCodeResponse, error) { - details, err := s.command.ResendInviteCode(ctx, req.GetUserId(), "", "") +func (s *Server) ResendInviteCode(ctx context.Context, req *connect.Request[user.ResendInviteCodeRequest]) (*connect.Response[user.ResendInviteCodeResponse], error) { + details, err := s.command.ResendInviteCode(ctx, req.Msg.GetUserId(), "", "") if err != nil { return nil, err } - return &user.ResendInviteCodeResponse{ + return connect.NewResponse(&user.ResendInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) VerifyInviteCode(ctx context.Context, req *user.VerifyInviteCodeRequest) (*user.VerifyInviteCodeResponse, error) { - details, err := s.command.VerifyInviteCode(ctx, req.GetUserId(), req.GetVerificationCode()) +func (s *Server) VerifyInviteCode(ctx context.Context, req *connect.Request[user.VerifyInviteCodeRequest]) (*connect.Response[user.VerifyInviteCodeResponse], error) { + details, err := s.command.VerifyInviteCode(ctx, req.Msg.GetUserId(), req.Msg.GetVerificationCode()) if err != nil { return nil, err } - return &user.VerifyInviteCodeResponse{ + return connect.NewResponse(&user.VerifyInviteCodeResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*command.CreateUserInvite, error) { @@ -394,33 +395,33 @@ func createInviteCodeRequestToCommand(req *user.CreateInviteCodeRequest) (*comma } } -func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *user.HumanMFAInitSkippedRequest) (_ *user.HumanMFAInitSkippedResponse, err error) { - details, err := s.command.HumanMFAInitSkippedV2(ctx, req.UserId) +func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *connect.Request[user.HumanMFAInitSkippedRequest]) (_ *connect.Response[user.HumanMFAInitSkippedResponse], err error) { + details, err := s.command.HumanMFAInitSkippedV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.HumanMFAInitSkippedResponse{ + return connect.NewResponse(&user.HumanMFAInitSkippedResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) CreateUser(ctx context.Context, req *user.CreateUserRequest) (*user.CreateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) CreateUser(ctx context.Context, req *connect.Request[user.CreateUserRequest]) (*connect.Response[user.CreateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.CreateUserRequest_Human_: - return s.createUserTypeHuman(ctx, userType.Human, req.OrganizationId, req.Username, req.UserId) + return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId) case *user.CreateUserRequest_Machine_: - return s.createUserTypeMachine(ctx, userType.Machine, req.OrganizationId, req.GetUsername(), req.GetUserId()) + return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId()) default: return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") } } -func (s *Server) UpdateUser(ctx context.Context, req *user.UpdateUserRequest) (*user.UpdateUserResponse, error) { - switch userType := req.GetUserType().(type) { +func (s *Server) UpdateUser(ctx context.Context, req *connect.Request[user.UpdateUserRequest]) (*connect.Response[user.UpdateUserResponse], error) { + switch userType := req.Msg.GetUserType().(type) { case *user.UpdateUserRequest_Human_: - return s.updateUserTypeHuman(ctx, userType.Human, req.UserId, req.Username) + return s.updateUserTypeHuman(ctx, userType.Human, req.Msg.GetUserId(), req.Msg.Username) case *user.UpdateUserRequest_Machine_: - return s.updateUserTypeMachine(ctx, userType.Machine, req.UserId, req.Username) + return s.updateUserTypeMachine(ctx, userType.Machine, req.Msg.GetUserId(), req.Msg.Username) default: return nil, zerrors.ThrowUnimplemented(nil, "", "user type is not implemented") } diff --git a/internal/api/grpc/user/v2/user_query.go b/internal/api/grpc/user/v2/user_query.go index dc886462be..5f5603af31 100644 --- a/internal/api/grpc/user/v2/user_query.go +++ b/internal/api/grpc/user/v2/user_query.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "github.com/muhlemmer/gu" "google.golang.org/protobuf/types/known/timestamppb" @@ -13,12 +14,12 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) (_ *user.GetUserByIDResponse, err error) { - resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.GetUserId(), s.checkPermission) +func (s *Server) GetUserByID(ctx context.Context, req *connect.Request[user.GetUserByIDRequest]) (_ *connect.Response[user.GetUserByIDResponse], err error) { + resp, err := s.query.GetUserByIDWithPermission(ctx, true, req.Msg.GetUserId(), s.checkPermission) if err != nil { return nil, err } - return &user.GetUserByIDResponse{ + return connect.NewResponse(&user.GetUserByIDResponse{ Details: object.DomainToDetailsPb(&domain.ObjectDetails{ Sequence: resp.Sequence, CreationDate: resp.CreationDate, @@ -26,11 +27,11 @@ func (s *Server) GetUserByID(ctx context.Context, req *user.GetUserByIDRequest) ResourceOwner: resp.ResourceOwner, }), User: userToPb(resp, s.assetAPIPrefix(ctx)), - }, nil + }), nil } -func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*user.ListUsersResponse, error) { - queries, err := listUsersRequestToModel(req) +func (s *Server) ListUsers(ctx context.Context, req *connect.Request[user.ListUsersRequest]) (*connect.Response[user.ListUsersResponse], error) { + queries, err := listUsersRequestToModel(req.Msg) if err != nil { return nil, err } @@ -38,10 +39,10 @@ func (s *Server) ListUsers(ctx context.Context, req *user.ListUsersRequest) (*us if err != nil { return nil, err } - return &user.ListUsersResponse{ + return connect.NewResponse(&user.ListUsersResponse{ Result: UsersToPb(res.Users, s.assetAPIPrefix(ctx)), Details: object.ToListDetails(res.SearchResponse), - }, nil + }), nil } func UsersToPb(users []*query.User, assetPrefix string) []*user.User { diff --git a/internal/api/grpc/user/v2beta/email.go b/internal/api/grpc/user/v2beta/email.go index 38cc73c75c..474111f767 100644 --- a/internal/api/grpc/user/v2beta/email.go +++ b/internal/api/grpc/user/v2beta/email.go @@ -3,6 +3,7 @@ package user import ( "context" + "connectrpc.com/connect" "google.golang.org/protobuf/types/known/timestamppb" "github.com/zitadel/zitadel/internal/domain" @@ -11,18 +12,18 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp *user.SetEmailResponse, err error) { +func (s *Server) SetEmail(ctx context.Context, req *connect.Request[user.SetEmailRequest]) (resp *connect.Response[user.SetEmailResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.SetEmailRequest_SendCode: - email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ChangeUserEmailURLTemplate(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.SetEmailRequest_ReturnCode: - email, err = s.command.ChangeUserEmailReturnCode(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmailReturnCode(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) case *user.SetEmailRequest_IsVerified: - email, err = s.command.ChangeUserEmailVerified(ctx, req.GetUserId(), req.GetEmail()) + email, err = s.command.ChangeUserEmailVerified(ctx, req.Msg.GetUserId(), req.Msg.GetEmail()) case nil: - email, err = s.command.ChangeUserEmail(ctx, req.GetUserId(), req.GetEmail(), s.userCodeAlg) + email, err = s.command.ChangeUserEmail(ctx, req.Msg.GetUserId(), req.Msg.GetEmail(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-Ahng0", "verification oneOf %T in method SetEmail not implemented", v) } @@ -30,26 +31,26 @@ func (s *Server) SetEmail(ctx context.Context, req *user.SetEmailRequest) (resp return nil, err } - return &user.SetEmailResponse{ + return connect.NewResponse(&user.SetEmailResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeRequest) (resp *user.ResendEmailCodeResponse, err error) { +func (s *Server) ResendEmailCode(ctx context.Context, req *connect.Request[user.ResendEmailCodeRequest]) (resp *connect.Response[user.ResendEmailCodeResponse], err error) { var email *domain.Email - switch v := req.GetVerification().(type) { + switch v := req.Msg.GetVerification().(type) { case *user.ResendEmailCodeRequest_SendCode: - email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) + email, err = s.command.ResendUserEmailCodeURLTemplate(ctx, req.Msg.GetUserId(), s.userCodeAlg, v.SendCode.GetUrlTemplate()) case *user.ResendEmailCodeRequest_ReturnCode: - email, err = s.command.ResendUserEmailReturnCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailReturnCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) case nil: - email, err = s.command.ResendUserEmailCode(ctx, req.GetUserId(), s.userCodeAlg) + email, err = s.command.ResendUserEmailCode(ctx, req.Msg.GetUserId(), s.userCodeAlg) default: err = zerrors.ThrowUnimplementedf(nil, "USERv2-faj0l0nj5x", "verification oneOf %T in method ResendEmailCode not implemented", v) } @@ -57,30 +58,30 @@ func (s *Server) ResendEmailCode(ctx context.Context, req *user.ResendEmailCodeR return nil, err } - return &user.ResendEmailCodeResponse{ + return connect.NewResponse(&user.ResendEmailCodeResponse{ Details: &object.Details{ Sequence: email.Sequence, ChangeDate: timestamppb.New(email.ChangeDate), ResourceOwner: email.ResourceOwner, }, VerificationCode: email.PlainCode, - }, nil + }), nil } -func (s *Server) VerifyEmail(ctx context.Context, req *user.VerifyEmailRequest) (*user.VerifyEmailResponse, error) { +func (s *Server) VerifyEmail(ctx context.Context, req *connect.Request[user.VerifyEmailRequest]) (*connect.Response[user.VerifyEmailResponse], error) { details, err := s.command.VerifyUserEmail(ctx, - req.GetUserId(), - req.GetVerificationCode(), + req.Msg.GetUserId(), + req.Msg.GetVerificationCode(), s.userCodeAlg, ) if err != nil { return nil, err } - return &user.VerifyEmailResponse{ + return connect.NewResponse(&user.VerifyEmailResponse{ Details: &object.Details{ Sequence: details.Sequence, ChangeDate: timestamppb.New(details.EventDate), ResourceOwner: details.ResourceOwner, }, - }, nil + }), nil } diff --git a/internal/api/grpc/user/v2beta/integration_test/user_test.go b/internal/api/grpc/user/v2beta/integration_test/user_test.go index 7b02f7da70..077ed02d0e 100644 --- a/internal/api/grpc/user/v2beta/integration_test/user_test.go +++ b/internal/api/grpc/user/v2beta/integration_test/user_test.go @@ -1773,7 +1773,7 @@ func TestServer_DeleteUser(t *testing.T) { request.UserId = resp.GetUserId() Instance.CreateProjectUserGrant(t, CTX, projectResp.GetId(), request.UserId) Instance.CreateProjectMembership(t, CTX, projectResp.GetId(), request.UserId) - Instance.CreateOrgMembership(t, CTX, request.UserId) + Instance.CreateOrgMembership(t, CTX, Instance.DefaultOrg.Id, request.UserId) }, }, want: &user.DeleteUserResponse{ @@ -2125,7 +2125,7 @@ func TestServer_StartIdentityProviderIntent(t *testing.T) { 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)) 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..3cde7b773e 100644 --- a/internal/api/grpc/user/v2beta/user.go +++ b/internal/api/grpc/user/v2beta/user.go @@ -6,6 +6,7 @@ import ( "io" "time" + "connectrpc.com/connect" "golang.org/x/text/language" "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" @@ -23,8 +24,8 @@ import ( user "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" ) -func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest) (_ *user.AddHumanUserResponse, err error) { - human, err := AddUserRequestToAddHuman(req) +func (s *Server) AddHumanUser(ctx context.Context, req *connect.Request[user.AddHumanUserRequest]) (_ *connect.Response[user.AddHumanUserResponse], err error) { + human, err := AddUserRequestToAddHuman(req.Msg) if err != nil { return nil, err } @@ -32,12 +33,12 @@ func (s *Server) AddHumanUser(ctx context.Context, req *user.AddHumanUserRequest if err = s.command.AddUserHuman(ctx, orgID, human, false, s.userCodeAlg); err != nil { return nil, err } - return &user.AddHumanUserResponse{ + return connect.NewResponse(&user.AddHumanUserResponse{ UserId: human.ID, Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } func AddUserRequestToAddHuman(req *user.AddHumanUserRequest) (*command.AddHuman, error) { @@ -115,8 +116,8 @@ func genderToDomain(gender user.Gender) domain.Gender { } } -func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserRequest) (_ *user.UpdateHumanUserResponse, err error) { - human, err := UpdateUserRequestToChangeHuman(req) +func (s *Server) UpdateHumanUser(ctx context.Context, req *connect.Request[user.UpdateHumanUserRequest]) (_ *connect.Response[user.UpdateHumanUserResponse], err error) { + human, err := UpdateUserRequestToChangeHuman(req.Msg) if err != nil { return nil, err } @@ -124,51 +125,51 @@ func (s *Server) UpdateHumanUser(ctx context.Context, req *user.UpdateHumanUserR if err != nil { return nil, err } - return &user.UpdateHumanUserResponse{ + return connect.NewResponse(&user.UpdateHumanUserResponse{ Details: object.DomainToDetailsPb(human.Details), EmailCode: human.EmailCode, PhoneCode: human.PhoneCode, - }, nil + }), nil } -func (s *Server) LockUser(ctx context.Context, req *user.LockUserRequest) (_ *user.LockUserResponse, err error) { - details, err := s.command.LockUserV2(ctx, req.UserId) +func (s *Server) LockUser(ctx context.Context, req *connect.Request[user.LockUserRequest]) (_ *connect.Response[user.LockUserResponse], err error) { + details, err := s.command.LockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.LockUserResponse{ + return connect.NewResponse(&user.LockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) UnlockUser(ctx context.Context, req *user.UnlockUserRequest) (_ *user.UnlockUserResponse, err error) { - details, err := s.command.UnlockUserV2(ctx, req.UserId) +func (s *Server) UnlockUser(ctx context.Context, req *connect.Request[user.UnlockUserRequest]) (_ *connect.Response[user.UnlockUserResponse], err error) { + details, err := s.command.UnlockUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.UnlockUserResponse{ + return connect.NewResponse(&user.UnlockUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeactivateUser(ctx context.Context, req *user.DeactivateUserRequest) (_ *user.DeactivateUserResponse, err error) { - details, err := s.command.DeactivateUserV2(ctx, req.UserId) +func (s *Server) DeactivateUser(ctx context.Context, req *connect.Request[user.DeactivateUserRequest]) (_ *connect.Response[user.DeactivateUserResponse], err error) { + details, err := s.command.DeactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.DeactivateUserResponse{ + return connect.NewResponse(&user.DeactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) ReactivateUser(ctx context.Context, req *user.ReactivateUserRequest) (_ *user.ReactivateUserResponse, err error) { - details, err := s.command.ReactivateUserV2(ctx, req.UserId) +func (s *Server) ReactivateUser(ctx context.Context, req *connect.Request[user.ReactivateUserRequest]) (_ *connect.Response[user.ReactivateUserResponse], err error) { + details, err := s.command.ReactivateUserV2(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - return &user.ReactivateUserResponse{ + return connect.NewResponse(&user.ReactivateUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func ifNotNilPtr[v, p any](value *v, conv func(v) p) *p { @@ -260,32 +261,32 @@ func SetHumanPasswordToPassword(password *user.SetPassword) *command.Password { } } -func (s *Server) AddIDPLink(ctx context.Context, req *user.AddIDPLinkRequest) (_ *user.AddIDPLinkResponse, err error) { - details, err := s.command.AddUserIDPLink(ctx, req.UserId, "", &command.AddLink{ - IDPID: req.GetIdpLink().GetIdpId(), - DisplayName: req.GetIdpLink().GetUserName(), - IDPExternalID: req.GetIdpLink().GetUserId(), +func (s *Server) AddIDPLink(ctx context.Context, req *connect.Request[user.AddIDPLinkRequest]) (_ *connect.Response[user.AddIDPLinkResponse], err error) { + details, err := s.command.AddUserIDPLink(ctx, req.Msg.GetUserId(), "", &command.AddLink{ + IDPID: req.Msg.GetIdpLink().GetIdpId(), + DisplayName: req.Msg.GetIdpLink().GetUserName(), + IDPExternalID: req.Msg.GetIdpLink().GetUserId(), }) if err != nil { return nil, err } - return &user.AddIDPLinkResponse{ + return connect.NewResponse(&user.AddIDPLinkResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } -func (s *Server) DeleteUser(ctx context.Context, req *user.DeleteUserRequest) (_ *user.DeleteUserResponse, err error) { - memberships, grants, err := s.removeUserDependencies(ctx, req.GetUserId()) +func (s *Server) DeleteUser(ctx context.Context, req *connect.Request[user.DeleteUserRequest]) (_ *connect.Response[user.DeleteUserResponse], err error) { + memberships, grants, err := s.removeUserDependencies(ctx, req.Msg.GetUserId()) if err != nil { return nil, err } - details, err := s.command.RemoveUserV2(ctx, req.UserId, "", memberships, grants...) + details, err := s.command.RemoveUserV2(ctx, req.Msg.GetUserId(), "", memberships, grants...) if err != nil { return nil, err } - return &user.DeleteUserResponse{ + return connect.NewResponse(&user.DeleteUserResponse{ Details: object.DomainToDetailsPb(details), - }, nil + }), nil } func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]*command.CascadingMembership, []string, error) { @@ -295,7 +296,7 @@ func (s *Server) removeUserDependencies(ctx context.Context, userID string) ([]* } grants, err := s.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } @@ -360,18 +361,18 @@ func userGrantsToIDs(userGrants []*query.UserGrant) []string { return converted } -func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *user.StartIdentityProviderIntentRequest) (_ *user.StartIdentityProviderIntentResponse, err error) { - switch t := req.GetContent().(type) { +func (s *Server) StartIdentityProviderIntent(ctx context.Context, req *connect.Request[user.StartIdentityProviderIntentRequest]) (_ *connect.Response[user.StartIdentityProviderIntentResponse], err error) { + switch t := req.Msg.GetContent().(type) { case *user.StartIdentityProviderIntentRequest_Urls: - return s.startIDPIntent(ctx, req.GetIdpId(), t.Urls) + return s.startIDPIntent(ctx, req.Msg.GetIdpId(), t.Urls) case *user.StartIdentityProviderIntentRequest_Ldap: - return s.startLDAPIntent(ctx, req.GetIdpId(), t.Ldap) + return s.startLDAPIntent(ctx, req.Msg.GetIdpId(), t.Ldap) default: return nil, zerrors.ThrowUnimplementedf(nil, "USERv2-S2g21", "type oneOf %T in method StartIdentityProviderIntent not implemented", t) } } -func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*user.StartIdentityProviderIntentResponse, error) { +func (s *Server) startIDPIntent(ctx context.Context, idpID string, urls *user.RedirectURLs) (*connect.Response[user.StartIdentityProviderIntentResponse], error) { state, session, err := s.command.AuthFromProvider(ctx, idpID, s.idpCallback(ctx), s.samlRootURL(ctx, idpID)) if err != nil { return nil, err @@ -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/api/saml/storage.go b/internal/api/saml/storage.go index 935e986c72..5a7f6cb576 100644 --- a/internal/api/saml/storage.go +++ b/internal/api/saml/storage.go @@ -309,7 +309,7 @@ func (p *Storage) getCustomAttributes(ctx context.Context, user *query.User, use true, user.ID, &query.UserMetadataSearchQueries{Queries: []query.SearchQuery{resourceOwnerQuery}}, - false, + nil, ) if err != nil { logging.WithError(err).Info("unable to get md in action") @@ -490,7 +490,7 @@ func (p *Storage) getGrants(ctx context.Context, userID, applicationID string) ( userIDQuery, activeQuery, }, - }, true) + }, true, nil) } type customAttribute struct { diff --git a/internal/api/scim/integration_test/bulk_test.go b/internal/api/scim/integration_test/bulk_test.go index 660b10f4fd..c2f430c1b7 100644 --- a/internal/api/scim/integration_test/bulk_test.go +++ b/internal/api/scim/integration_test/bulk_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" @@ -478,7 +480,7 @@ func TestBulk(t *testing.T) { }, { name: "fail on errors", - body: bulkFailOnErrorsJson, + body: withUsername(bulkFailOnErrorsJson, gofakeit.Username()), want: &scim.BulkResponse{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkResponse}, Operations: []*scim.BulkResponseOperation{ @@ -579,7 +581,6 @@ func TestBulk(t *testing.T) { response, err := Instance.Client.SCIM.Bulk(ctx, orgID, tt.body) createdUserIDs := buildCreatedIDs(response) - defer deleteUsers(t, createdUserIDs) if tt.wantErr != nil { statusCode := tt.wantErr.status @@ -656,17 +657,6 @@ func buildCreatedIDs(response *scim.BulkResponse) []string { return createdIds } -func deleteUsers(t require.TestingT, ids []string) { - for _, id := range ids { - err := Instance.Client.SCIM.Users.Delete(CTX, Instance.DefaultOrg.Id, id) - - // only not found errors are ok (if the user is deleted in a later on bulk request) - if err != nil { - scim.RequireScimError(t, http.StatusNotFound, err) - } - } -} - func buildMinimalUpdateRequest(userID string) *scim.BulkRequest { return &scim.BulkRequest{ Schemas: []schemas.ScimSchemaType{schemas.IdBulkRequest}, @@ -690,7 +680,7 @@ func buildTooManyOperationsRequest() *scim.BulkRequest { req.Operations[i] = &scim.BulkRequestOperation{ Method: http.MethodPost, Path: "/Users", - Data: minimalUserJson, + Data: withUsername(minimalUserJson, gofakeit.Username()), } } @@ -720,8 +710,11 @@ func ensureMetadataProjected(t require.TestingT, userID, key, value string) { Id: userID, Key: key, }) - require.NoError(tt, err) - require.Equal(tt, value, string(md.Metadata.Value)) + if !assert.NoError(tt, err) { + require.Equal(tt, status.Code(err), codes.NotFound) + return + } + assert.Equal(tt, value, string(md.Metadata.Value)) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json index bc9ed1346a..d1d0c88fe6 100644 --- a/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json +++ b/internal/api/scim/integration_test/testdata/bulk_test_fail_on_errors.json @@ -32,14 +32,14 @@ "urn:ietf:params:scim:schemas:core:2.0:User" ], "externalId": "scim-bulk-created-user-0", - "userName": "scim-bulk-created-user-0", + "userName": "{{ .Username }}", "name": { "familyName": "scim-bulk-created-user-0-family-name", "givenName": "scim-bulk-created-user-0-given-name" }, "emails": [ { - "value": "scim-bulk-created-user-0@example.com", + "value": "{{ .Username }}@example.com", "primary": true } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_full.json b/internal/api/scim/integration_test/testdata/users_create_test_full.json index 7879ecf160..cfc786a7e3 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_full.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_full.json @@ -1,7 +1,7 @@ { "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"], "externalId": "701984", - "userName": "bjensen@example.com", + "userName": "{{ .Username }}@example.com", "name": { "formatted": "Ms. Barbara J Jensen, III", "familyName": "Jensen", @@ -15,12 +15,12 @@ "profileUrl": "http://login.example.com/bjensen", "emails": [ { - "value": "bjensen@example.com", + "value": "{{ .Username }}@example.com", "type": "work", "primary": true }, { - "value": "babs@jensen.org", + "value": "{{ .Username }}+1@example.com", "type": "home" } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json index c51f416bc7..bdceb3e45f 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_minimal.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "{{ .Username }}", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com", + "value": "{{ .Username }}@example.com", "primary": true } ] diff --git a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json index 11650674a6..95ca1246d5 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_minimal_inactive.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "acmeUser1-inactive", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com", + "value": "user1-inactive@example.com", "primary": true } ], diff --git a/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json b/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json index 20c67c4715..f2b4bf2e4c 100644 --- a/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json +++ b/internal/api/scim/integration_test/testdata/users_create_test_no_primary_email_phone.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1", + "userName": "acmeUser1-no-primary-email-phone", "name": { "familyName": "Ross", "givenName": "Bethany" }, "emails": [ { - "value": "user1@example.com" + "value": "user1-no-primary-email-phone@example.com" } ], "phoneNumbers": [ diff --git a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json index b7e8d87590..33d78a2e3a 100644 --- a/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json +++ b/internal/api/scim/integration_test/testdata/users_replace_test_minimal_with_email_type.json @@ -2,14 +2,14 @@ "schemas": [ "urn:ietf:params:scim:schemas:core:2.0:User" ], - "userName": "acmeUser1-minimal-replaced", + "userName": "{{ .Username }}", "name": { "familyName": "Ross-replaced", "givenName": "Bethany-replaced" }, "emails": [ { - "value": "user1-minimal-replaced@example.com", + "value": "{{ .Username }}@example.com", "primary": true, "type": "work" } diff --git a/internal/api/scim/integration_test/testdata/users_update_test_full.json b/internal/api/scim/integration_test/testdata/users_update_test_full.json index 23403f3e5b..f79a0b5332 100644 --- a/internal/api/scim/integration_test/testdata/users_update_test_full.json +++ b/internal/api/scim/integration_test/testdata/users_update_test_full.json @@ -7,7 +7,7 @@ "value": { "emails":[ { - "value":"babs@example.com", + "value":"{{ .Username }}+2@example.com", "type":"home", "primary": true } diff --git a/internal/api/scim/integration_test/users_create_test.go b/internal/api/scim/integration_test/users_create_test.go index 35d5297878..4d1d9268ce 100644 --- a/internal/api/scim/integration_test/users_create_test.go +++ b/internal/api/scim/integration_test/users_create_test.go @@ -3,17 +3,22 @@ package integration_test import ( + "bytes" "context" _ "embed" + "fmt" "net/http" "path" "testing" + "text/template" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "github.com/zitadel/zitadel/internal/api/scim/resources" "github.com/zitadel/zitadel/internal/api/scim/schemas" @@ -157,7 +162,18 @@ var ( } ) +func withUsername(fixture []byte, username string) []byte { + buf := new(bytes.Buffer) + template.Must(template.New("").Parse(string(fixture))).Execute(buf, &struct { + Username string + }{ + Username: username, + }) + return buf.Bytes() +} + func TestCreateUser(t *testing.T) { + minimalUsername := gofakeit.Username() tests := []struct { name string body []byte @@ -171,16 +187,16 @@ func TestCreateUser(t *testing.T) { }{ { name: "minimal user", - body: minimalUserJson, + body: withUsername(minimalUserJson, minimalUsername), want: &resources.ScimUser{ - UserName: "acmeUser1", + UserName: minimalUsername, Name: &resources.ScimUserName{ FamilyName: "Ross", GivenName: "Bethany", }, Emails: []*resources.ScimEmail{ { - Value: "user1@example.com", + Value: minimalUsername + "@example.com", Primary: true, }, }, @@ -195,7 +211,7 @@ func TestCreateUser(t *testing.T) { }, { name: "full user", - body: fullUserJson, + body: withUsername(fullUserJson, "bjensen"), want: fullUser, }, { @@ -204,7 +220,7 @@ func TestCreateUser(t *testing.T) { want: &resources.ScimUser{ Emails: []*resources.ScimEmail{ { - Value: "user1@example.com", + Value: "user1-no-primary-email-phone@example.com", Primary: true, }, }, @@ -262,21 +278,21 @@ func TestCreateUser(t *testing.T) { }, { name: "not authenticated", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: context.Background(), wantErr: true, errorStatus: http.StatusUnauthorized, }, { name: "no permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), orgID: SecondaryOrganization.OrganizationId, wantErr: true, errorStatus: http.StatusNotFound, @@ -315,11 +331,6 @@ func TestCreateUser(t *testing.T) { } assert.NotEmpty(t, createdUser.ID) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - assert.NoError(t, err) - }() - assert.EqualValues(t, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, createdUser.Resource.Schemas) assert.Equal(t, schemas.ScimResourceTypeSingular("User"), createdUser.Resource.Meta.ResourceType) assert.Equal(t, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, orgID, "Users", createdUser.ID), createdUser.Resource.Meta.Location) @@ -345,10 +356,11 @@ func TestCreateUser(t *testing.T) { } func TestCreateUser_duplicate(t *testing.T) { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + parsedMinimalUserJson := withUsername(minimalUserJson, gofakeit.Username()) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, parsedMinimalUserJson) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, minimalUserJson) + _, err = Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, parsedMinimalUserJson) scimErr := scim.RequireScimError(t, http.StatusConflict, err) assert.Equal(t, "User already exists", scimErr.Error.Detail) assert.Equal(t, "uniqueness", scimErr.Error.ScimType) @@ -358,14 +370,10 @@ func TestCreateUser_duplicate(t *testing.T) { } func TestCreateUser_metadata(t *testing.T) { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - }() - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ @@ -391,38 +399,36 @@ func TestCreateUser_metadata(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:locale", "en-US") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:ims", `[{"value":"someaimhandle","type":"aim"},{"value":"twitterhandle","type":"X"}]`) test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:roles", `[{"value":"my-role-1","display":"Rolle 1","type":"main-role","primary":true},{"value":"my-role-2","display":"Rolle 2","type":"secondary-role"}]`) - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", `[{"value":"bjensen@example.com","primary":true,"type":"work"},{"value":"babs@jensen.org","primary":false,"type":"home"}]`) + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf(`[{"value":"%s@example.com","primary":true,"type":"work"},{"value":"%s+1@example.com","primary":false,"type":"home"}]`, username, username)) }, retryDuration, tick) } func TestCreateUser_scopedExternalID(t *testing.T) { - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBar") - - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") require.NoError(t, err) - - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) - }() - - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + ctx := integration.WithAuthorizationToken(CTX, callingUserPat) + setProvisioningDomain(t, callingUserId, "fooBar") + createdUser, err := Instance.Client.SCIM.Users.Create(ctx, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) + require.NoError(t, err) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { // unscoped externalID should not exist - _, err = Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + unscoped, err := Instance.Client.Mgmt.GetUserMetadata(ctx, &management.GetUserMetadataRequest{ Id: createdUser.ID, Key: "urn:zitadel:scim:externalId", }) integration.AssertGrpcStatus(tt, codes.NotFound, err) + unscoped = unscoped // scoped externalID should exist - md, err := Instance.Client.Mgmt.GetUserMetadata(CTX, &management.GetUserMetadataRequest{ + md, err := Instance.Client.Mgmt.GetUserMetadata(ctx, &management.GetUserMetadataRequest{ Id: createdUser.ID, Key: "urn:zitadel:scim:fooBar:externalId", }) - require.NoError(tt, err) + if !assert.NoError(tt, err) { + require.Equal(tt, status.Code(err), codes.NotFound) + return + } assert.Equal(tt, "701984", string(md.Metadata.Value)) }, retryDuration, tick) } diff --git a/internal/api/scim/integration_test/users_get_test.go b/internal/api/scim/integration_test/users_get_test.go index 8a1bab6c93..08e09946fb 100644 --- a/internal/api/scim/integration_test/users_get_test.go +++ b/internal/api/scim/integration_test/users_get_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -18,256 +19,260 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) func TestGetUser(t *testing.T) { - tests := []struct { - name string - orgID string - buildUserID func() string - cleanup func(userID string) + type testCase struct { ctx context.Context + orgID string + userID string want *resources.ScimUser wantErr bool errorStatus int + } + tests := []struct { + name string + setup func(t *testing.T) testCase }{ { - name: "not authenticated", - ctx: context.Background(), - errorStatus: http.StatusUnauthorized, - wantErr: true, + name: "not authenticated", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: context.Background(), + errorStatus: http.StatusUnauthorized, + wantErr: true, + } + }, }, { - name: "no permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - errorStatus: http.StatusNotFound, - wantErr: true, + name: "no permissions", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { - name: "another org", - orgID: SecondaryOrganization.OrganizationId, - errorStatus: http.StatusNotFound, - wantErr: true, + name: "another org", + setup: func(t *testing.T) testCase { + return testCase{ + orgID: SecondaryOrganization.OrganizationId, + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { - name: "another org with permissions", - orgID: SecondaryOrganization.OrganizationId, - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - errorStatus: http.StatusNotFound, - wantErr: true, + name: "another org with permissions", + setup: func(t *testing.T) testCase { + return testCase{ + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + orgID: SecondaryOrganization.OrganizationId, + errorStatus: http.StatusNotFound, + wantErr: true, + } + }, }, { name: "unknown user id", - buildUserID: func() string { - return "unknown" + setup: func(t *testing.T) testCase { + return testCase{ + userID: "unknown", + errorStatus: http.StatusNotFound, + wantErr: true, + } }, - errorStatus: http.StatusNotFound, - wantErr: true, }, { name: "created via grpc", - want: &resources.ScimUser{ - Name: &resources.ScimUserName{ - FamilyName: "Mouse", - GivenName: "Mickey", - }, - PreferredLanguage: language.MustParse("nl"), - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+41791234567", - Primary: true, + setup: func(t *testing.T) testCase { + return testCase{ + want: &resources.ScimUser{ + Name: &resources.ScimUserName{ + FamilyName: "Mouse", + GivenName: "Mickey", + }, + PreferredLanguage: language.MustParse("nl"), + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+41791234567", + Primary: true, + }, + }, }, - }, + } }, }, { name: "created via scim", - buildUserID: func() string { - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - return createdUser.ID - }, - cleanup: func(userID string) { - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) - require.NoError(t, err) - }, - want: &resources.ScimUser{ - ExternalID: "701984", - UserName: "bjensen@example.com", - Name: &resources.ScimUserName{ - Formatted: "Babs Jensen", // DisplayName takes precedence - FamilyName: "Jensen", - GivenName: "Barbara", - MiddleName: "Jane", - HonorificPrefix: "Ms.", - HonorificSuffix: "III", - }, - DisplayName: "Babs Jensen", - NickName: "Babs", - ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), - Title: "Tour Guide", - PreferredLanguage: language.Make("en-US"), - Locale: "en-US", - Timezone: "America/Los_Angeles", - Active: schemas.NewRelaxedBool(true), - Emails: []*resources.ScimEmail{ - { - Value: "bjensen@example.com", - Primary: true, - Type: "work", + return testCase{ + userID: createdUser.ID, + want: &resources.ScimUser{ + ExternalID: "701984", + UserName: username + "@example.com", + Name: &resources.ScimUserName{ + Formatted: "Babs Jensen", // DisplayName takes precedence + FamilyName: "Jensen", + GivenName: "Barbara", + MiddleName: "Jane", + HonorificPrefix: "Ms.", + HonorificSuffix: "III", + }, + DisplayName: "Babs Jensen", + NickName: "Babs", + ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Title: "Tour Guide", + PreferredLanguage: language.Make("en-US"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: schemas.NewRelaxedBool(true), + Emails: []*resources.ScimEmail{ + { + Value: username + "@example.com", + Primary: true, + Type: "work", + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+415555555555", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "X", + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "work", + StreetAddress: "100 Universal City Plaza", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + { + Type: "home", + StreetAddress: "456 Hollywood Blvd", + Locality: "Hollywood", + Region: "CA", + PostalCode: "91608", + Country: "USA", + Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), + Type: "thumbnail", + }, + }, + Roles: []*resources.ScimRole{ + { + Value: "my-role-1", + Display: "Rolle 1", + Type: "main-role", + Primary: true, + }, + { + Value: "my-role-2", + Display: "Rolle 2", + Type: "secondary-role", + Primary: false, + }, + }, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "Entitlement 1", + Type: "main-entitlement", + Primary: true, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + }, }, - }, - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+415555555555", - Primary: true, - }, - }, - Ims: []*resources.ScimIms{ - { - Value: "someaimhandle", - Type: "aim", - }, - { - Value: "twitterhandle", - Type: "X", - }, - }, - Addresses: []*resources.ScimAddress{ - { - Type: "work", - StreetAddress: "100 Universal City Plaza", - Locality: "Hollywood", - Region: "CA", - PostalCode: "91608", - Country: "USA", - Formatted: "100 Universal City Plaza\nHollywood, CA 91608 USA", - Primary: true, - }, - { - Type: "home", - StreetAddress: "456 Hollywood Blvd", - Locality: "Hollywood", - Region: "CA", - PostalCode: "91608", - Country: "USA", - Formatted: "456 Hollywood Blvd\nHollywood, CA 91608 USA", - }, - }, - Photos: []*resources.ScimPhoto{ - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), - Type: "photo", - }, - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/T")), - Type: "thumbnail", - }, - }, - Roles: []*resources.ScimRole{ - { - Value: "my-role-1", - Display: "Rolle 1", - Type: "main-role", - Primary: true, - }, - { - Value: "my-role-2", - Display: "Rolle 2", - Type: "secondary-role", - Primary: false, - }, - }, - Entitlements: []*resources.ScimEntitlement{ - { - Value: "my-entitlement-1", - Display: "Entitlement 1", - Type: "main-entitlement", - Primary: true, - }, - { - Value: "my-entitlement-2", - Display: "Entitlement 2", - Type: "secondary-entitlement", - Primary: false, - }, - }, + } }, }, { name: "scoped externalID", - buildUserID: func() string { - // create user without provisioning domain - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + setup: func(t *testing.T) testCase { + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - - // set provisioning domain of service user - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBar") - - // set externalID for provisioning domain + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") + require.NoError(t, err) + setProvisioningDomain(t, callingUserId, "fooBar") setAndEnsureMetadata(t, createdUser.ID, "urn:zitadel:scim:fooBar:externalId", "100-scopedExternalId") - return createdUser.ID - }, - cleanup: func(userID string) { - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) - }, - want: &resources.ScimUser{ - ExternalID: "100-scopedExternalId", + return testCase{ + ctx: integration.WithAuthorizationToken(CTX, callingUserPat), + userID: createdUser.ID, + want: &resources.ScimUser{ + ExternalID: "100-scopedExternalId", + }, + } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := tt.ctx - if ctx == nil { - ctx = CTX + ttt := tt.setup(t) + if ttt.userID == "" { + ttt.userID = Instance.CreateHumanUser(CTX).UserId } - - var userID string - if tt.buildUserID != nil { - userID = tt.buildUserID() - } else { - createUserResp := Instance.CreateHumanUser(CTX) - userID = createUserResp.UserId + if ttt.ctx == nil { + ttt.ctx = CTX } - - orgID := tt.orgID - if orgID == "" { - orgID = Instance.DefaultOrg.Id + if ttt.orgID == "" { + ttt.orgID = Instance.DefaultOrg.Id } - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) - var fetchedUser *resources.ScimUser - var err error - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - fetchedUser, err = Instance.Client.SCIM.Users.Get(ctx, orgID, userID) - if tt.wantErr { - statusCode := tt.errorStatus + require.EventuallyWithT(t, func(collect *assert.CollectT) { + fetchedUser, err := Instance.Client.SCIM.Users.Get(ttt.ctx, ttt.orgID, ttt.userID) + if ttt.wantErr { + statusCode := ttt.errorStatus if statusCode == 0 { statusCode = http.StatusBadRequest } - - scim.RequireScimError(ttt, statusCode, err) + scim.RequireScimError(collect, statusCode, err) return } - - assert.Equal(ttt, userID, fetchedUser.ID) - assert.EqualValues(ttt, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas) - assert.Equal(ttt, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType) - assert.Equal(ttt, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, orgID, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location) - assert.Nil(ttt, fetchedUser.Password) - if !test.PartiallyDeepEqual(tt.want, fetchedUser) { - ttt.Errorf("GetUser() got = %#v, want %#v", fetchedUser, tt.want) + if !assert.NoError(collect, err) { + scim.RequireScimError(collect, http.StatusNotFound, err) + return + } + assert.Equal(collect, ttt.userID, fetchedUser.ID) + assert.EqualValues(collect, []schemas.ScimSchemaType{"urn:ietf:params:scim:schemas:core:2.0:User"}, fetchedUser.Schemas) + assert.Equal(collect, schemas.ScimResourceTypeSingular("User"), fetchedUser.Resource.Meta.ResourceType) + assert.Equal(collect, "http://"+Instance.Host()+path.Join(schemas.HandlerPrefix, ttt.orgID, "Users", fetchedUser.ID), fetchedUser.Resource.Meta.Location) + assert.Nil(collect, fetchedUser.Password) + if !test.PartiallyDeepEqual(ttt.want, fetchedUser) { + collect.Errorf("GetUser() got = %#v, want %#v", fetchedUser, ttt.want) } }, retryDuration, tick) - - if tt.cleanup != nil { - tt.cleanup(fetchedUser.ID) - } }) } } diff --git a/internal/api/scim/integration_test/users_list_test.go b/internal/api/scim/integration_test/users_list_test.go index 8c6ccb80ef..81fbf1a5bc 100644 --- a/internal/api/scim/integration_test/users_list_test.go +++ b/internal/api/scim/integration_test/users_list_test.go @@ -23,15 +23,6 @@ var totalCountOfHumanUsers = 13 /* func TestListUser(t *testing.T) { createdUserIDs := createUsers(t, CTX, Instance.DefaultOrg.Id) - defer func() { - // only the full user needs to be deleted, all others have random identification data - // fullUser is always the first one. - _, err := Instance.Client.UserV2.DeleteUser(CTX, &user_v2.DeleteUserRequest{ - UserId: createdUserIDs[0], - }) - require.NoError(t, err) - }() - // secondary organization with same set of users, // these should never be modified. // This allows testing list requests without filters. @@ -451,7 +442,7 @@ func createUsers(t *testing.T, ctx context.Context, orgID string) []string { // create the full scim user if on primary org if orgID == Instance.DefaultOrg.Id { - fullUserCreatedResp, err := Instance.Client.SCIM.Users.Create(ctx, orgID, fullUserJson) + fullUserCreatedResp, err := Instance.Client.SCIM.Users.Create(ctx, orgID, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) createdUserIDs = append(createdUserIDs, fullUserCreatedResp.ID) count-- diff --git a/internal/api/scim/integration_test/users_replace_test.go b/internal/api/scim/integration_test/users_replace_test.go index 1c99592b01..7896ed4e00 100644 --- a/internal/api/scim/integration_test/users_replace_test.go +++ b/internal/api/scim/integration_test/users_replace_test.go @@ -5,11 +5,13 @@ package integration_test import ( "context" _ "embed" + "fmt" "net/http" "path" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -20,7 +22,6 @@ import ( "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" "github.com/zitadel/zitadel/pkg/grpc/management" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -199,28 +200,28 @@ func TestReplaceUser(t *testing.T) { }, { name: "not authenticated", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: context.Background(), wantErr: true, errorStatus: http.StatusUnauthorized, }, { name: "no permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), replaceUserOrgID: SecondaryOrganization.OrganizationId, wantErr: true, errorStatus: http.StatusNotFound, }, { name: "another org with permissions", - body: minimalUserJson, + body: withUsername(minimalUserJson, gofakeit.Username()), replaceUserOrgID: SecondaryOrganization.OrganizationId, ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), wantErr: true, @@ -230,14 +231,9 @@ func TestReplaceUser(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // use iam owner => we don't want to test permissions of the create endpoint. - createdUser, err := Instance.Client.SCIM.Users.Create(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - assert.NoError(t, err) - }() - ctx := tt.ctx if ctx == nil { ctx = CTX @@ -294,10 +290,11 @@ func TestReplaceUser(t *testing.T) { func TestReplaceUser_removeOldMetadata(t *testing.T) { // ensure old metadata is removed correctly - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + username := gofakeit.Username() + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserJson) + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, withUsername(minimalUserJson, username)) require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -312,20 +309,17 @@ func TestReplaceUser_removeOldMetadata(t *testing.T) { for i := range md.Result { mdMap[md.Result[i].Key] = string(md.Result[i].Value) } - - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1@example.com\",\"primary\":true}]") + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf("[{\"value\":\"%s@example.com\",\"primary\":true}]", username)) }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) } func TestReplaceUser_emailType(t *testing.T) { // ensure old metadata is removed correctly - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithEmailTypeReplaceJson) + replacedUsername := gofakeit.Username() + _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, withUsername(minimalUserWithEmailTypeReplaceJson, replacedUsername)) require.NoError(t, err) retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) @@ -341,28 +335,26 @@ func TestReplaceUser_emailType(t *testing.T) { mdMap[md.Result[i].Key] = string(md.Result[i].Value) } - test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", "[{\"value\":\"user1-minimal-replaced@example.com\",\"primary\":true,\"type\":\"work\"}]") + test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:emails", fmt.Sprintf("[{\"value\":\"%s@example.com\",\"primary\":true,\"type\":\"work\"}]", replacedUsername)) }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) } func TestReplaceUser_scopedExternalID(t *testing.T) { - // create user without provisioning domain set - createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) + createdUser, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, gofakeit.Username())) require.NoError(t, err) - + callingUserId, callingUserPat, err := Instance.CreateMachineUserPATWithMembership(CTX, "ORG_OWNER") + require.NoError(t, err) + ctx := integration.WithAuthorizationToken(CTX, callingUserPat) // set provisioning domain of service user - setProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID, "fooBazz") + setProvisioningDomain(t, callingUserId, "fooBazz") // replace the user with provisioning domain set - _, err = Instance.Client.SCIM.Users.Replace(CTX, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson) + _, err = Instance.Client.SCIM.Users.Replace(ctx, Instance.DefaultOrg.Id, createdUser.ID, minimalUserWithExternalIDJson) require.NoError(t, err) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) + retryDuration, tick := integration.WaitForAndTickWithMaxDuration(ctx, time.Minute) require.EventuallyWithT(t, func(tt *assert.CollectT) { - md, err := Instance.Client.Mgmt.ListUserMetadata(CTX, &management.ListUserMetadataRequest{ + md, err := Instance.Client.Mgmt.ListUserMetadata(ctx, &management.ListUserMetadataRequest{ Id: createdUser.ID, }) require.NoError(tt, err) @@ -376,9 +368,4 @@ func TestReplaceUser_scopedExternalID(t *testing.T) { test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:externalId", "701984") test.AssertMapContains(tt, mdMap, "urn:zitadel:scim:fooBazz:externalId", "replaced-external-id") }, retryDuration, tick) - - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: createdUser.ID}) - require.NoError(t, err) - - removeProvisioningDomain(t, Instance.Users.Get(integration.UserTypeOrgOwner).ID) } diff --git a/internal/api/scim/integration_test/users_update_test.go b/internal/api/scim/integration_test/users_update_test.go index 77e55bac60..f8a65a8a69 100644 --- a/internal/api/scim/integration_test/users_update_test.go +++ b/internal/api/scim/integration_test/users_update_test.go @@ -5,11 +5,13 @@ package integration_test import ( "context" _ "embed" + "encoding/json" "fmt" "net/http" "testing" "time" + "github.com/brianvoe/gofakeit/v6" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/text/language" @@ -19,7 +21,6 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/internal/integration/scim" "github.com/zitadel/zitadel/internal/test" - "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) var ( @@ -34,15 +35,7 @@ func init() { } func TestUpdateUser(t *testing.T) { - fullUserCreated, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, fullUserJson) - require.NoError(t, err) - - defer func() { - _, err = Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: fullUserCreated.ID}) - require.NoError(t, err) - }() - - tests := []struct { + type testCase struct { name string body []byte ctx context.Context @@ -52,215 +45,297 @@ func TestUpdateUser(t *testing.T) { wantErr bool scimErrorType string errorStatus int + } + tests := []struct { + name string + setup func(t *testing.T) testCase }{ { - name: "not authenticated", - ctx: context.Background(), - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusUnauthorized, + name: "not authenticated", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: context.Background(), + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusUnauthorized, + } + }, }, { - name: "no permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "no permissions", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeNoPermission), + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "other org", - orgID: SecondaryOrganization.OrganizationId, - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "other org", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + orgID: SecondaryOrganization.OrganizationId, + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "other org with permissions", - ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), - orgID: SecondaryOrganization.OrganizationId, - body: minimalUserUpdateJson, - wantErr: true, - errorStatus: http.StatusNotFound, + name: "other org with permissions", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + ctx: Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner), + orgID: SecondaryOrganization.OrganizationId, + body: minimalUserUpdateJson, + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { - name: "invalid patch json", - body: simpleReplacePatchBody("nickname", "10"), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid patch json", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("nickname", "10"), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "password complexity violation", - body: simpleReplacePatchBody("password", `"fooBar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "password complexity violation", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("password", `"fooBar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid profile url", - body: simpleReplacePatchBody("profileUrl", `"ftp://example.com/profiles"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid profile url", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("profileUrl", `"ftp://example.com/profiles"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid time zone", - body: simpleReplacePatchBody("timezone", `"foobar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid time zone", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("timezone", `"foobar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "invalid locale", - body: simpleReplacePatchBody("locale", `"foobar"`), - wantErr: true, - scimErrorType: "invalidValue", + name: "invalid locale", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: simpleReplacePatchBody("locale", `"foobar"`), + wantErr: true, + scimErrorType: "invalidValue", + } + }, }, { - name: "unknown user id", - body: simpleReplacePatchBody("nickname", `"foo"`), - userID: "fooBar", - wantErr: true, - errorStatus: http.StatusNotFound, + name: "unknown user id", + setup: func(t *testing.T) testCase { + return testCase{ + body: simpleReplacePatchBody("nickname", `"foo"`), + userID: "fooBar", + wantErr: true, + errorStatus: http.StatusNotFound, + } + }, }, { name: "full", - body: fullUserUpdateJson, - want: &resources.ScimUser{ - ExternalID: "fooBAR", - UserName: "bjensen@example.com", - Name: &resources.ScimUserName{ - Formatted: "replaced-display-name", - FamilyName: "added-family-name", - GivenName: "added-given-name", - MiddleName: "added-middle-name-2", - HonorificPrefix: "added-honorific-prefix", - HonorificSuffix: "replaced-honorific-suffix", - }, - DisplayName: "replaced-display-name", - NickName: "", - ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), - Emails: []*resources.ScimEmail{ - { - Value: "bjensen@example.com", - Type: "work", + setup: func(t *testing.T) testCase { + username := gofakeit.Username() + created, err := Instance.Client.SCIM.Users.Create(CTX, Instance.DefaultOrg.Id, withUsername(fullUserJson, username)) + require.NoError(t, err) + return testCase{ + userID: created.ID, + body: withUsername(fullUserUpdateJson, username), + want: &resources.ScimUser{ + ExternalID: "fooBAR", + UserName: username + "@example.com", + Name: &resources.ScimUserName{ + Formatted: "replaced-display-name", + FamilyName: "added-family-name", + GivenName: "added-given-name", + MiddleName: "added-middle-name-2", + HonorificPrefix: "added-honorific-prefix", + HonorificSuffix: "replaced-honorific-suffix", + }, + DisplayName: "replaced-display-name", + NickName: "", + ProfileUrl: test.Must(schemas.ParseHTTPURL("http://login.example.com/bjensen")), + Emails: []*resources.ScimEmail{ + { + Value: username + "@example.com", + Type: "work", + }, + { + Value: username + "+1@example.com", + Type: "home", + }, + { + Value: username + "+2@example.com", + Primary: true, + Type: "home", + }, + }, + Addresses: []*resources.ScimAddress{ + { + Type: "replaced-work", + StreetAddress: "replaced-100 Universal City Plaza", + Locality: "replaced-Hollywood", + Region: "replaced-CA", + PostalCode: "replaced-91608", + Country: "replaced-USA", + Formatted: "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA", + Primary: true, + }, + }, + PhoneNumbers: []*resources.ScimPhoneNumber{ + { + Value: "+41711234567", + Primary: true, + }, + }, + Ims: []*resources.ScimIms{ + { + Value: "someaimhandle", + Type: "aim", + }, + { + Value: "twitterhandle", + Type: "", + }, + }, + Photos: []*resources.ScimPhoto{ + { + Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), + Type: "photo", + }, + }, + Roles: nil, + Entitlements: []*resources.ScimEntitlement{ + { + Value: "my-entitlement-1", + Display: "added-entitlement-1", + Type: "added-entitlement-1", + Primary: false, + }, + { + Value: "my-entitlement-2", + Display: "Entitlement 2", + Type: "secondary-entitlement", + Primary: false, + }, + { + Value: "added-entitlement-1", + Primary: false, + }, + { + Value: "added-entitlement-2", + Primary: false, + }, + { + Value: "added-entitlement-3", + Primary: true, + }, + }, + Title: "Tour Guide", + PreferredLanguage: language.MustParse("en"), + Locale: "en-US", + Timezone: "America/Los_Angeles", + Active: schemas.NewRelaxedBool(true), }, - { - Value: "babs@jensen.org", - Type: "home", - }, - { - Value: "babs@example.com", - Primary: true, - Type: "home", - }, - }, - Addresses: []*resources.ScimAddress{ - { - Type: "replaced-work", - StreetAddress: "replaced-100 Universal City Plaza", - Locality: "replaced-Hollywood", - Region: "replaced-CA", - PostalCode: "replaced-91608", - Country: "replaced-USA", - Formatted: "replaced-100 Universal City Plaza\nHollywood, CA 91608 USA", - Primary: true, - }, - }, - PhoneNumbers: []*resources.ScimPhoneNumber{ - { - Value: "+41711234567", - Primary: true, - }, - }, - Ims: []*resources.ScimIms{ - { - Value: "someaimhandle", - Type: "aim", - }, - { - Value: "twitterhandle", - Type: "", - }, - }, - Photos: []*resources.ScimPhoto{ - { - Value: *test.Must(schemas.ParseHTTPURL("https://photos.example.com/profilephoto/72930000000Ccne/F")), - Type: "photo", - }, - }, - Roles: nil, - Entitlements: []*resources.ScimEntitlement{ - { - Value: "my-entitlement-1", - Display: "added-entitlement-1", - Type: "added-entitlement-1", - Primary: false, - }, - { - Value: "my-entitlement-2", - Display: "Entitlement 2", - Type: "secondary-entitlement", - Primary: false, - }, - { - Value: "added-entitlement-1", - Primary: false, - }, - { - Value: "added-entitlement-2", - Primary: false, - }, - { - Value: "added-entitlement-3", - Primary: true, - }, - }, - Title: "Tour Guide", - PreferredLanguage: language.MustParse("en-US"), - Locale: "en-US", - Timezone: "America/Los_Angeles", - Active: schemas.NewRelaxedBool(true), + } }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if tt.ctx == nil { - tt.ctx = CTX + ttt := tt.setup(t) + if ttt.orgID == "" { + ttt.orgID = Instance.DefaultOrg.Id } - - if tt.orgID == "" { - tt.orgID = Instance.DefaultOrg.Id + if ttt.ctx == nil { + ttt.ctx = CTX } - - if tt.userID == "" { - tt.userID = fullUserCreated.ID - } - - err := Instance.Client.SCIM.Users.Update(tt.ctx, tt.orgID, tt.userID, tt.body) - - if tt.wantErr { + err := Instance.Client.SCIM.Users.Update(ttt.ctx, ttt.orgID, ttt.userID, ttt.body) + if ttt.wantErr { require.Error(t, err) - - statusCode := tt.errorStatus + statusCode := ttt.errorStatus if statusCode == 0 { statusCode = http.StatusBadRequest } - scimErr := scim.RequireScimError(t, statusCode, err) - assert.Equal(t, tt.scimErrorType, scimErr.Error.ScimType) + assert.Equal(t, ttt.scimErrorType, scimErr.Error.ScimType) return } require.NoError(t, err) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, time.Minute) - require.EventuallyWithT(t, func(ttt *assert.CollectT) { - fetchedUser, err := Instance.Client.SCIM.Users.Get(tt.ctx, tt.orgID, fullUserCreated.ID) - require.NoError(ttt, err) - + require.EventuallyWithT(t, func(collect *assert.CollectT) { + fetchedUser, err := Instance.Client.SCIM.Users.Get(ttt.ctx, ttt.orgID, ttt.userID) + if !assert.NoError(collect, err) { + return + } fetchedUser.Resource = nil fetchedUser.ID = "" - if tt.want != nil && !test.PartiallyDeepEqual(tt.want, fetchedUser) { - ttt.Errorf("got = %#v, want = %#v", fetchedUser, tt.want) - } + fetched, err := json.Marshal(fetchedUser) + require.NoError(collect, err) + want, err := json.Marshal(ttt.want) + require.NoError(collect, err) + assert.JSONEq(collect, string(want), string(fetched)) }, retryDuration, tick) }) } diff --git a/internal/api/scim/resources/user.go b/internal/api/scim/resources/user.go index 6506ae35c7..dbf97e0eae 100644 --- a/internal/api/scim/resources/user.go +++ b/internal/api/scim/resources/user.go @@ -262,7 +262,7 @@ func (h *UsersHandler) queryUserDependencies(ctx context.Context, userID string) grants, err := h.query.UserGrants(ctx, &query.UserGrantsQueries{ Queries: []query.SearchQuery{userGrantUserQuery}, - }, true) + }, true, nil) if err != nil { return nil, nil, err } diff --git a/internal/api/scim/resources/user_metadata.go b/internal/api/scim/resources/user_metadata.go index 3e018507fe..b758117ce8 100644 --- a/internal/api/scim/resources/user_metadata.go +++ b/internal/api/scim/resources/user_metadata.go @@ -45,7 +45,7 @@ func (h *UsersHandler) queryMetadataForUsers(ctx context.Context, userIds []stri func (h *UsersHandler) queryMetadataForUser(ctx context.Context, id string) (map[metadata.ScopedKey][]byte, error) { queries := h.buildMetadataQueries(ctx) - md, err := h.query.SearchUserMetadata(ctx, false, id, queries, false) + md, err := h.query.SearchUserMetadata(ctx, false, id, queries, nil) if err != nil { return nil, err } diff --git a/internal/api/ui/login/external_provider_handler.go b/internal/api/ui/login/external_provider_handler.go index abd20088ba..967d79c1a9 100644 --- a/internal/api/ui/login/external_provider_handler.go +++ b/internal/api/ui/login/external_provider_handler.go @@ -1260,7 +1260,8 @@ func (l *Login) appendUserGrants(ctx context.Context, userGrants []*domain.UserG return nil } for _, grant := range userGrants { - _, err := l.command.AddUserGrant(setContext(ctx, resourceOwner), grant, resourceOwner) + grant.ResourceOwner = resourceOwner + _, err := l.command.AddUserGrant(setContext(ctx, resourceOwner), grant, nil) if err != nil { return err } diff --git a/internal/auth/repository/eventsourcing/repository.go b/internal/auth/repository/eventsourcing/repository.go index 16bee6e7a4..81dc8abd5c 100644 --- a/internal/auth/repository/eventsourcing/repository.go +++ b/internal/auth/repository/eventsourcing/repository.go @@ -125,7 +125,7 @@ func (q queryViewWrapper) UserGrantsByProjectAndUserID(ctx context.Context, proj return nil, err } queries := &query.UserGrantsQueries{Queries: []query.SearchQuery{userGrantUserID, userGrantProjectID, activeQuery}} - grants, err := q.Queries.UserGrants(ctx, queries, true) + grants, err := q.Queries.UserGrants(ctx, queries, true, nil) if err != nil { return nil, err } diff --git a/internal/command/instance.go b/internal/command/instance.go index 9e8f3d47c7..8a686262d7 100644 --- a/internal/command/instance.go +++ b/internal/command/instance.go @@ -690,7 +690,7 @@ func setupLoginClient(commands *Commands, validations *[]preparation.Validation, func setupAdminMembers(commands *Commands, validations *[]preparation.Validation, instanceAgg *instance.Aggregate, orgAgg *org.Aggregate, userID string) { *validations = append(*validations, - commands.AddOrgMemberCommand(orgAgg, userID, domain.RoleOrgOwner), + commands.AddOrgMemberCommand(&AddOrgMember{orgAgg.ID, userID, []string{domain.RoleOrgOwner}}), commands.AddInstanceMemberCommand(instanceAgg, userID, domain.RoleIAMOwner), ) } diff --git a/internal/command/instance_member.go b/internal/command/instance_member.go index a33635e8f5..0657170f75 100644 --- a/internal/command/instance_member.go +++ b/internal/command/instance_member.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" @@ -69,9 +69,19 @@ func IsInstanceMember(ctx context.Context, filter preparation.FilterToQueryReduc return isMember, nil } -func (c *Commands) AddInstanceMember(ctx context.Context, userID string, roles ...string) (*domain.Member, error) { - instanceAgg := instance.NewAggregate(authz.GetInstance(ctx).InstanceID()) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddInstanceMemberCommand(instanceAgg, userID, roles...)) +type AddInstanceMember struct { + InstanceID string + UserID string + Roles []string +} + +func (c *Commands) AddInstanceMember(ctx context.Context, member *AddInstanceMember) (*domain.ObjectDetails, error) { + instanceAgg := instance.NewAggregate(member.InstanceID) + if err := c.checkPermissionUpdateInstanceMember(ctx, member.InstanceID); err != nil { + return nil, err + } + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddInstanceMemberCommand(instanceAgg, member.UserID, member.Roles...)) if err != nil { return nil, err } @@ -79,33 +89,56 @@ func (c *Commands) AddInstanceMember(ctx context.Context, userID string, roles . if err != nil { return nil, err } - addedMember := NewInstanceMemberWriteModel(ctx, userID) + addedMember := NewInstanceMemberWriteModel(member.InstanceID, member.UserID) err = AppendAndReduce(addedMember, events...) if err != nil { return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil +} + +type ChangeInstanceMember struct { + InstanceID string + UserID string + Roles []string +} + +func (i *ChangeInstanceMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.InstanceID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IAM.MemberInvalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.IAMRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "INSTANCE-3m9fs", "Errors.IAM.MemberInvalid") + } + return nil } // ChangeInstanceMember updates an existing member -func (c *Commands) ChangeInstanceMember(ctx context.Context, member *domain.Member) (*domain.Member, error) { - if !member.IsIAMValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IAM.MemberInvalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.IAMRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-3m9fs", "Errors.IAM.MemberInvalid") - } - - existingMember, err := c.instanceMemberWriteModelByID(ctx, member.UserID) - if err != nil { +func (c *Commands) ChangeInstanceMember(ctx context.Context, member *ChangeInstanceMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "INSTANCE-LiaZi", "Errors.IAM.Member.RolesNotChanged") + existingMember, err := c.instanceMemberWriteModelByID(ctx, member.InstanceID, member.UserID) + if err != nil { + return nil, err } - instanceAgg := InstanceAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, instance.NewMemberChangedEvent(ctx, instanceAgg, member.UserID, member.Roles...)) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "INSTANCE-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateInstanceMember(ctx, existingMember.AggregateID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + pushedEvents, err := c.eventstore.Push(ctx, + instance.NewMemberChangedEvent(ctx, + InstanceAggregateFromWriteModel(&existingMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -114,34 +147,40 @@ func (c *Commands) ChangeInstanceMember(ctx context.Context, member *domain.Memb return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } -func (c *Commands) RemoveInstanceMember(ctx context.Context, userID string) (*domain.ObjectDetails, error) { +func (c *Commands) RemoveInstanceMember(ctx context.Context, instanceID, userID string) (*domain.ObjectDetails, error) { if userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "INSTANCE-LiaZi", "Errors.IDMissing") } - memberWriteModel, err := c.instanceMemberWriteModelByID(ctx, userID) - if err != nil && !zerrors.IsNotFound(err) { - return nil, err - } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil - } - - instanceAgg := InstanceAggregateFromWriteModel(&memberWriteModel.MemberWriteModel.WriteModel) - removeEvent := c.removeInstanceMember(ctx, instanceAgg, userID, false) - pushedEvents, err := c.eventstore.Push(ctx, removeEvent) + existingMember, err := c.instanceMemberWriteModelByID(ctx, instanceID, userID) if err != nil { return nil, err } - err = AppendAndReduce(memberWriteModel, pushedEvents...) + if err := c.checkPermissionDeleteInstanceMember(ctx, instanceID); err != nil { + return nil, err + } + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + + pushedEvents, err := c.eventstore.Push(ctx, + c.removeInstanceMember(ctx, + InstanceAggregateFromWriteModel(&existingMember.WriteModel), + userID, + false, + ), + ) + if err != nil { + return nil, err + } + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&memberWriteModel.MemberWriteModel.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeInstanceMember(ctx context.Context, instanceAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -155,19 +194,15 @@ func (c *Commands) removeInstanceMember(ctx context.Context, instanceAgg *events } } -func (c *Commands) instanceMemberWriteModelByID(ctx context.Context, userID string) (member *InstanceMemberWriteModel, err error) { +func (c *Commands) instanceMemberWriteModelByID(ctx context.Context, instanceID, userID string) (member *InstanceMemberWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewInstanceMemberWriteModel(ctx, userID) + writeModel := NewInstanceMemberWriteModel(instanceID, userID) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "INSTANCE-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/instance_member_model.go b/internal/command/instance_member_model.go index e987a41d97..9cd2ca26a0 100644 --- a/internal/command/instance_member_model.go +++ b/internal/command/instance_member_model.go @@ -1,9 +1,6 @@ package command import ( - "context" - - "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/instance" ) @@ -12,12 +9,12 @@ type InstanceMemberWriteModel struct { MemberWriteModel } -func NewInstanceMemberWriteModel(ctx context.Context, userID string) *InstanceMemberWriteModel { +func NewInstanceMemberWriteModel(instanceID, userID string) *InstanceMemberWriteModel { return &InstanceMemberWriteModel{ MemberWriteModel{ WriteModel: eventstore.WriteModel{ - AggregateID: authz.GetInstance(ctx).InstanceID(), - ResourceOwner: authz.GetInstance(ctx).InstanceID(), + AggregateID: instanceID, + ResourceOwner: instanceID, }, UserID: userID, }, diff --git a/internal/command/instance_member_test.go b/internal/command/instance_member_test.go index 8d254c56bc..264e7d16aa 100644 --- a/internal/command/instance_member_test.go +++ b/internal/command/instance_member_test.go @@ -10,24 +10,22 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/instance" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) -func TestCommandSide_AddIAMMember(t *testing.T) { +func TestCommandSide_AddInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - userID string - roles []string + member *AddInstanceMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -39,28 +37,28 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), + member: &AddInstanceMember{}, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + err: zerrors.IsInternal, }, }, { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -69,10 +67,10 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -80,9 +78,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsPreconditionFailed, @@ -91,8 +91,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -118,6 +117,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -125,9 +125,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -136,8 +138,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -163,6 +164,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -170,9 +172,11 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -181,8 +185,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusherWithInstanceID( "INSTANCE", @@ -209,6 +212,7 @@ func TestCommandSide_AddIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -216,30 +220,49 @@ func TestCommandSide_AddIAMMember(t *testing.T) { }, }, args: args{ - ctx: authz.WithInstanceID(context.Background(), "INSTANCE"), - userID: "user1", - roles: []string{"IAM_OWNER"}, + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - InstanceID: "INSTANCE", - ResourceOwner: "INSTANCE", - AggregateID: "INSTANCE", - }, - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "IAM_OWNER", + }, + }, + }, + args: args{ + member: &AddInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddInstanceMember(tt.args.ctx, tt.args.userID, tt.args.roles...) + got, err := r.AddInstanceMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -247,24 +270,23 @@ func TestCommandSide_AddIAMMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } } -func TestCommandSide_ChangeIAMMember(t *testing.T) { +func TestCommandSide_ChangeInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - instanceID string - member *domain.Member + member *ChangeInstanceMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -276,13 +298,11 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{}, + member: &ChangeInstanceMember{}, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -291,15 +311,14 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ @@ -309,10 +328,10 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -320,10 +339,10 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ @@ -333,8 +352,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -345,6 +363,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleIAMOwner, @@ -352,21 +371,22 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -384,6 +404,7 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "IAM_OWNER", @@ -394,32 +415,62 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - UserID: "user1", - Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "INSTANCE", - AggregateID: "INSTANCE", - InstanceID: "INSTANCE", - }, - UserID: "user1", - Roles: []string{"IAM_OWNER", "IAM_OWNER_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", }, }, }, + { + name: "member change, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewMemberAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "user1", + []string{"IAM_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "IAM_OWNER", + }, + { + Role: "IAM_OWNER_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeInstanceMember{ + InstanceID: "INSTANCE", + UserID: "user1", + Roles: []string{"IAM_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeInstanceMember(tt.args.ctx, tt.args.member) + got, err := r.ChangeInstanceMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -427,18 +478,18 @@ func TestCommandSide_ChangeIAMMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } } -func TestCommandSide_RemoveIAMMember(t *testing.T) { +func TestCommandSide_RemoveInstanceMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context instanceID string userID string } @@ -455,13 +506,12 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "", + instanceID: "INSTANCE", + userID: "", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -470,24 +520,25 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { { name: "member not existing, empty object details result", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", + instanceID: "INSTANCE", + userID: "user1", }, res: res{ - want: &domain.ObjectDetails{}, + want: &domain.ObjectDetails{ + ResourceOwner: "INSTANCE", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( instance.NewMemberAddedEvent(context.Background(), @@ -504,10 +555,11 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - userID: "user1", + instanceID: "INSTANCE", + userID: "user1", }, res: res{ want: &domain.ObjectDetails{ @@ -515,13 +567,38 @@ func TestCommandSide_RemoveIAMMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + instance.NewMemberAddedEvent(context.Background(), + &instance.NewAggregate("INSTANCE").Aggregate, + "user1", + []string{"IAM_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + instanceID: "INSTANCE", + userID: "user1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveInstanceMember(tt.args.ctx, tt.args.userID) + got, err := r.RemoveInstanceMember(context.Background(), tt.args.instanceID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/org.go b/internal/command/org.go index 876c256a0a..ff0208e5e2 100644 --- a/internal/command/org.go +++ b/internal/command/org.go @@ -123,7 +123,7 @@ func (c *Commands) newOrgSetupCommands(ctx context.Context, orgID string, orgSet func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail bool) error { if admin.ID != "" { - c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, admin.ID, orgAdminRoles(admin.Roles)...)) + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(&AddOrgMember{OrgID: c.aggregate.ID, UserID: admin.ID, Roles: orgAdminRoles(admin.Roles)})) return nil } @@ -147,7 +147,7 @@ func (c *orgSetupCommands) setupOrgAdmin(admin *OrgSetupAdmin, allowInitialMail return err } } - c.validations = append(c.validations, c.commands.AddOrgMemberCommand(c.aggregate, userID, orgAdminRoles(admin.Roles)...)) + c.validations = append(c.validations, c.commands.AddOrgMemberCommand(&AddOrgMember{OrgID: c.aggregate.ID, UserID: userID, Roles: orgAdminRoles(admin.Roles)})) return nil } @@ -359,16 +359,15 @@ func (c *Commands) addOrgWithIDAndMember(ctx context.Context, name, userID, reso if err != nil { return nil, err } - err = c.checkUserExists(ctx, userID, resourceOwner) + _, err = c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - addedMember := NewOrgMemberWriteModel(addedOrg.AggregateID, userID) - orgMemberEvent, err := c.addOrgMember(ctx, orgAgg, addedMember, domain.NewMember(orgAgg.ID, userID, domain.RoleOrgOwner)) - if err != nil { + addMember := &AddOrgMember{OrgID: orgAgg.ID, UserID: userID, Roles: []string{domain.RoleOrgOwner}} + if err := addMember.IsValid(c.zitadelRoles); err != nil { return nil, err } - events = append(events, orgMemberEvent) + events = append(events, org.NewMemberAddedEvent(ctx, orgAgg, addMember.UserID, addMember.Roles...)) if setOrgInactive { deactivateOrgEvent := org.NewOrgDeactivatedEvent(ctx, orgAgg) events = append(events, deactivateOrgEvent) diff --git a/internal/command/org_member.go b/internal/command/org_member.go index bf1ae91d8a..a384145e50 100644 --- a/internal/command/org_member.go +++ b/internal/command/org_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" @@ -12,29 +13,22 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddOrgMemberCommand(a *org.Aggregate, userID string, roles ...string) preparation.Validation { +func (c *Commands) AddOrgMemberCommand(member *AddOrgMember) preparation.Validation { return func() (preparation.CreateCommands, error) { - if userID == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument") - } - if len(roles) == 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "V2-PfYhb", "Errors.Invalid.Argument") - } - - if len(domain.CheckForInvalidRoles(roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(roles, domain.RoleSelfManagementGlobal, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) (_ []eventstore.Command, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if exists, err := ExistsUser(ctx, filter, userID, "", false); err != nil || !exists { + if exists, err := ExistsUser(ctx, filter, member.UserID, "", false); err != nil || !exists { return nil, zerrors.ThrowPreconditionFailed(err, "ORG-GoXOn", "Errors.User.NotFound") } - if isMember, err := IsOrgMember(ctx, filter, a.ID, userID); err != nil || isMember { + if isMember, err := IsOrgMember(ctx, filter, member.OrgID, member.UserID); err != nil || isMember { return nil, zerrors.ThrowAlreadyExists(err, "ORG-poWwe", "Errors.Org.Member.AlreadyExists") } - return []eventstore.Command{org.NewMemberAddedEvent(ctx, &a.Aggregate, userID, roles...)}, nil + return []eventstore.Command{org.NewMemberAddedEvent(ctx, &org.NewAggregate(member.OrgID).Aggregate, member.UserID, member.Roles...)}, nil }, nil } @@ -76,12 +70,33 @@ func IsOrgMember(ctx context.Context, filter preparation.FilterToQueryReducer, o return isMember, nil } -func (c *Commands) AddOrgMember(ctx context.Context, orgID, userID string, roles ...string) (_ *domain.Member, err error) { +type AddOrgMember struct { + OrgID string + UserID string + Roles []string +} + +func (m *AddOrgMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if m.UserID == "" || m.OrgID == "" || len(m.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument") + } + if len(domain.CheckForInvalidRoles(m.Roles, domain.OrgRolePrefix, zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(m.Roles, domain.RoleSelfManagementGlobal, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") + } + return nil +} + +func (c *Commands) AddOrgMember(ctx context.Context, member *AddOrgMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - - orgAgg := org.NewAggregate(orgID) - cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddOrgMemberCommand(orgAgg, userID, roles...)) + if err := c.checkOrgExists(ctx, member.OrgID); err != nil { + return nil, err + } + if err := c.checkPermissionUpdateOrgMember(ctx, member.OrgID, member.OrgID); err != nil { + return nil, err + } + //nolint:staticcheck + cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.AddOrgMemberCommand(member)) if err != nil { return nil, err } @@ -89,51 +104,59 @@ func (c *Commands) AddOrgMember(ctx context.Context, orgID, userID string, roles if err != nil { return nil, err } - addedMember := NewOrgMemberWriteModel(orgID, userID) + addedMember := NewOrgMemberWriteModel(member.OrgID, member.UserID) err = AppendAndReduce(addedMember, events...) if err != nil { return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil } -func (c *Commands) addOrgMember(ctx context.Context, orgAgg *eventstore.Aggregate, addedMember *OrgMemberWriteModel, member *domain.Member) (eventstore.Command, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-W8m4l", "Errors.Org.MemberInvalid") +type ChangeOrgMember struct { + OrgID string + UserID string + Roles []string +} + +func (c *ChangeOrgMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if c.OrgID == "" || c.UserID == "" || len(c.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") } - if len(domain.CheckForInvalidRoles(member.Roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 && len(domain.CheckForInvalidRoles(member.Roles, domain.RoleSelfManagementGlobal, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-4N8es", "Errors.Org.MemberInvalid") - } - err := c.eventstore.FilterToQueryReducer(ctx, addedMember) - if err != nil { - return nil, err - } - if addedMember.State == domain.MemberStateActive { - return nil, zerrors.ThrowAlreadyExists(nil, "Org-PtXi1", "Errors.Org.Member.AlreadyExists") + if len(domain.CheckForInvalidRoles(c.Roles, domain.OrgRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "IAM-m9fG8", "Errors.Org.MemberInvalid") } - return org.NewMemberAddedEvent(ctx, orgAgg, member.UserID, member.Roles...), nil + return nil } // ChangeOrgMember updates an existing member -func (c *Commands) ChangeOrgMember(ctx context.Context, member *domain.Member) (*domain.Member, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.OrgRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "IAM-m9fG8", "Errors.Org.MemberInvalid") - } - - existingMember, err := c.orgMemberWriteModelByID(ctx, member.AggregateID, member.UserID) - if err != nil { +func (c *Commands) ChangeOrgMember(ctx context.Context, member *ChangeOrgMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "Org-LiaZi", "Errors.Org.Member.RolesNotChanged") + existingMember, err := c.orgMemberWriteModelByID(ctx, member.OrgID, member.UserID) + if err != nil { + return nil, err } - orgAgg := OrgAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) - pushedEvents, err := c.eventstore.Push(ctx, org.NewMemberChangedEvent(ctx, orgAgg, member.UserID, member.Roles...)) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "Org-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateOrgMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err + } + + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + + pushedEvents, err := c.eventstore.Push(ctx, + org.NewMemberChangedEvent(ctx, + OrgAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -142,30 +165,39 @@ func (c *Commands) ChangeOrgMember(ctx context.Context, member *domain.Member) ( return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveOrgMember(ctx context.Context, orgID, userID string) (*domain.ObjectDetails, error) { - m, err := c.orgMemberWriteModelByID(ctx, orgID, userID) - if err != nil && !zerrors.IsNotFound(err) { + if orgID == "" || userID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "Org-LiaZi", "Errors.Org.MemberInvalid") + } + existingMember, err := c.orgMemberWriteModelByID(ctx, orgID, userID) + if err != nil { return nil, err } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteOrgMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err } - orgAgg := OrgAggregateFromWriteModel(&m.MemberWriteModel.WriteModel) - removeEvent := c.removeOrgMember(ctx, orgAgg, userID, false) - pushedEvents, err := c.eventstore.Push(ctx, removeEvent) + pushedEvents, err := c.eventstore.Push(ctx, + c.removeOrgMember(ctx, + OrgAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + userID, + false, + ), + ) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeOrgMember(ctx context.Context, orgAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -189,9 +221,5 @@ func (c *Commands) orgMemberWriteModelByID(ctx context.Context, orgID, userID st return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "Org-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/org_member_test.go b/internal/command/org_member_test.go index 4e2e926b52..25dd3b818a 100644 --- a/internal/command/org_member_test.go +++ b/internal/command/org_member_test.go @@ -11,24 +11,19 @@ import ( "github.com/zitadel/zitadel/internal/command/preparation" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/org" - "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) func TestAddMember(t *testing.T) { type args struct { - a *org.Aggregate - userID string - roles []string + member *AddOrgMember zitadelRoles []authz.RoleMapping filter preparation.FilterToQueryReducer } ctx := context.Background() - agg := org.NewAggregate("test") tests := []struct { name string @@ -38,8 +33,10 @@ func TestAddMember(t *testing.T) { { name: "no user id", args: args{ - a: agg, - userID: "", + member: &AddOrgMember{ + OrgID: "test", + UserID: "", + }, }, want: Want{ ValidationErr: zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument"), @@ -48,19 +45,23 @@ func TestAddMember(t *testing.T) { { name: "no roles", args: args{ - a: agg, - userID: "12342", + member: &AddOrgMember{ + OrgID: "test", + UserID: "12342", + }, }, want: Want{ - ValidationErr: zerrors.ThrowInvalidArgument(nil, "V2-PfYhb", "Errors.Invalid.Argument"), + ValidationErr: zerrors.ThrowInvalidArgument(nil, "ORG-4Mlfs", "Errors.Invalid.Argument"), }, }, { name: "TODO: invalid roles", args: args{ - a: agg, - userID: "123", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "12342", + Roles: []string{"ORG_OWNER"}, + }, }, want: Want{ ValidationErr: zerrors.ThrowInvalidArgument(nil, "Org-4N8es", ""), @@ -69,9 +70,11 @@ func TestAddMember(t *testing.T) { { name: "user not exists", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -89,9 +92,11 @@ func TestAddMember(t *testing.T) { { name: "already member", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -129,9 +134,11 @@ func TestAddMember(t *testing.T) { { name: "correct", args: args{ - a: agg, - userID: "userID", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "test", + UserID: "userID", + Roles: []string{"ORG_OWNER"}, + }, zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -158,14 +165,14 @@ func TestAddMember(t *testing.T) { }, want: Want{ Commands: []eventstore.Command{ - org.NewMemberAddedEvent(ctx, &agg.Aggregate, "userID", "ORG_OWNER"), + org.NewMemberAddedEvent(ctx, &org.NewAggregate("test").Aggregate, "userID", "ORG_OWNER"), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - AssertValidation(t, context.Background(), (&Commands{zitadelRoles: tt.args.zitadelRoles}).AddOrgMemberCommand(tt.args.a, tt.args.userID, tt.args.roles...), tt.args.filter, tt.want) + AssertValidation(t, context.Background(), (&Commands{zitadelRoles: tt.args.zitadelRoles}).AddOrgMemberCommand(tt.args.member), tt.args.filter, tt.want) }) } } @@ -287,17 +294,15 @@ func TestIsMember(t *testing.T) { func TestCommandSide_AddOrgMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping } type args struct { - ctx context.Context - userID string - orgID string - roles []string + member *AddOrgMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -309,13 +314,22 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - orgID: "org1", + member: &AddOrgMember{ + OrgID: "org1", + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -324,15 +338,24 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -341,10 +364,18 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -352,10 +383,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{domain.RoleOrgOwner}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsPreconditionFailed, @@ -364,8 +396,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -391,6 +430,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -398,10 +438,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -410,8 +451,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -437,6 +485,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -444,10 +493,11 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -456,8 +506,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -483,6 +540,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -490,30 +548,58 @@ func TestCommandSide_AddOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - orgID: "org1", - userID: "user1", - roles: []string{"ORG_OWNER"}, + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "org1", - }, - UserID: "user1", - Roles: []string{domain.RoleOrgOwner}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "org", + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleOrgOwner, + }, + }, + }, + args: args{ + member: &AddOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddOrgMember(tt.args.ctx, tt.args.orgID, tt.args.userID, tt.args.roles...) + got, err := r.AddOrgMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -521,7 +607,7 @@ func TestCommandSide_AddOrgMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -529,15 +615,15 @@ func TestCommandSide_AddOrgMember(t *testing.T) { func TestCommandSide_ChangeOrgMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping } type args struct { - ctx context.Context - member *domain.Member + member *ChangeOrgMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -549,16 +635,12 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", }, }, res: res{ @@ -568,16 +650,12 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"PROJECT_OWNER"}, }, @@ -589,10 +667,10 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -600,11 +678,8 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER"}, }, @@ -614,10 +689,9 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, { - name: "member not changed, precondition error", + name: "member not changed, no change", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewMemberAddedEvent(context.Background(), @@ -628,6 +702,7 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleOrgOwner, @@ -635,24 +710,22 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewMemberAddedEvent(context.Background(), @@ -670,6 +743,7 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "ORG_OWNER", @@ -680,160 +754,210 @@ func TestCommandSide_ChangeOrgMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "org1", - }, + member: &ChangeOrgMember{ + OrgID: "org1", UserID: "user1", Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, }, }, - res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "org1", - }, - UserID: "user1", - Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, - } - got, err := r.ChangeOrgMember(tt.args.ctx, tt.args.member) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { - t.Errorf("got wrong err: %v ", err) - } - if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) - } - }) - } -} - -func TestCommandSide_RemoveOrgMember(t *testing.T) { - type fields struct { - eventstore *eventstore.Eventstore - } - type args struct { - ctx context.Context - projectID string - userID string - resourceOwner string - } - type res struct { - want *domain.ObjectDetails - err func(error) bool - } - tests := []struct { - name string - fields fields - args args - res res - }{ - { - name: "invalid member projectid missing, error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "", - userID: "user1", - resourceOwner: "org1", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "invalid member userid missing, error", - fields: fields{ - eventstore: eventstoreExpect( - t, - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "", - resourceOwner: "org1", - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, - { - name: "member not existing, empty object details result", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter(), - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "user1", - resourceOwner: "org1", - }, - res: res{ - want: &domain.ObjectDetails{}, - }, - }, - { - name: "member remove, ok", - fields: fields{ - eventstore: eventstoreExpect( - t, - expectFilter( - eventFromEventPusher( - project.NewProjectMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - []string{"PROJECT_OWNER"}..., - ), - ), - ), - expectPush( - project.NewProjectMemberRemovedEvent(context.Background(), - &project.NewAggregate("project1", "org1").Aggregate, - "user1", - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - projectID: "project1", - userID: "user1", - resourceOwner: "org1", - }, res: res{ want: &domain.ObjectDetails{ ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"ORG_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "ORG_OWNER", + }, + { + Role: "ORG_OWNER_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeOrgMember{ + OrgID: "org1", + UserID: "user1", + Roles: []string{"ORG_OWNER", "ORG_OWNER_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveProjectMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.resourceOwner) + got, err := r.ChangeOrgMember(context.Background(), tt.args.member) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assertObjectDetails(t, tt.res.want, got) + } + }) + } +} + +func TestCommandSide_RemoveOrgMember(t *testing.T) { + type fields struct { + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck + } + type args struct { + ctx context.Context + orgID string + userID string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "invalid member orgID missing, error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "", + userID: "user1", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "invalid member userid missing, error", + fields: fields{ + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "", + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "member not existing, empty object details result", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "member remove, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + expectPush( + org.NewMemberRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewMemberAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, + } + got, err := r.RemoveOrgMember(tt.args.ctx, tt.args.orgID, tt.args.userID) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/org_model.go b/internal/command/org_model.go index 2661af9bd9..9b39805609 100644 --- a/internal/command/org_model.go +++ b/internal/command/org_model.go @@ -1,6 +1,8 @@ package command import ( + "context" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/org" @@ -63,3 +65,7 @@ func (wm *OrgWriteModel) Query() *eventstore.SearchQueryBuilder { func OrgAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Aggregate { return eventstore.AggregateFromWriteModel(wm, org.AggregateType, org.AggregateVersion) } + +func OrgAggregateFromWriteModelWithCTX(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return org.AggregateFromWriteModel(ctx, wm) +} diff --git a/internal/command/org_test.go b/internal/command/org_test.go index 4239be760a..a5d00d9bfd 100644 --- a/internal/command/org_test.go +++ b/internal/command/org_test.go @@ -73,7 +73,7 @@ func TestAddOrg(t *testing.T) { func TestCommandSide_AddOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator zitadelRoles []authz.RoleMapping } @@ -97,9 +97,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "invalid org, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -113,9 +111,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "invalid org (spaces), error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -130,8 +126,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "user removed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -174,8 +169,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "push failed unique constraint, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -193,7 +187,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "id", "internal"), org.NewOrgAddedEvent( context.Background(), @@ -242,8 +235,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -261,7 +253,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPushFailed(zerrors.ThrowInternal(nil, "id", "internal"), org.NewOrgAddedEvent( context.Background(), @@ -310,8 +301,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "add org, no error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -329,7 +319,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPush( org.NewOrgAddedEvent(context.Background(), &org.NewAggregate("org2").Aggregate, @@ -381,8 +370,7 @@ func TestCommandSide_AddOrg(t *testing.T) { { name: "add org (remove spaces), no error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilterOrgDomainNotFound(), expectFilter( eventFromEventPusher( @@ -400,7 +388,6 @@ func TestCommandSide_AddOrg(t *testing.T) { ), ), ), - expectFilterOrgMemberNotFound(), expectPush( org.NewOrgAddedEvent(context.Background(), &org.NewAggregate("org2").Aggregate, @@ -453,7 +440,7 @@ func TestCommandSide_AddOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, zitadelRoles: tt.fields.zitadelRoles, } @@ -473,7 +460,7 @@ func TestCommandSide_AddOrg(t *testing.T) { func TestCommandSide_ChangeOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context @@ -492,9 +479,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "empty name, invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -507,9 +492,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "empty name (spaces), invalid argument error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -523,8 +506,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -540,8 +522,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "no change (spaces), error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -563,8 +544,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -593,8 +573,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name verified, not primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -645,8 +624,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name verified, with primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -705,8 +683,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { { name: "change org name case verified, with primary", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -754,7 +731,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } _, err := r.ChangeOrg(tt.args.ctx, tt.args.orgID, tt.args.name) if tt.res.err == nil { @@ -769,7 +746,7 @@ func TestCommandSide_ChangeOrg(t *testing.T) { func TestCommandSide_DeactivateOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator iamDomain string } @@ -790,8 +767,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -806,8 +782,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "org already inactive, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -832,8 +807,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -860,8 +834,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { { name: "deactivate org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -886,7 +859,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.DeactivateOrg(tt.args.ctx, tt.args.orgID) @@ -902,7 +875,7 @@ func TestCommandSide_DeactivateOrg(t *testing.T) { func TestCommandSide_ReactivateOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator iamDomain string } @@ -923,8 +896,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -939,8 +911,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "org already active, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -961,8 +932,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -994,8 +964,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { { name: "reactivate org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( org.NewOrgAddedEvent(context.Background(), @@ -1024,7 +993,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.ReactivateOrg(tt.args.ctx, tt.args.orgID) @@ -1040,7 +1009,7 @@ func TestCommandSide_ReactivateOrg(t *testing.T) { func TestCommandSide_RemoveOrg(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore idGenerator id.Generator } type args struct { @@ -1059,9 +1028,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "default org, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.WithInstance(context.Background(), &mockInstance{}), @@ -1074,8 +1041,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "zitadel org, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectAddedEvent(context.Background(), @@ -1100,8 +1066,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "org not found, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter(), ), @@ -1117,8 +1082,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "push failed, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1160,8 +1124,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "remove org", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1200,8 +1163,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { { name: "remove org with usernames and domains", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), // zitadel project check expectFilter( eventFromEventPusher( @@ -1291,7 +1253,7 @@ func TestCommandSide_RemoveOrg(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), idGenerator: tt.fields.idGenerator, } _, err := r.RemoveOrg(tt.args.ctx, tt.args.orgID) diff --git a/internal/command/permission_checks.go b/internal/command/permission_checks.go index 3f978b6618..128880341a 100644 --- a/internal/command/permission_checks.go +++ b/internal/command/permission_checks.go @@ -6,6 +6,8 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" + "github.com/zitadel/zitadel/internal/repository/instance" + "github.com/zitadel/zitadel/internal/repository/org" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/v2/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -13,6 +15,8 @@ import ( type PermissionCheck func(resourceOwner, aggregateID string) error +type UserGrantPermissionCheck func(projectID, projectGrantID string) PermissionCheck + func (c *Commands) newPermissionCheck(ctx context.Context, permission string, aggregateType eventstore.AggregateType) PermissionCheck { return func(resourceOwner, aggregateID string) error { if aggregateID == "" { @@ -93,3 +97,62 @@ func (c *Commands) checkPermissionUpdateApplication(ctx context.Context, resourc func (c *Commands) checkPermissionDeleteApp(ctx context.Context, resourceOwner, appID string) error { return c.newPermissionCheck(ctx, domain.PermissionProjectAppDelete, project.AggregateType)(resourceOwner, appID) } + +func (c *Commands) checkPermissionUpdateInstanceMember(ctx context.Context, instanceID string) error { + return c.newPermissionCheck(ctx, domain.PermissionInstanceMemberWrite, instance.AggregateType)(instanceID, instanceID) +} + +func (c *Commands) checkPermissionDeleteInstanceMember(ctx context.Context, instanceID string) error { + return c.newPermissionCheck(ctx, domain.PermissionInstanceMemberDelete, instance.AggregateType)(instanceID, instanceID) +} + +func (c *Commands) checkPermissionUpdateOrgMember(ctx context.Context, instanceID, orgID string) error { + return c.newPermissionCheck(ctx, domain.PermissionOrgMemberWrite, org.AggregateType)(instanceID, orgID) +} +func (c *Commands) checkPermissionDeleteOrgMember(ctx context.Context, instanceID, orgID string) error { + return c.newPermissionCheck(ctx, domain.PermissionOrgMemberDelete, org.AggregateType)(instanceID, orgID) +} + +func (c *Commands) checkPermissionUpdateProjectMember(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectMemberWrite, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionDeleteProjectMember(ctx context.Context, resourceOwner, projectID string) error { + return c.newPermissionCheck(ctx, domain.PermissionProjectMemberDelete, project.AggregateType)(resourceOwner, projectID) +} + +func (c *Commands) checkPermissionUpdateProjectGrantMember(ctx context.Context, grantedOrgID, projectGrantID string) (err error) { + // TODO: add permission check for project grant owners + //if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberWrite, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + return c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberWrite, project.AggregateType)(grantedOrgID, projectGrantID) + //} + //return nil +} + +func (c *Commands) checkPermissionDeleteProjectGrantMember(ctx context.Context, grantedOrgID, projectGrantID string) (err error) { + // TODO: add permission check for project grant owners + //if err := c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberDelete, project.AggregateType)(resourceOwner, projectGrantID); err != nil { + return c.newPermissionCheck(ctx, domain.PermissionProjectGrantMemberDelete, project.AggregateType)(grantedOrgID, projectGrantID) + //} + //return nil +} + +func (c *Commands) newUserGrantPermissionCheck(ctx context.Context, permission string) UserGrantPermissionCheck { + check := c.newPermissionCheck(ctx, permission, project.AggregateType) + return func(projectID, projectGrantID string) PermissionCheck { + return func(resourceOwner, _ string) error { + if projectGrantID != "" { + return check(resourceOwner, projectGrantID) + } + return check(resourceOwner, projectID) + } + } +} + +func (c *Commands) NewPermissionCheckUserGrantWrite(ctx context.Context) UserGrantPermissionCheck { + return c.newUserGrantPermissionCheck(ctx, domain.PermissionUserGrantWrite) +} + +func (c *Commands) NewPermissionCheckUserGrantDelete(ctx context.Context) UserGrantPermissionCheck { + return c.newUserGrantPermissionCheck(ctx, domain.PermissionUserGrantDelete) +} diff --git a/internal/command/project.go b/internal/command/project.go index 40aa79f186..4cdf1b7373 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -346,7 +346,7 @@ func (c *Commands) RemoveProject(ctx context.Context, projectID, resourceOwner s } for _, grantID := range cascadingUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, "", true) + event, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue @@ -398,7 +398,7 @@ func (c *Commands) DeleteProject(ctx context.Context, id, resourceOwner string, ), } for _, grantID := range cascadingUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, "", true) + event, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("id", "COMMAND-b8Djf", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue diff --git a/internal/command/project_grant.go b/internal/command/project_grant.go index b613974b7e..2a0e83a6e8 100644 --- a/internal/command/project_grant.go +++ b/internal/command/project_grant.go @@ -234,6 +234,17 @@ func (c *Commands) DeactivateProjectGrant(ctx context.Context, projectID, grantI return writeModelToObjectDetails(&existingGrant.WriteModel), nil } +func (c *Commands) checkProjectGrantExists(ctx context.Context, grantID, grantedOrgID, projectID, resourceOwner string) (string, string, error) { + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, grantedOrgID, projectID, resourceOwner) + if err != nil { + return "", "", err + } + if !existingGrant.State.Exists() { + return "", "", zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.Project.Grant.NotFound") + } + return existingGrant.GrantedOrgID, existingGrant.ResourceOwner, nil +} + func (c *Commands) ReactivateProjectGrant(ctx context.Context, projectID, grantID, grantedOrgID, resourceOwner string) (details *domain.ObjectDetails, err error) { if (grantID == "" && grantedOrgID == "") || projectID == "" { return details, zerrors.ThrowInvalidArgument(nil, "PROJECT-p0s4V", "Errors.IDMissing") @@ -302,16 +313,17 @@ func (c *Commands) RemoveProjectGrant(ctx context.Context, projectID, grantID, r ProjectAggregateFromWriteModelWithCTX(ctx, &existingGrant.WriteModel), existingGrant.GrantID, existingGrant.GrantedOrgID, - ), - ) + )) for _, userGrantID := range cascadeUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) + event, _, err := c.removeUserGrant(ctx, userGrantID, "", true, true, nil) if err != nil { logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } - events = append(events, event) + if event != nil { + events = append(events, event) + } } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { @@ -348,12 +360,14 @@ func (c *Commands) DeleteProjectGrant(ctx context.Context, projectID, grantID, g ) for _, userGrantID := range cascadeUserGrantIDs { - event, _, err := c.removeUserGrant(ctx, userGrantID, "", true) + event, _, err := c.removeUserGrant(ctx, userGrantID, "", true, true, nil) if err != nil { logging.WithFields("id", "COMMAND-3m8sG", "usergrantid", grantID).WithError(err).Warn("could not cascade remove user grant") continue } - events = append(events, event) + if event != nil { + events = append(events, event) + } } pushedEvents, err := c.eventstore.Push(ctx, events...) if err != nil { diff --git a/internal/command/project_grant_member.go b/internal/command/project_grant_member.go index f7ea887475..611b897c61 100644 --- a/internal/command/project_grant_member.go +++ b/internal/command/project_grant_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/project" @@ -11,25 +12,66 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectGrantMember(ctx context.Context, member *domain.ProjectGrantMember) (_ *domain.ProjectGrantMember, err error) { +type AddProjectGrantMember struct { + ResourceOwner string + UserID string + GrantID string + ProjectID string + Roles []string +} + +func (i *AddProjectGrantMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.GrantID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-8fi7G", "Errors.Project.Grant.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectGrantRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-m9gKK", "Errors.Project.Grant.Member.Invalid") + } + return nil +} + +func (c *Commands) AddProjectGrantMember(ctx context.Context, member *AddProjectGrantMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-8fi7G", "Errors.Project.Grant.Member.Invalid") + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectGrantRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-m9gKK", "Errors.Project.Grant.Member.Invalid") - } - err = c.checkUserExists(ctx, member.UserID, "") + _, err = c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } - addedMember := NewProjectGrantMemberWriteModel(member.AggregateID, member.UserID, member.GrantID) - projectAgg := ProjectAggregateFromWriteModel(&addedMember.WriteModel) + grantedOrgID, projectGrantResourceOwner, err := c.checkProjectGrantExists(ctx, member.GrantID, "", member.ProjectID, "") + if err != nil { + return nil, err + } + if member.ResourceOwner == "" { + member.ResourceOwner = projectGrantResourceOwner + } + addedMember, err := c.projectGrantMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.GrantID, member.ResourceOwner) + if err != nil { + return nil, err + } + // TODO: change e2e tests to use correct resourceowner, wrong resource owner is corrected through aggregate + // error if provided resourceowner is not equal to the resourceowner of the project grant + //if projectGrantResourceOwner != addedMember.ResourceOwner { + // return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Grant.Invalid") + //} + if addedMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.AlreadyExists") + } + if err := c.checkPermissionUpdateProjectGrantMember(ctx, grantedOrgID, addedMember.GrantID); err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push( ctx, - project.NewProjectGrantMemberAddedEvent(ctx, projectAgg, member.UserID, member.GrantID, member.Roles...)) + project.NewProjectGrantMemberAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &addedMember.WriteModel), + member.UserID, + member.GrantID, + member.Roles..., + )) if err != nil { return nil, err } @@ -38,30 +80,58 @@ func (c *Commands) AddProjectGrantMember(ctx context.Context, member *domain.Pro return nil, err } - return memberWriteModelToProjectGrantMember(addedMember), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil +} + +type ChangeProjectGrantMember struct { + UserID string + GrantID string + ProjectID string + Roles []string +} + +func (i *ChangeProjectGrantMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.GrantID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-109fs", "Errors.Project.Grant.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectGrantRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-m0sDf", "Errors.Project.Grant.Member.Invalid") + } + return nil } // ChangeProjectGrantMember updates an existing member -func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *domain.ProjectGrantMember) (*domain.ProjectGrantMember, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-109fs", "Errors.Project.Member.Invalid") +func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *ChangeProjectGrantMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectGrantRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-m0sDf", "Errors.Project.Member.Invalid") - } - - existingMember, err := c.projectGrantMemberWriteModelByID(ctx, member.AggregateID, member.UserID, member.GrantID) + existingGrant, err := c.projectGrantWriteModelByID(ctx, member.GrantID, "", member.ProjectID, "") if err != nil { return nil, err } - - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-2n8vx", "Errors.Project.Member.RolesNotChanged") + existingMember, err := c.projectGrantMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.GrantID, existingGrant.ResourceOwner) + if err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&existingMember.WriteModel) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.NotFound") + } + + if err := c.checkPermissionUpdateProjectGrantMember(ctx, existingGrant.GrantedOrgID, existingMember.GrantID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + pushedEvents, err := c.eventstore.Push( ctx, - project.NewProjectGrantMemberChangedEvent(ctx, projectAgg, member.UserID, member.GrantID, member.Roles...)) + project.NewProjectGrantMemberChangedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + member.UserID, + member.GrantID, + member.Roles..., + )) if err != nil { return nil, err } @@ -70,29 +140,43 @@ func (c *Commands) ChangeProjectGrantMember(ctx context.Context, member *domain. return nil, err } - return memberWriteModelToProjectGrantMember(existingMember), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveProjectGrantMember(ctx context.Context, projectID, userID, grantID string) (*domain.ObjectDetails, error) { if projectID == "" || userID == "" || grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-66mHd", "Errors.Project.Member.Invalid") } - m, err := c.projectGrantMemberWriteModelByID(ctx, projectID, userID, grantID) + existingGrant, err := c.projectGrantWriteModelByID(ctx, grantID, "", projectID, "") if err != nil { return nil, err } + existingMember, err := c.projectGrantMemberWriteModelByID(ctx, projectID, userID, grantID, existingGrant.ResourceOwner) + if err != nil { + return nil, err + } + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectGrantMember(ctx, existingGrant.GrantedOrgID, existingMember.GrantID); err != nil { + return nil, err + } - projectAgg := ProjectAggregateFromWriteModel(&m.WriteModel) - removeEvent := c.removeProjectGrantMember(ctx, projectAgg, userID, grantID, false) + removeEvent := c.removeProjectGrantMember(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel), + userID, + grantID, + false, + ) pushedEvents, err := c.eventstore.Push(ctx, removeEvent) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeProjectGrantMember(ctx context.Context, projectAgg *eventstore.Aggregate, userID, grantID string, cascade bool) eventstore.Command { @@ -107,19 +191,15 @@ func (c *Commands) removeProjectGrantMember(ctx context.Context, projectAgg *eve } } -func (c *Commands) projectGrantMemberWriteModelByID(ctx context.Context, projectID, userID, grantID string) (member *ProjectGrantMemberWriteModel, err error) { +func (c *Commands) projectGrantMemberWriteModelByID(ctx context.Context, projectID, userID, grantID, resourceOwner string) (member *ProjectGrantMemberWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - writeModel := NewProjectGrantMemberWriteModel(projectID, userID, grantID) + writeModel := NewProjectGrantMemberWriteModel(projectID, userID, grantID, resourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, writeModel) if err != nil { return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-37fug", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/project_grant_member_model.go b/internal/command/project_grant_member_model.go index 6c089ba1c8..1d9ab02478 100644 --- a/internal/command/project_grant_member_model.go +++ b/internal/command/project_grant_member_model.go @@ -16,10 +16,11 @@ type ProjectGrantMemberWriteModel struct { State domain.MemberState } -func NewProjectGrantMemberWriteModel(projectID, userID, grantID string) *ProjectGrantMemberWriteModel { +func NewProjectGrantMemberWriteModel(projectID, userID, grantID, resourceOwner string) *ProjectGrantMemberWriteModel { return &ProjectGrantMemberWriteModel{ WriteModel: eventstore.WriteModel{ - AggregateID: projectID, + AggregateID: projectID, + ResourceOwner: resourceOwner, }, UserID: userID, GrantID: grantID, @@ -66,6 +67,7 @@ func (wm *ProjectGrantMemberWriteModel) Reduce() error { case *project.GrantMemberAddedEvent: wm.Roles = e.Roles wm.State = domain.MemberStateActive + wm.ResourceOwner = e.Aggregate().ResourceOwner case *project.GrantMemberChangedEvent: wm.Roles = e.Roles case *project.GrantMemberRemovedEvent: @@ -80,7 +82,8 @@ func (wm *ProjectGrantMemberWriteModel) Reduce() error { } func (wm *ProjectGrantMemberWriteModel) Query() *eventstore.SearchQueryBuilder { - return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + query := eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). AddQuery(). AggregateTypes(project.AggregateType). AggregateIDs(wm.AggregateID). @@ -92,4 +95,5 @@ func (wm *ProjectGrantMemberWriteModel) Query() *eventstore.SearchQueryBuilder { project.GrantRemovedType, project.ProjectRemovedType). Builder() + return query } diff --git a/internal/command/project_grant_member_test.go b/internal/command/project_grant_member_test.go index 189d1b9911..3cd8ecdd82 100644 --- a/internal/command/project_grant_member_test.go +++ b/internal/command/project_grant_member_test.go @@ -10,7 +10,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -18,15 +17,15 @@ import ( func TestCommandSide_AddProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.ProjectGrantMember + member *AddProjectGrantMember } type res struct { - want *domain.ProjectGrantMember + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -38,16 +37,12 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &AddProjectGrantMember{ + ProjectID: "project1", }, }, res: res{ @@ -57,19 +52,15 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -79,10 +70,10 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -90,14 +81,11 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -107,8 +95,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -125,15 +112,28 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internal"), project.NewProjectGrantMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "").Aggregate, + &project.NewAggregate("project1", "org1").Aggregate, "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -141,14 +141,11 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -158,8 +155,7 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -176,15 +172,28 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), expectPush( project.NewProjectGrantMemberAddedEvent(context.Background(), - &project.NewAggregate("project1", "").Aggregate, + &project.NewAggregate("project1", "org1").Aggregate, "user1", "projectgrant1", []string{"PROJECT_GRANT_OWNER"}..., ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -192,35 +201,81 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - GrantID: "projectgrant1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ - want: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + ID: "project1", }, }, }, + { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + eventFromEventPusher(project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "grantedorg1", + []string{"key1"}, + ), + ), + ), + ), + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "PROJECT_GRANT_OWNER", + }, + }, + }, + args: args{ + member: &AddProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectGrantMember(tt.args.ctx, tt.args.member) + got, err := r.AddProjectGrantMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -236,15 +291,15 @@ func TestCommandSide_AddProjectGrantMember(t *testing.T) { func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.ProjectGrantMember + member *ChangeProjectGrantMember } type res struct { - want *domain.ProjectGrantMember + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -256,16 +311,12 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", }, }, res: res{ @@ -275,19 +326,15 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, }, res: res{ @@ -297,10 +344,20 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -308,14 +365,11 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ @@ -325,8 +379,17 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -338,6 +401,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -345,25 +409,33 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER"}, }, }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -383,6 +455,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: "PROJECT_GRANT_OWNER", @@ -393,36 +466,75 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, }, }, res: res{ - want: &domain.ProjectGrantMember{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - GrantID: "projectgrant1", - UserID: "user1", - Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectGrantMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + "projectgrant1", + []string{"PROJECT_GRANT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: "PROJECT_GRANT_OWNER", + }, + { + Role: "PROJECT_GRANT_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeProjectGrantMember{ + ProjectID: "project1", + GrantID: "projectgrant1", + UserID: "user1", + Roles: []string{"PROJECT_GRANT_OWNER", "PROJECT_GRANT_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectGrantMember(tt.args.ctx, tt.args.member) + got, err := r.ChangeProjectGrantMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -430,7 +542,7 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -438,7 +550,8 @@ func TestCommandSide_ChangeProjectGrantMember(t *testing.T) { func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { ctx context.Context @@ -459,9 +572,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member projectid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -476,9 +588,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -493,9 +604,8 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { { name: "invalid member grantid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -508,12 +618,22 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { }, }, { - name: "member not existing, not found err", + name: "member not existing, not found ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -522,14 +642,25 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { grantID: "projectgrant1", }, res: res{ - err: zerrors.IsNotFound, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectGrantMemberAddedEvent(context.Background(), @@ -548,6 +679,7 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -561,11 +693,49 @@ func TestCommandSide_RemoveProjectGrantMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectgrant1", + "org2", + []string{"rol1", "role2"}, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectGrantMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + "projectgrant1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + projectID: "project1", + userID: "user1", + grantID: "projectgrant1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveProjectGrantMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.grantID) if tt.res.err == nil { diff --git a/internal/command/project_member.go b/internal/command/project_member.go index a2e4fae553..1bbd358490 100644 --- a/internal/command/project_member.go +++ b/internal/command/project_member.go @@ -2,8 +2,9 @@ package command import ( "context" - "reflect" + "slices" + "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" "github.com/zitadel/zitadel/internal/repository/project" @@ -11,18 +12,64 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddProjectMember(ctx context.Context, member *domain.Member, resourceOwner string) (_ *domain.Member, err error) { +type AddProjectMember struct { + ResourceOwner string + ProjectID string + UserID string + Roles []string +} + +func (i *AddProjectMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-W8m4l", "Errors.Project.Member.Invalid") + } + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9ds", "Errors.Project.Member.Invalid") + } + return nil +} + +func (c *Commands) AddProjectMember(ctx context.Context, member *AddProjectMember) (_ *domain.ObjectDetails, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - addedMember := NewProjectMemberWriteModel(member.AggregateID, member.UserID, resourceOwner) - projectAgg := ProjectAggregateFromWriteModel(&addedMember.WriteModel) - event, err := c.addProjectMember(ctx, projectAgg, addedMember, member) + if err := member.IsValid(c.zitadelRoles); err != nil { + return nil, err + } + _, err = c.checkUserExists(ctx, member.UserID, "") if err != nil { return nil, err } + projectResourceOwner, err := c.checkProjectExists(ctx, member.ProjectID, member.ResourceOwner) + if err != nil { + return nil, err + } + // resourceowner of the member if not provided is the resourceowner of the project + if member.ResourceOwner == "" { + member.ResourceOwner = projectResourceOwner + } + addedMember, err := c.projectMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.ResourceOwner) + if err != nil { + return nil, err + } + // error if provided resourceowner is not equal to the resourceowner of the project + if projectResourceOwner != addedMember.ResourceOwner { + return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-0l10S9OmZV", "Errors.Project.Member.Invalid") + } + if err := c.checkPermissionUpdateProjectMember(ctx, addedMember.ResourceOwner, addedMember.AggregateID); err != nil { + return nil, err + } + if addedMember.State.Exists() { + return nil, zerrors.ThrowAlreadyExists(nil, "PROJECT-PtXi1", "Errors.Project.Member.AlreadyExists") + } - pushedEvents, err := c.eventstore.Push(ctx, event) + pushedEvents, err := c.eventstore.Push(ctx, + project.NewProjectMemberAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &addedMember.WriteModel), + member.UserID, + member.Roles..., + ), + ) if err != nil { return nil, err } @@ -31,53 +78,46 @@ func (c *Commands) AddProjectMember(ctx context.Context, member *domain.Member, return nil, err } - return memberWriteModelToMember(&addedMember.MemberWriteModel), nil + return writeModelToObjectDetails(&addedMember.WriteModel), nil } -func (c *Commands) addProjectMember(ctx context.Context, projectAgg *eventstore.Aggregate, addedMember *ProjectMemberWriteModel, member *domain.Member) (_ eventstore.Command, err error) { - ctx, span := tracing.NewSpan(ctx) - defer func() { span.EndWithError(err) }() +type ChangeProjectMember struct { + ResourceOwner string + ProjectID string + UserID string + Roles []string +} - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-W8m4l", "Errors.Project.Member.Invalid") +func (i *ChangeProjectMember) IsValid(zitadelRoles []authz.RoleMapping) error { + if i.ProjectID == "" || i.UserID == "" || len(i.Roles) == 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-LiaZi", "Errors.Project.Member.Invalid") } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9ds", "Errors.Project.Member.Invalid") + if len(domain.CheckForInvalidRoles(i.Roles, domain.ProjectRolePrefix, zitadelRoles)) > 0 { + return zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9d", "Errors.Project.Member.Invalid") } - - err = c.checkUserExists(ctx, addedMember.UserID, "") - if err != nil { - return nil, err - } - err = c.eventstore.FilterToQueryReducer(ctx, addedMember) - if err != nil { - return nil, err - } - if addedMember.State == domain.MemberStateActive { - return nil, zerrors.ThrowAlreadyExists(nil, "PROJECT-PtXi1", "Errors.Project.Member.AlreadyExists") - } - - return project.NewProjectMemberAddedEvent(ctx, projectAgg, member.UserID, member.Roles...), nil + return nil } // ChangeProjectMember updates an existing member -func (c *Commands) ChangeProjectMember(ctx context.Context, member *domain.Member, resourceOwner string) (*domain.Member, error) { - if !member.IsValid() { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-LiaZi", "Errors.Project.Member.Invalid") - } - if len(domain.CheckForInvalidRoles(member.Roles, domain.ProjectRolePrefix, c.zitadelRoles)) > 0 { - return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-3m9d", "Errors.Project.Member.Invalid") - } - - existingMember, err := c.projectMemberWriteModelByID(ctx, member.AggregateID, member.UserID, resourceOwner) - if err != nil { +func (c *Commands) ChangeProjectMember(ctx context.Context, member *ChangeProjectMember) (*domain.ObjectDetails, error) { + if err := member.IsValid(c.zitadelRoles); err != nil { return nil, err } - if reflect.DeepEqual(existingMember.Roles, member.Roles) { - return nil, zerrors.ThrowPreconditionFailed(nil, "PROJECT-LiaZi", "Errors.Project.Member.RolesNotChanged") + existingMember, err := c.projectMemberWriteModelByID(ctx, member.ProjectID, member.UserID, member.ResourceOwner) + if err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&existingMember.MemberWriteModel.WriteModel) + if !existingMember.State.Exists() { + return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.NotFound") + } + if err := c.checkPermissionUpdateProjectMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err + } + if slices.Compare(existingMember.Roles, member.Roles) == 0 { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, project.NewProjectMemberChangedEvent(ctx, projectAgg, member.UserID, member.Roles...)) if err != nil { return nil, err @@ -88,33 +128,35 @@ func (c *Commands) ChangeProjectMember(ctx context.Context, member *domain.Membe return nil, err } - return memberWriteModelToMember(&existingMember.MemberWriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) RemoveProjectMember(ctx context.Context, projectID, userID, resourceOwner string) (*domain.ObjectDetails, error) { if projectID == "" || userID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "PROJECT-66mHd", "Errors.Project.Member.Invalid") } - m, err := c.projectMemberWriteModelByID(ctx, projectID, userID, resourceOwner) - if err != nil && !zerrors.IsNotFound(err) { + existingMember, err := c.projectMemberWriteModelByID(ctx, projectID, userID, resourceOwner) + if err != nil { return nil, err } - if zerrors.IsNotFound(err) { - // empty response because we have no data that match the request - return &domain.ObjectDetails{}, nil + if !existingMember.State.Exists() { + return writeModelToObjectDetails(&existingMember.WriteModel), nil + } + if err := c.checkPermissionDeleteProjectMember(ctx, existingMember.ResourceOwner, existingMember.AggregateID); err != nil { + return nil, err } - projectAgg := ProjectAggregateFromWriteModel(&m.MemberWriteModel.WriteModel) + projectAgg := ProjectAggregateFromWriteModelWithCTX(ctx, &existingMember.WriteModel) removeEvent := c.removeProjectMember(ctx, projectAgg, userID, false) pushedEvents, err := c.eventstore.Push(ctx, removeEvent) if err != nil { return nil, err } - err = AppendAndReduce(m, pushedEvents...) + err = AppendAndReduce(existingMember, pushedEvents...) if err != nil { return nil, err } - return writeModelToObjectDetails(&m.WriteModel), nil + return writeModelToObjectDetails(&existingMember.WriteModel), nil } func (c *Commands) removeProjectMember(ctx context.Context, projectAgg *eventstore.Aggregate, userID string, cascade bool) eventstore.Command { @@ -138,9 +180,5 @@ func (c *Commands) projectMemberWriteModelByID(ctx context.Context, projectID, u return nil, err } - if writeModel.State == domain.MemberStateUnspecified || writeModel.State == domain.MemberStateRemoved { - return nil, zerrors.ThrowNotFound(nil, "PROJECT-D8JxR", "Errors.NotFound") - } - return writeModel, nil } diff --git a/internal/command/project_member_test.go b/internal/command/project_member_test.go index 88b52f63f8..1bd06e0b99 100644 --- a/internal/command/project_member_test.go +++ b/internal/command/project_member_test.go @@ -10,7 +10,6 @@ import ( "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore" - "github.com/zitadel/zitadel/internal/eventstore/v1/models" "github.com/zitadel/zitadel/internal/repository/project" "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" @@ -18,16 +17,15 @@ import ( func TestCommandSide_AddProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.Member - resourceOwner string + member *AddProjectMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -39,18 +37,14 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -59,20 +53,16 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -81,10 +71,10 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "user not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -92,15 +82,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -109,8 +96,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member already exists, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -127,6 +113,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -136,6 +135,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -143,15 +143,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -160,8 +157,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member add uniqueconstraint err, already exists", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -178,6 +174,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter(), expectPushFailed(zerrors.ThrowAlreadyExists(nil, "ERROR", "internal"), project.NewProjectMemberAddedEvent(context.Background(), @@ -187,6 +196,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -194,15 +204,12 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorAlreadyExists, @@ -211,8 +218,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { { name: "member add, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -229,6 +235,19 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), expectFilter(), expectPush( project.NewProjectMemberAddedEvent(context.Background(), @@ -238,6 +257,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -245,35 +265,81 @@ func TestCommandSide_AddProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{domain.RoleProjectOwner}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, + }, { + name: "member add, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", + false, + false, + false, + domain.PrivateLabelingSettingUnspecified, + ), + ), + ), + expectFilter(), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleProjectOwner, + }, + }, + }, + args: args{ + member: &AddProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.AddProjectMember(tt.args.ctx, tt.args.member, tt.args.resourceOwner) + got, err := r.AddProjectMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -281,7 +347,7 @@ func TestCommandSide_AddProjectMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -289,16 +355,15 @@ func TestCommandSide_AddProjectMember(t *testing.T) { func TestCommandSide_ChangeProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - zitadelRoles []authz.RoleMapping + eventstore func(t *testing.T) *eventstore.Eventstore + zitadelRoles []authz.RoleMapping + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context - member *domain.Member - resourceOwner string + member *ChangeProjectMember } type res struct { - want *domain.Member + want *domain.ObjectDetails err func(error) bool } tests := []struct { @@ -310,18 +375,14 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "invalid member, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -330,20 +391,16 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "invalid roles, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -352,10 +409,10 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "member not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -363,15 +420,12 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -380,8 +434,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { { name: "member not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -392,6 +445,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -399,25 +453,23 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER"}, }, - resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member change, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -435,6 +487,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), zitadelRoles: []authz.RoleMapping{ { Role: domain.RoleProjectOwner, @@ -445,35 +498,64 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { }, }, args: args{ - ctx: context.Background(), - member: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, }, - resourceOwner: "org1", }, res: res{ - want: &domain.Member{ - ObjectRoot: models.ObjectRoot{ - ResourceOwner: "org1", - AggregateID: "project1", - }, - UserID: "user1", - Roles: []string{domain.RoleProjectOwner, "PROJECT_VIEWER"}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", }, }, }, + { + name: "member change, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + zitadelRoles: []authz.RoleMapping{ + { + Role: domain.RoleProjectOwner, + }, + { + Role: "PROJECT_VIEWER", + }, + }, + }, + args: args{ + member: &ChangeProjectMember{ + ResourceOwner: "org1", + ProjectID: "project1", + UserID: "user1", + Roles: []string{"PROJECT_OWNER", "PROJECT_VIEWER"}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, - zitadelRoles: tt.fields.zitadelRoles, + eventstore: tt.fields.eventstore(t), + zitadelRoles: tt.fields.zitadelRoles, + checkPermission: tt.fields.checkPermission, } - got, err := r.ChangeProjectMember(tt.args.ctx, tt.args.member, tt.args.resourceOwner) + got, err := r.ChangeProjectMember(context.Background(), tt.args.member) if tt.res.err == nil { assert.NoError(t, err) } @@ -481,7 +563,7 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { t.Errorf("got wrong err: %v ", err) } if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) + assertObjectDetails(t, tt.res.want, got) } }) } @@ -489,10 +571,10 @@ func TestCommandSide_ChangeProjectMember(t *testing.T) { func TestCommandSide_RemoveProjectMember(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type args struct { - ctx context.Context projectID string userID string resourceOwner string @@ -510,12 +592,10 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "invalid member projectid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "", userID: "user1", resourceOwner: "org1", @@ -527,12 +607,10 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "invalid member userid missing, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "", resourceOwner: "org1", @@ -544,26 +622,26 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { { name: "member not existing, empty object details result", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "user1", resourceOwner: "org1", }, res: res{ - want: &domain.ObjectDetails{}, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { name: "member remove, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( project.NewProjectMemberAddedEvent(context.Background(), @@ -580,9 +658,9 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ - ctx: context.Background(), projectID: "project1", userID: "user1", resourceOwner: "org1", @@ -593,13 +671,39 @@ func TestCommandSide_RemoveProjectMember(t *testing.T) { }, }, }, + { + name: "member remove, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + project.NewProjectMemberAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", + []string{"PROJECT_OWNER"}..., + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + projectID: "project1", + userID: "user1", + resourceOwner: "org1", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } - got, err := r.RemoveProjectMember(tt.args.ctx, tt.args.projectID, tt.args.userID, tt.args.resourceOwner) + got, err := r.RemoveProjectMember(context.Background(), tt.args.projectID, tt.args.userID, tt.args.resourceOwner) if tt.res.err == nil { assert.NoError(t, err) } diff --git a/internal/command/user.go b/internal/command/user.go index 0db4fda328..d834169f8a 100644 --- a/internal/command/user.go +++ b/internal/command/user.go @@ -194,7 +194,7 @@ func (c *Commands) RemoveUser(ctx context.Context, userID, resourceOwner string, events = append(events, user.NewUserRemovedEvent(ctx, userAgg, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) for _, grantID := range cascadingGrantIDs { - removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true) + removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, false, nil) if err != nil { logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove role on user grant") continue @@ -327,18 +327,18 @@ func (c *Commands) UserDomainClaimedSent(ctx context.Context, orgID, userID stri return err } -func (c *Commands) checkUserExists(ctx context.Context, userID, resourceOwner string) (err error) { +func (c *Commands) checkUserExists(ctx context.Context, userID, resourceOwner string) (_ string, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() existingUser, err := c.userWriteModelByID(ctx, userID, resourceOwner) if err != nil { - return err + return "", err } if !isUserStateExists(existingUser.UserState) { - return zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound") + return "", zerrors.ThrowPreconditionFailed(nil, "COMMAND-uXHNj", "Errors.User.NotFound") } - return nil + return existingUser.ResourceOwner, nil } func (c *Commands) userWriteModelByID(ctx context.Context, userID, resourceOwner string) (writeModel *UserWriteModel, err error) { diff --git a/internal/command/user_grant.go b/internal/command/user_grant.go index 6bb4a20b0a..2b0ff4cf18 100644 --- a/internal/command/user_grant.go +++ b/internal/command/user_grant.go @@ -2,7 +2,7 @@ package command import ( "context" - "reflect" + "slices" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" @@ -15,11 +15,14 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) -func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (_ *domain.UserGrant, err error) { +// AddUserGrant authorizes a user for a project with the given role keys. +// The project must be owned by or granted to the resourceOwner. +// If the resourceOwner is nil, the project must be owned by the project that belongs to usergrant.ProjectID. +func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (_ *domain.UserGrant, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - event, addedUserGrant, err := c.addUserGrant(ctx, usergrant, resourceOwner) + event, addedUserGrant, err := c.addUserGrant(ctx, usergrant, check) if err != nil { return nil, err } @@ -35,11 +38,11 @@ func (c *Commands) AddUserGrant(ctx context.Context, usergrant *domain.UserGrant return userGrantWriteModelToUserGrant(addedUserGrant), nil } -func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (command eventstore.Command, _ *UserGrantWriteModel, err error) { +func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant, check UserGrantPermissionCheck) (command eventstore.Command, _ *UserGrantWriteModel, err error) { if !userGrant.IsValid() { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-kVfMa", "Errors.UserGrant.Invalid") } - err = c.checkUserGrantPreCondition(ctx, userGrant, resourceOwner) + err = c.checkUserGrantPreCondition(ctx, userGrant, check) if err != nil { return nil, nil, err } @@ -48,7 +51,7 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant return nil, nil, err } - addedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, resourceOwner) + addedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, userGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&addedUserGrant.WriteModel) command = usergrant.NewUserGrantAddedEvent( ctx, @@ -61,54 +64,51 @@ func (c *Commands) addUserGrant(ctx context.Context, userGrant *domain.UserGrant return command, addedUserGrant, nil } -func (c *Commands) ChangeUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (_ *domain.UserGrant, err error) { - event, changedUserGrant, err := c.changeUserGrant(ctx, userGrant, resourceOwner, false) +func (c *Commands) ChangeUserGrant(ctx context.Context, userGrant *domain.UserGrant, cascade, ignoreUnchanged bool, check UserGrantPermissionCheck) (_ *domain.UserGrant, err error) { + if userGrant.AggregateID == "" { + return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-3M0sd", "Errors.UserGrant.Invalid") + } + existingUserGrant, err := c.userGrantWriteModelByID(ctx, userGrant.AggregateID, "") if err != nil { return nil, err } + if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { + return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") + } + + grantUnchanged := slices.Equal(existingUserGrant.RoleKeys, userGrant.RoleKeys) + if grantUnchanged { + if ignoreUnchanged { + return userGrantWriteModelToUserGrant(existingUserGrant), nil + } + return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Rs8fy", "Errors.UserGrant.NotChanged") + } + userGrant.UserID = existingUserGrant.UserID + userGrant.ProjectID = existingUserGrant.ProjectID + userGrant.ProjectGrantID = existingUserGrant.ProjectGrantID + userGrant.ResourceOwner = existingUserGrant.ResourceOwner + + err = c.checkUserGrantPreCondition(ctx, userGrant, check) + if err != nil { + return nil, err + } + + changedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, userGrant.ResourceOwner) + userGrantAgg := UserGrantAggregateFromWriteModel(&changedUserGrant.WriteModel) + + var event eventstore.Command = usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, userGrant.RoleKeys) + if cascade { + event = usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys) + } pushedEvents, err := c.eventstore.Push(ctx, event) if err != nil { return nil, err } - err = AppendAndReduce(changedUserGrant, pushedEvents...) + err = AppendAndReduce(existingUserGrant, pushedEvents...) if err != nil { return nil, err } - return userGrantWriteModelToUserGrant(changedUserGrant), nil -} - -func (c *Commands) changeUserGrant(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string, cascade bool) (_ eventstore.Command, _ *UserGrantWriteModel, err error) { - if userGrant.AggregateID == "" { - return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-3M0sd", "Errors.UserGrant.Invalid") - } - existingUserGrant, err := c.userGrantWriteModelByID(ctx, userGrant.AggregateID, userGrant.ResourceOwner) - if err != nil { - return nil, nil, err - } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) - if err != nil { - return nil, nil, err - } - if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { - return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") - } - if reflect.DeepEqual(existingUserGrant.RoleKeys, userGrant.RoleKeys) { - return nil, nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-Rs8fy", "Errors.UserGrant.NotChanged") - } - userGrant.ProjectID = existingUserGrant.ProjectID - userGrant.ProjectGrantID = existingUserGrant.ProjectGrantID - err = c.checkUserGrantPreCondition(ctx, userGrant, resourceOwner) - if err != nil { - return nil, nil, err - } - - changedUserGrant := NewUserGrantWriteModel(userGrant.AggregateID, resourceOwner) - userGrantAgg := UserGrantAggregateFromWriteModel(&changedUserGrant.WriteModel) - - if cascade { - return usergrant.NewUserGrantCascadeChangedEvent(ctx, userGrantAgg, userGrant.RoleKeys), existingUserGrant, nil - } - return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, userGrant.RoleKeys), existingUserGrant, nil + return userGrantWriteModelToUserGrant(existingUserGrant), nil } func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID string, roleKeys []string, cascade bool) (_ eventstore.Command, err error) { @@ -144,8 +144,8 @@ func (c *Commands) removeRoleFromUserGrant(ctx context.Context, userGrantID stri return usergrant.NewUserGrantChangedEvent(ctx, userGrantAgg, existingUserGrant.UserID, existingUserGrant.RoleKeys), nil } -func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - if grantID == "" || resourceOwner == "" { +func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID string, resourceOwner string, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + if grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-N2OhG", "Errors.UserGrant.IDMissing") } @@ -157,14 +157,17 @@ func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwn return nil, zerrors.ThrowNotFound(nil, "COMMAND-3M9sd", "Errors.UserGrant.NotFound") } if existingUserGrant.State != domain.UserGrantStateActive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1S9gx", "Errors.UserGrant.NotActive") + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } + if check != nil { + err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, "") + } else { + err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, err } - - deactivateUserGrant := NewUserGrantWriteModel(grantID, resourceOwner) + deactivateUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&deactivateUserGrant.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, usergrant.NewUserGrantDeactivatedEvent(ctx, userGrantAgg)) if err != nil { @@ -177,8 +180,8 @@ func (c *Commands) DeactivateUserGrant(ctx context.Context, grantID, resourceOwn return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil } -func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - if grantID == "" || resourceOwner == "" { +func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID string, resourceOwner string, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + if grantID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-Qxy8v", "Errors.UserGrant.IDMissing") } @@ -190,13 +193,17 @@ func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwn return nil, zerrors.ThrowNotFound(nil, "COMMAND-Lp0gs", "Errors.UserGrant.NotFound") } if existingUserGrant.State != domain.UserGrantStateInactive { - return nil, zerrors.ThrowPreconditionFailed(nil, "COMMAND-1ML0v", "Errors.UserGrant.NotInactive") + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } + if check != nil { + err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, "") + } else { + err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) } - err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, err } - deactivateUserGrant := NewUserGrantWriteModel(grantID, resourceOwner) + deactivateUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&deactivateUserGrant.WriteModel) pushedEvents, err := c.eventstore.Push(ctx, usergrant.NewUserGrantReactivatedEvent(ctx, userGrantAgg)) if err != nil { @@ -209,12 +216,14 @@ func (c *Commands) ReactivateUserGrant(ctx context.Context, grantID, resourceOwn return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil } -func (c *Commands) RemoveUserGrant(ctx context.Context, grantID, resourceOwner string) (objectDetails *domain.ObjectDetails, err error) { - event, existingUserGrant, err := c.removeUserGrant(ctx, grantID, resourceOwner, false) +func (c *Commands) RemoveUserGrant(ctx context.Context, grantID string, resourceOwner string, ignoreNotFound bool, check UserGrantPermissionCheck) (objectDetails *domain.ObjectDetails, err error) { + event, existingUserGrant, err := c.removeUserGrant(ctx, grantID, resourceOwner, false, ignoreNotFound, check) if err != nil { return nil, err } - + if event == nil { + return writeModelToObjectDetails(&existingUserGrant.WriteModel), nil + } pushedEvents, err := c.eventstore.Push(ctx, event) if err != nil { return nil, err @@ -232,7 +241,7 @@ func (c *Commands) BulkRemoveUserGrant(ctx context.Context, grantIDs []string, r } events := make([]eventstore.Command, len(grantIDs)) for i, grantID := range grantIDs { - event, _, err := c.removeUserGrant(ctx, grantID, resourceOwner, false) + event, _, err := c.removeUserGrant(ctx, grantID, resourceOwner, false, false, nil) if err != nil { return err } @@ -242,7 +251,7 @@ func (c *Commands) BulkRemoveUserGrant(ctx context.Context, grantIDs []string, r return err } -func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner string, cascade bool) (_ eventstore.Command, writeModel *UserGrantWriteModel, err error) { +func (c *Commands) removeUserGrant(ctx context.Context, grantID string, resourceOwner string, cascade, ignoreNotFound bool, check UserGrantPermissionCheck) (_ eventstore.Command, writeModel *UserGrantWriteModel, err error) { if grantID == "" { return nil, nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-J9sc5", "Errors.UserGrant.IDMissing") } @@ -252,15 +261,22 @@ func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner s return nil, nil, err } if existingUserGrant.State == domain.UserGrantStateUnspecified || existingUserGrant.State == domain.UserGrantStateRemoved { + if ignoreNotFound { + return nil, existingUserGrant, nil + } return nil, nil, zerrors.ThrowNotFound(nil, "COMMAND-1My0t", "Errors.UserGrant.NotFound") } - if !cascade { + if !cascade && check == nil { err = checkExplicitProjectPermission(ctx, existingUserGrant.ProjectGrantID, existingUserGrant.ProjectID) if err != nil { return nil, nil, err } } - + if check != nil { + if err = check(existingUserGrant.ProjectID, existingUserGrant.ProjectGrantID)(existingUserGrant.ResourceOwner, ""); err != nil { + return nil, nil, err + } + } removeUserGrant := NewUserGrantWriteModel(grantID, existingUserGrant.ResourceOwner) userGrantAgg := UserGrantAggregateFromWriteModel(&removeUserGrant.WriteModel) if cascade { @@ -279,7 +295,7 @@ func (c *Commands) removeUserGrant(ctx context.Context, grantID, resourceOwner s existingUserGrant.ProjectGrantID), existingUserGrant, nil } -func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID, resourceOwner string) (writeModel *UserGrantWriteModel, err error) { +func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID string, resourceOwner string) (writeModel *UserGrantWriteModel, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -291,31 +307,46 @@ func (c *Commands) userGrantWriteModelByID(ctx context.Context, userGrantID, res return writeModel, nil } -func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (err error) { +func (c *Commands) checkUserGrantPreCondition(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { if !authz.GetFeatures(ctx).ShouldUseImprovedPerformance(feature.ImprovedPerformanceTypeUserGrant) { - return c.checkUserGrantPreConditionOld(ctx, usergrant, resourceOwner) + return c.checkUserGrantPreConditionOld(ctx, usergrant, check) } ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - if err := c.checkUserExists(ctx, usergrant.UserID, ""); err != nil { + if _, err := c.checkUserExists(ctx, usergrant.UserID, ""); err != nil { return err } - existingRoleKeys, err := c.searchUserGrantPreConditionState(ctx, usergrant, resourceOwner) + if usergrant.ProjectGrantID != "" || usergrant.ResourceOwner == "" { + projectOwner, grantID, err := c.searchProjectOwnerAndGrantID(ctx, usergrant.ProjectID, "") + if err != nil { + return err + } + if usergrant.ResourceOwner == "" { + usergrant.ResourceOwner = projectOwner + } + if usergrant.ProjectGrantID == "" { + usergrant.ProjectGrantID = grantID + } + } + existingRoleKeys, err := c.searchUserGrantPreConditionState(ctx, usergrant) if err != nil { return err } if usergrant.HasInvalidRoles(existingRoleKeys) { return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } - return nil + if check != nil { + return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + } + return checkExplicitProjectPermission(ctx, usergrant.ProjectGrantID, usergrant.ProjectID) } // this code needs to be rewritten anyways as soon as we improved the fields handling // //nolint:gocognit -func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGrant *domain.UserGrant, resourceOwner string) (existingRoleKeys []string, err error) { +func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGrant *domain.UserGrant) (existingRoleKeys []string, err error) { criteria := []map[eventstore.FieldType]any{ // project state query { @@ -327,7 +358,7 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra // granted org query { eventstore.FieldTypeAggregateType: org.AggregateType, - eventstore.FieldTypeAggregateID: resourceOwner, + eventstore.FieldTypeAggregateID: userGrant.ResourceOwner, eventstore.FieldTypeFieldName: org.OrgStateSearchField, eventstore.FieldTypeObjectType: org.OrgSearchType, }, @@ -386,7 +417,7 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra case project.ProjectGrantGrantedOrgIDSearchField: var orgID string err := result.Value.Unmarshal(&orgID) - if err != nil || orgID != resourceOwner { + if err != nil || orgID != userGrant.ResourceOwner { return nil, zerrors.ThrowPreconditionFailed(err, "COMMAND-3m9gg", "Errors.Org.NotFound") } case project.ProjectGrantStateSearchField: @@ -425,26 +456,63 @@ func (c *Commands) searchUserGrantPreConditionState(ctx context.Context, userGra return existingRoleKeys, nil } -func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, resourceOwner string) (err error) { +func (c *Commands) checkUserGrantPreConditionOld(ctx context.Context, usergrant *domain.UserGrant, check UserGrantPermissionCheck) (err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - preConditions := NewUserGrantPreConditionReadModel(usergrant.UserID, usergrant.ProjectID, usergrant.ProjectGrantID, resourceOwner) + preConditions := NewUserGrantPreConditionReadModel(usergrant.UserID, usergrant.ProjectID, usergrant.ProjectGrantID, usergrant.ResourceOwner) err = c.eventstore.FilterToQueryReducer(ctx, preConditions) if err != nil { return err } + if usergrant.ResourceOwner == "" { + usergrant.ResourceOwner = preConditions.ProjectResourceOwner + } + if usergrant.ProjectGrantID == "" { + usergrant.ProjectGrantID = preConditions.ProjectGrantID + } if !preConditions.UserExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-4f8sg", "Errors.User.NotFound") } - if usergrant.ProjectGrantID == "" && !preConditions.ProjectExists { + projectIsOwned := usergrant.ResourceOwner == "" || usergrant.ResourceOwner == preConditions.ProjectResourceOwner + if projectIsOwned && !preConditions.ProjectExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-3n77S", "Errors.Project.NotFound") } - if usergrant.ProjectGrantID != "" && !preConditions.ProjectGrantExists { + if !projectIsOwned && !preConditions.ProjectGrantExists { return zerrors.ThrowPreconditionFailed(err, "COMMAND-4m9ff", "Errors.Project.Grant.NotFound") } if usergrant.HasInvalidRoles(preConditions.ExistingRoleKeys) { return zerrors.ThrowPreconditionFailed(err, "COMMAND-mm9F4", "Errors.Project.Role.NotFound") } - return nil + if check != nil { + return check(usergrant.ProjectID, usergrant.ProjectGrantID)(usergrant.ResourceOwner, "") + } + return checkExplicitProjectPermission(ctx, usergrant.ProjectGrantID, usergrant.ProjectID) +} + +func (c *Commands) searchProjectOwnerAndGrantID(ctx context.Context, projectID string, grantedOrgID string) (projectOwner string, grantID string, err error) { + grantIDQuery := map[eventstore.FieldType]any{ + eventstore.FieldTypeAggregateType: project.AggregateType, + eventstore.FieldTypeAggregateID: projectID, + eventstore.FieldTypeFieldName: project.ProjectGrantGrantedOrgIDSearchField, + } + if grantedOrgID != "" { + grantIDQuery[eventstore.FieldTypeValue] = grantedOrgID + grantIDQuery[eventstore.FieldTypeObjectType] = project.ProjectGrantSearchType + + } + results, err := c.eventstore.Search(ctx, grantIDQuery) + if err != nil { + return "", "", err + } + for _, result := range results { + projectOwner = result.Aggregate.ResourceOwner + if grantedOrgID != "" && grantedOrgID == projectOwner { + return projectOwner, "", nil + } + if result.Object.Type == project.ProjectGrantSearchType { + return projectOwner, result.Object.ID, nil + } + } + return projectOwner, grantID, err } diff --git a/internal/command/user_grant_model.go b/internal/command/user_grant_model.go index b2490177d9..b35c96a2d5 100644 --- a/internal/command/user_grant_model.go +++ b/internal/command/user_grant_model.go @@ -36,6 +36,7 @@ func (wm *UserGrantWriteModel) Reduce() error { wm.ProjectGrantID = e.ProjectGrantID wm.RoleKeys = e.RoleKeys wm.State = domain.UserGrantStateActive + wm.ResourceOwner = e.Aggregate().ResourceOwner case *usergrant.UserGrantChangedEvent: wm.RoleKeys = e.RoleKeys case *usergrant.UserGrantCascadeChangedEvent: @@ -86,17 +87,18 @@ func UserGrantAggregateFromWriteModel(wm *eventstore.WriteModel) *eventstore.Agg type UserGrantPreConditionReadModel struct { eventstore.WriteModel - UserID string - ProjectID string - ProjectGrantID string - ResourceOwner string - UserExists bool - ProjectExists bool - ProjectGrantExists bool - ExistingRoleKeys []string + UserID string + ProjectID string + ProjectResourceOwner string + ProjectGrantID string + ResourceOwner string + UserExists bool + ProjectExists bool + ProjectGrantExists bool + ExistingRoleKeys []string } -func NewUserGrantPreConditionReadModel(userID, projectID, projectGrantID, resourceOwner string) *UserGrantPreConditionReadModel { +func NewUserGrantPreConditionReadModel(userID, projectID, projectGrantID string, resourceOwner string) *UserGrantPreConditionReadModel { return &UserGrantPreConditionReadModel{ UserID: userID, ProjectID: projectID, @@ -117,15 +119,19 @@ func (wm *UserGrantPreConditionReadModel) Reduce() error { case *user.UserRemovedEvent: wm.UserExists = false case *project.ProjectAddedEvent: - if wm.ProjectGrantID == "" && wm.ResourceOwner == e.Aggregate().ResourceOwner { + if wm.ResourceOwner == "" || wm.ResourceOwner == e.Aggregate().ResourceOwner { wm.ProjectExists = true } + wm.ProjectResourceOwner = e.Aggregate().ResourceOwner case *project.ProjectRemovedEvent: wm.ProjectExists = false case *project.GrantAddedEvent: - if wm.ProjectGrantID == e.GrantID && wm.ResourceOwner == e.GrantedOrgID { + if (wm.ProjectGrantID == e.GrantID || wm.ProjectGrantID == "") && wm.ResourceOwner != "" && wm.ResourceOwner == e.GrantedOrgID { wm.ProjectGrantExists = true wm.ExistingRoleKeys = e.RoleKeys + if wm.ProjectGrantID == "" { + wm.ProjectGrantID = e.GrantID + } } case *project.GrantChangedEvent: if wm.ProjectGrantID == e.GrantID { diff --git a/internal/command/user_grant_test.go b/internal/command/user_grant_test.go index dec5903fe8..b12e190d82 100644 --- a/internal/command/user_grant_test.go +++ b/internal/command/user_grant_test.go @@ -2,6 +2,7 @@ package command import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -19,15 +20,27 @@ import ( "github.com/zitadel/zitadel/internal/zerrors" ) +var ( + errMockedPermissionCheck = errors.New("mocked permission check error") + isMockedPermissionCheckErr = func(err error) bool { + return errors.Is(err, errMockedPermissionCheck) + } + succeedingUserGrantPermissionCheck = func(_, _ string) PermissionCheck { + return func(_, _ string) error { return nil } + } + failingUserGrantPermissionCheck = func(_, _ string) PermissionCheck { + return func(_, _ string) error { return errMockedPermissionCheck } + } +) + func TestCommandSide_AddUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore - idGenerator id.Generator + eventstore func(t *testing.T) *eventstore.Eventstore + idGenerator func(t *testing.T) id.Generator } type args struct { - ctx context.Context - userGrant *domain.UserGrant - resourceOwner string + ctx context.Context + userGrant *domain.UserGrant } type res struct { want *domain.UserGrant @@ -42,16 +55,16 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "invalid usergrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ UserID: "user1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -60,8 +73,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "user removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -94,8 +106,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -104,8 +118,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -143,8 +156,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -153,8 +168,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project on other org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -185,8 +199,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { userGrant: &domain.UserGrant{ UserID: "user1", ProjectID: "project1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org2", + }, }, - resourceOwner: "org2", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -195,8 +211,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -228,8 +243,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { UserID: "user1", ProjectID: "project1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -238,8 +255,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -272,8 +288,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -282,8 +300,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -332,8 +349,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -342,8 +361,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "project grant on other org, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -392,8 +410,10 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org2", + }, }, - resourceOwner: "org2", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -402,8 +422,7 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "usergrant for project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -445,7 +464,82 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "usergrant1"), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant without resource owner on project, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", + []string{"rolekey1"}, + ), + ), + ), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, }, args: args{ ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), @@ -454,7 +548,6 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -472,8 +565,90 @@ func TestCommandSide_AddUserGrant(t *testing.T) { { name: "usergrant for projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewGrantAddedEvent(context.Background(), + &project.NewAggregate("project1", "org2").Aggregate, + "projectgrant1", + "org1", + []string{"rolekey1"}, + ), + ), + ), + expectPush( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + []string{"rolekey1"}, + ), + ), + ), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + ProjectGrantID: "projectgrant1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + ProjectGrantID: "projectgrant1", + RoleKeys: []string{"rolekey1"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for granted resource owner, ok", + fields: fields{ + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -523,17 +698,20 @@ func TestCommandSide_AddUserGrant(t *testing.T) { ), ), ), - idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "usergrant1"), + idGenerator: func(t *testing.T) id.Generator { + return id_mock.NewIDGeneratorExpectIDs(t, "usergrant1") + }, }, args: args{ ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ - UserID: "user1", - ProjectID: "project1", - ProjectGrantID: "projectgrant1", - RoleKeys: []string{"rolekey1"}, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -550,34 +728,111 @@ func TestCommandSide_AddUserGrant(t *testing.T) { }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := &Commands{ - eventstore: tt.fields.eventstore, - idGenerator: tt.fields.idGenerator, - } - got, err := r.AddUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.resourceOwner) - if tt.res.err == nil { - assert.NoError(t, err) - } - if tt.res.err != nil && !tt.res.err(err) { - t.Errorf("got wrong err: %v ", err) - } - if tt.res.err == nil { - assert.Equal(t, tt.res.want, got) - } - }) - } + t.Run("without permission check", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + if tt.fields.idGenerator != nil { + r.idGenerator = tt.fields.idGenerator(t) + } + got, err := r.AddUserGrant(tt.args.ctx, tt.args.userGrant, nil) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } + }) + t.Run("with succeeding permission check", func(t *testing.T) { + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore(t), + } + if tt.fields.idGenerator != nil { + r.idGenerator = tt.fields.idGenerator(t) + } + // we use an empty context and only rely on the permission check implementation + got, err := r.AddUserGrant(context.Background(), tt.args.userGrant, succeedingUserGrantPermissionCheck) + if tt.res.err == nil { + assert.NoError(t, err) + } + if tt.res.err != nil && !tt.res.err(err) { + t.Errorf("got wrong err: %v ", err) + } + if tt.res.err == nil { + assert.Equal(t, tt.res.want, got) + } + }) + } + }) + t.Run("with failing permission check", func(t *testing.T) { + r := &Commands{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + ), + ), + } + // we use an empty context and only rely on the permission check implementation + _, err := r.AddUserGrant(context.Background(), &domain.UserGrant{ + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1"}, + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + }, failingUserGrantPermissionCheck) + assert.ErrorIs(t, err, errMockedPermissionCheck) + }) } func TestCommandSide_ChangeUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - userGrant *domain.UserGrant - resourceOwner string + ctx context.Context + userGrant *domain.UserGrant + permissionCheck UserGrantPermissionCheck + cascade bool + ignoreUnchanged bool } type res struct { want *domain.UserGrant @@ -592,16 +847,16 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "invalid usergrant, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ UserID: "user1", + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsErrorInvalidArgument, @@ -610,28 +865,66 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "invalid permissions, error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), - &usergrant.NewAggregate("usergrant1", "org").Aggregate, + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, "user1", "project1", "", []string{"rolekey1"}), ), ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), ), }, args: args{ ctx: context.Background(), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPermissionDenied, @@ -640,8 +933,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -649,13 +941,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -664,8 +956,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -673,13 +964,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsNotFound, @@ -688,8 +979,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant roles not changed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -705,13 +995,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -720,8 +1010,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "user removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -762,12 +1051,12 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -776,8 +1065,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project removed, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -823,12 +1111,12 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -837,8 +1125,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -877,13 +1164,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -892,8 +1179,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project grant not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -932,14 +1218,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -948,8 +1234,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "project grant roles not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1004,14 +1289,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "org", "user", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"roleKey"}, }, - resourceOwner: "org1", }, res: res{ err: zerrors.IsPreconditionFailed, @@ -1020,8 +1305,7 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { { name: "usergrant for project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1083,13 +1367,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", RoleKeys: []string{"rolekey1", "rolekey2"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -1104,11 +1388,94 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { }, }, }, + { + name: "usergrant for project cascade, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantCascadeChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + cascade: true, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + State: domain.UserGrantStateActive, + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + }, + }, { name: "usergrant for projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1178,14 +1545,14 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrant: &domain.UserGrant{ ObjectRoot: models.ObjectRoot{ - AggregateID: "usergrant1", + AggregateID: "usergrant1", + ResourceOwner: "org1", }, UserID: "user1", ProjectID: "project1", ProjectGrantID: "projectgrant1", RoleKeys: []string{"rolekey1", "rolekey2"}, }, - resourceOwner: "org1", }, res: res{ want: &domain.UserGrant{ @@ -1201,13 +1568,307 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { }, }, }, + { + name: "usergrant for project without resource owner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for project with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + expectPush( + usergrant.NewUserGrantChangedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + []string{"rolekey1", "rolekey2"}, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + permissionCheck: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, + { + name: "usergrant for project with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + eventFromEventPusher( + project.NewProjectAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "projectname1", true, true, true, + domain.PrivateLabelingSettingUnspecified, + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey1", + "rolekey", + "", + ), + ), + eventFromEventPusher( + project.NewRoleAddedEvent(context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "rolekey2", + "rolekey 2", + "", + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + permissionCheck: failingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + err: isMockedPermissionCheckErr, + }, + }, + { + name: "usergrant roles not changed, ignore unchanged, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1", "rolekey2"}), + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrant: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + }, + ignoreUnchanged: true, + }, + res: res{ + want: &domain.UserGrant{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "usergrant1", + ResourceOwner: "org1", + }, + UserID: "user1", + ProjectID: "project1", + RoleKeys: []string{"rolekey1", "rolekey2"}, + State: domain.UserGrantStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ChangeUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.resourceOwner) + got, err := r.ChangeUserGrant(tt.args.ctx, tt.args.userGrant, tt.args.cascade, tt.args.ignoreUnchanged, tt.args.permissionCheck) if tt.res.err == nil { assert.NoError(t, err) } @@ -1223,12 +1884,13 @@ func TestCommandSide_ChangeUserGrant(t *testing.T) { func TestCommandSide_DeactivateUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userGrantID string resourceOwner string + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1243,12 +1905,10 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1256,25 +1916,39 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "invalid resourceOwner, error", + name: "not provided resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + ), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1290,8 +1964,7 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1320,14 +1993,13 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), - &usergrant.NewAggregate("usergrant1", "org").Aggregate, + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, "user1", "project1", "", []string{"rolekey1"}), @@ -1345,10 +2017,9 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, { - name: "already deactivated, precondition error", + name: "already deactivated, ignore, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1370,14 +2041,15 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "deactivated, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1405,13 +2077,70 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.DeactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.DeactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1427,12 +2156,13 @@ func TestCommandSide_DeactivateUserGrant(t *testing.T) { func TestCommandSide_ReactivateUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { ctx context.Context userGrantID string resourceOwner string + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1447,12 +2177,10 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1460,25 +2188,43 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "invalid resourceOwner, error", + name: "not provided resourceOwner, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + expectPush( + usergrant.NewUserGrantReactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate, + ), + ), ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", }, res: res{ - err: zerrors.IsErrorInvalidArgument, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1494,8 +2240,7 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1524,10 +2269,9 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1553,10 +2297,9 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, { - name: "already active, precondition error", + name: "already active, ignore, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1569,19 +2312,20 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { ), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), userGrantID: "usergrant1", resourceOwner: "org1", }, res: res{ - err: zerrors.IsPreconditionFailed, + want: &domain.ObjectDetails{ + ResourceOwner: "org", + }, }, }, { name: "reactivated, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1613,13 +2357,78 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + expectPush( + usergrant.NewUserGrantReactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + eventFromEventPusher( + usergrant.NewUserGrantDeactivatedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org").Aggregate), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.ReactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.ReactivateUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1635,12 +2444,14 @@ func TestCommandSide_ReactivateUserGrant(t *testing.T) { func TestCommandSide_RemoveUserGrant(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore } type args struct { - ctx context.Context - userGrantID string - resourceOwner string + ctx context.Context + userGrantID string + resourceOwner string + ignoreNotFound bool + check UserGrantPermissionCheck } type res struct { want *domain.ObjectDetails @@ -1655,12 +2466,10 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "invalid usergrantID, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ - ctx: context.Background(), + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), resourceOwner: "org1", }, res: res{ @@ -1670,8 +2479,7 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "usergrant not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -1684,11 +2492,29 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { err: zerrors.IsNotFound, }, }, + { + name: "usergrant not existing, ignore, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrantID: "usergrant1", + resourceOwner: "org1", + ignoreNotFound: true, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "usergrant removed, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1717,10 +2543,9 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, { - name: "no permissions, permisison denied error", + name: "no permissions, permission denied error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1748,8 +2573,7 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { { name: "remove usergrant project, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1780,11 +2604,43 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, }, + { + name: "not provided resourceOwner, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantRemovedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "", + ), + ), + ), + }, + args: args{ + ctx: authz.NewMockContextWithPermissions("", "", "", []string{domain.RoleProjectOwner}), + userGrantID: "usergrant1", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "remove usergrant projectgrant, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( usergrant.NewUserGrantAddedEvent(context.Background(), @@ -1815,13 +2671,73 @@ func TestCommandSide_RemoveUserGrant(t *testing.T) { }, }, }, + { + name: "with passed succeeding permission check, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", []string{"rolekey1"}), + ), + ), + expectPush( + usergrant.NewUserGrantRemovedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: succeedingUserGrantPermissionCheck, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "with passed failing permission check, error", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + usergrant.NewUserGrantAddedEvent(context.Background(), + &usergrant.NewAggregate("usergrant1", "org1").Aggregate, + "user1", + "project1", + "projectgrant1", []string{"rolekey1"}), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + userGrantID: "usergrant1", + resourceOwner: "org1", + check: failingUserGrantPermissionCheck, + }, + res: res{ + err: isMockedPermissionCheckErr, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), } - got, err := r.RemoveUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner) + got, err := r.RemoveUserGrant(tt.args.ctx, tt.args.userGrantID, tt.args.resourceOwner, tt.args.ignoreNotFound, tt.args.check) if tt.res.err == nil { assert.NoError(t, err) } @@ -1857,9 +2773,7 @@ func TestCommandSide_BulkRemoveUserGrant(t *testing.T) { { name: "empty usergrantid list, error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: eventstoreExpect(t), }, args: args{ ctx: context.Background(), diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 97596aabd8..fca762cbab 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -26,7 +26,7 @@ func (c *Commands) ImportHumanTOTP(ctx context.Context, userID, userAgentID, res if err != nil { return err } - if err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { + if _, err = c.checkUserExists(ctx, userID, resourceOwner); err != nil { return err } diff --git a/internal/command/user_idp_link.go b/internal/command/user_idp_link.go index 432d2e0b90..e3484c9753 100644 --- a/internal/command/user_idp_link.go +++ b/internal/command/user_idp_link.go @@ -56,7 +56,7 @@ func (c *Commands) BulkAddedUserIDPLinks(ctx context.Context, userID, resourceOw return zerrors.ThrowInvalidArgument(nil, "COMMAND-Ek9s", "Errors.User.ExternalIDP.MinimumExternalIDPNeeded") } - if err := c.checkUserExists(ctx, userID, resourceOwner); err != nil { + if _, err := c.checkUserExists(ctx, userID, resourceOwner); err != nil { return err } diff --git a/internal/command/user_metadata.go b/internal/command/user_metadata.go index d47c5b61d0..294866e23b 100644 --- a/internal/command/user_metadata.go +++ b/internal/command/user_metadata.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "context" "github.com/zitadel/zitadel/internal/domain" @@ -14,12 +15,25 @@ func (c *Commands) SetUserMetadata(ctx context.Context, metadata *domain.Metadat ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) + if err != nil { + return nil, err + } + + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + setMetadata, err := c.getUserMetadataModelByID(ctx, userID, userResourceOwner, metadata.Key) if err != nil { return nil, err } - setMetadata := NewUserMetadataWriteModel(userID, resourceOwner, metadata.Key) userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel) + // return if no change in the metadata + if bytes.Equal(setMetadata.Value, metadata.Value) { + return writeModelToUserMetadata(setMetadata), nil + } + event, err := c.setUserMetadata(ctx, userAgg, metadata) if err != nil { return nil, err @@ -40,20 +54,35 @@ func (c *Commands) BulkSetUserMetadata(ctx context.Context, userID, resourceOwne if len(metadatas) == 0 { return nil, zerrors.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - events := make([]eventstore.Command, len(metadatas)) - setMetadata := NewUserMetadataListWriteModel(userID, resourceOwner) + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + events := make([]eventstore.Command, 0) + setMetadata, err := c.getUserMetadataListModelByID(ctx, userID, userResourceOwner) + if err != nil { + return nil, err + } userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel) - for i, data := range metadatas { + for _, data := range metadatas { + // if no change to metadata no event has to be pushed + if existingValue, ok := setMetadata.metadataList[data.Key]; ok && bytes.Equal(existingValue, data.Value) { + continue + } event, err := c.setUserMetadata(ctx, userAgg, data) if err != nil { return nil, err } - events[i] = event + events = append(events, event) + } + // no changes for the metadata + if len(events) == 0 { + return writeModelToObjectDetails(&setMetadata.WriteModel), nil } pushedEvents, err := c.eventstore.Push(ctx, events...) @@ -84,11 +113,16 @@ func (c *Commands) RemoveUserMetadata(ctx context.Context, metadataKey, userID, if metadataKey == "" { return nil, zerrors.ThrowInvalidArgument(nil, "META-2n0fs", "Errors.Metadata.Invalid") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } - removeMetadata, err := c.getUserMetadataModelByID(ctx, userID, resourceOwner, metadataKey) + + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + + removeMetadata, err := c.getUserMetadataModelByID(ctx, userID, userResourceOwner, metadataKey) if err != nil { return nil, err } @@ -116,13 +150,17 @@ func (c *Commands) BulkRemoveUserMetadata(ctx context.Context, userID, resourceO if len(metadataKeys) == 0 { return nil, zerrors.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData") } - err = c.checkUserExists(ctx, userID, resourceOwner) + userResourceOwner, err := c.checkUserExists(ctx, userID, resourceOwner) if err != nil { return nil, err } + if err := c.checkPermissionUpdateUser(ctx, userResourceOwner, userID); err != nil { + return nil, err + } + events := make([]eventstore.Command, len(metadataKeys)) - removeMetadata, err := c.getUserMetadataListModelByID(ctx, userID, resourceOwner) + removeMetadata, err := c.getUserMetadataListModelByID(ctx, userID, userResourceOwner) if err != nil { return nil, err } @@ -153,24 +191,6 @@ func (c *Commands) BulkRemoveUserMetadata(ctx context.Context, userID, resourceO return writeModelToObjectDetails(&removeMetadata.WriteModel), nil } -func (c *Commands) removeUserMetadataFromOrg(ctx context.Context, resourceOwner string) ([]eventstore.Command, error) { - existingUserMetadata, err := c.getUserMetadataByOrgListModelByID(ctx, resourceOwner) - if err != nil { - return nil, err - } - if len(existingUserMetadata.UserMetadata) == 0 { - return nil, nil - } - events := make([]eventstore.Command, 0) - for key, value := range existingUserMetadata.UserMetadata { - if len(value) == 0 { - continue - } - events = append(events, user.NewMetadataRemovedAllEvent(ctx, &user.NewAggregate(key, resourceOwner).Aggregate)) - } - return events, nil -} - func (c *Commands) removeUserMetadata(ctx context.Context, userAgg *eventstore.Aggregate, metadataKey string) (command eventstore.Command, err error) { command = user.NewMetadataRemovedEvent( ctx, @@ -197,12 +217,3 @@ func (c *Commands) getUserMetadataListModelByID(ctx context.Context, userID, res } return userMetadataWriteModel, nil } - -func (c *Commands) getUserMetadataByOrgListModelByID(ctx context.Context, resourceOwner string) (*UserMetadataByOrgListWriteModel, error) { - userMetadataWriteModel := NewUserMetadataByOrgListWriteModel(resourceOwner) - err := c.eventstore.FilterToQueryReducer(ctx, userMetadataWriteModel) - if err != nil { - return nil, err - } - return userMetadataWriteModel, nil -} diff --git a/internal/command/user_metadata_test.go b/internal/command/user_metadata_test.go index b3ffa7b823..e8fe25acd9 100644 --- a/internal/command/user_metadata_test.go +++ b/internal/command/user_metadata_test.go @@ -16,7 +16,8 @@ import ( func TestCommandSide_SetUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -39,10 +40,10 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -60,8 +61,7 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -78,7 +78,17 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -93,10 +103,9 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { }, }, { - name: "add metadata, ok", + name: "add metadata, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -113,6 +122,43 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + Value: []byte("value"), + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "add metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), expectPush( user.NewMetadataSetEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -121,6 +167,7 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -143,11 +190,116 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { }, }, }, + { + name: "add metadata, reset, invalid", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "add metadata, reset, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value"), + ), + ), + ), + expectPush( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key", + []byte("value2"), + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadata: &domain.Metadata{ + Key: "key", + Value: []byte("value2"), + }, + }, + res: res{ + want: &domain.Metadata{ + ObjectRoot: models.ObjectRoot{ + AggregateID: "user1", + ResourceOwner: "org1", + }, + Key: "key", + Value: []byte("value2"), + State: domain.MetadataStateActive, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.SetUserMetadata(tt.args.ctx, tt.args.metadata, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -165,7 +317,8 @@ func TestCommandSide_SetUserMetadata(t *testing.T) { func TestCommandSide_BulkSetUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -188,9 +341,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "empty meta data list, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -204,8 +355,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -225,8 +375,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -243,7 +392,9 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -259,10 +410,9 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { }, }, { - name: "add metadata, ok", + name: "add metadata, no permission", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -279,6 +429,43 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataList: []*domain.Metadata{ + {Key: "key", Value: []byte("value")}, + {Key: "key1", Value: []byte("value1")}, + }, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, + { + name: "add metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + expectFilter(), expectPush( user.NewMetadataSetEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, @@ -292,6 +479,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -312,7 +500,8 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.BulkSetUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...) if tt.res.err == nil { @@ -330,7 +519,8 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { func TestCommandSide_UserRemoveMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -353,8 +543,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -371,9 +560,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -388,8 +575,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { { name: "meta data not existing, not found error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -408,6 +594,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { ), expectFilter(), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -419,11 +606,43 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { err: zerrors.IsNotFound, }, }, + { + name: "remove metadata, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataKey: "key", + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, { name: "remove metadata, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -456,6 +675,7 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -473,7 +693,8 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.RemoveUserMetadata(tt.args.ctx, tt.args.metadataKey, tt.args.userID, tt.args.orgID) if tt.res.err == nil { @@ -491,7 +712,8 @@ func TestCommandSide_UserRemoveMetadata(t *testing.T) { func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { type fields struct { - eventstore *eventstore.Eventstore + eventstore func(t *testing.T) *eventstore.Eventstore + checkPermission domain.PermissionCheck } type ( args struct { @@ -514,9 +736,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "empty meta data list, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, - ), + eventstore: expectEventstore(), }, args: args{ ctx: context.Background(), @@ -530,8 +750,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "user not existing, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter(), ), }, @@ -548,8 +767,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "remove metadata keys not existing, precondition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -576,6 +794,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -590,8 +809,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { { name: "invalid metadata, pre condition error", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -625,6 +843,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -636,11 +855,43 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { err: zerrors.IsErrorInvalidArgument, }, }, + { + name: "remove metadata, no permission", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username", + "firstname", + "lastname", + "", + "firstname lastname", + language.Und, + domain.GenderUnspecified, + "email@test.ch", + true, + ), + ), + ), + ), + checkPermission: newMockPermissionCheckNotAllowed(), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + userID: "user1", + metadataList: []string{"key", "key1"}, + }, + res: res{ + err: zerrors.IsPermissionDenied, + }, + }, { name: "remove metadata, ok", fields: fields{ - eventstore: eventstoreExpect( - t, + eventstore: expectEventstore( expectFilter( eventFromEventPusher( user.NewHumanAddedEvent(context.Background(), @@ -684,6 +935,7 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { ), ), ), + checkPermission: newMockPermissionCheckAllowed(), }, args: args{ ctx: context.Background(), @@ -701,7 +953,8 @@ func TestCommandSide_BulkRemoveUserMetadata(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r := &Commands{ - eventstore: tt.fields.eventstore, + eventstore: tt.fields.eventstore(t), + checkPermission: tt.fields.checkPermission, } got, err := r.BulkRemoveUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...) if tt.res.err == nil { diff --git a/internal/command/user_v2.go b/internal/command/user_v2.go index be10fd03fe..d6c5e7de53 100644 --- a/internal/command/user_v2.go +++ b/internal/command/user_v2.go @@ -2,7 +2,6 @@ package command import ( "context" - "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/domain" @@ -150,7 +149,7 @@ func (c *Commands) RemoveUserV2(ctx context.Context, userID, resourceOwner strin events = append(events, user.NewUserRemovedEvent(ctx, &existingUser.Aggregate().Aggregate, existingUser.UserName, existingUser.IDPLinks, domainPolicy.UserLoginMustBeDomain)) for _, grantID := range cascadingGrantIDs { - removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true) + removeEvent, _, err := c.removeUserGrant(ctx, grantID, "", true, true, nil) if err != nil { logging.WithFields("usergrantid", grantID).WithError(err).Warn("could not cascade remove role on user grant") continue diff --git a/internal/domain/member.go b/internal/domain/member.go index 821e65dfe3..76854a6568 100644 --- a/internal/domain/member.go +++ b/internal/domain/member.go @@ -25,10 +25,6 @@ func (i *Member) IsValid() bool { return i.AggregateID != "" && i.UserID != "" && len(i.Roles) != 0 } -func (i *Member) IsIAMValid() bool { - return i.UserID != "" && len(i.Roles) != 0 -} - type MemberState int32 const ( @@ -42,3 +38,7 @@ const ( func (f MemberState) Valid() bool { return f >= 0 && f < memberStateCount } + +func (f MemberState) Exists() bool { + return f != MemberStateRemoved && f != MemberStateUnspecified +} diff --git a/internal/domain/permission.go b/internal/domain/permission.go index 119e8c2d3e..1405991dae 100644 --- a/internal/domain/permission.go +++ b/internal/domain/permission.go @@ -27,29 +27,44 @@ func (p *Permissions) appendPermission(ctxID, permission string) { type PermissionCheck func(ctx context.Context, permission, resourceOwnerID, aggregateID string) (err error) const ( - PermissionUserWrite = "user.write" - PermissionUserRead = "user.read" - PermissionUserDelete = "user.delete" - PermissionUserCredentialWrite = "user.credential.write" - PermissionSessionWrite = "session.write" - PermissionSessionRead = "session.read" - PermissionSessionLink = "session.link" - PermissionSessionDelete = "session.delete" - PermissionOrgRead = "org.read" - PermissionIDPRead = "iam.idp.read" - PermissionOrgIDPRead = "org.idp.read" - PermissionProjectWrite = "project.write" - PermissionProjectRead = "project.read" - PermissionProjectDelete = "project.delete" - PermissionProjectGrantWrite = "project.grant.write" - PermissionProjectGrantRead = "project.grant.read" - PermissionProjectGrantDelete = "project.grant.delete" - PermissionProjectRoleWrite = "project.role.write" - PermissionProjectRoleRead = "project.role.read" - PermissionProjectRoleDelete = "project.role.delete" - PermissionProjectAppWrite = "project.app.write" - PermissionProjectAppDelete = "project.app.delete" - PermissionProjectAppRead = "project.app.read" + PermissionUserWrite = "user.write" + PermissionUserRead = "user.read" + PermissionUserDelete = "user.delete" + PermissionUserCredentialWrite = "user.credential.write" + PermissionSessionWrite = "session.write" + PermissionSessionRead = "session.read" + PermissionSessionLink = "session.link" + PermissionSessionDelete = "session.delete" + PermissionOrgRead = "org.read" + PermissionIDPRead = "iam.idp.read" + PermissionOrgIDPRead = "org.idp.read" + PermissionProjectWrite = "project.write" + PermissionProjectRead = "project.read" + PermissionProjectDelete = "project.delete" + PermissionProjectGrantWrite = "project.grant.write" + PermissionProjectGrantRead = "project.grant.read" + PermissionProjectGrantDelete = "project.grant.delete" + PermissionProjectRoleWrite = "project.role.write" + PermissionProjectRoleRead = "project.role.read" + PermissionProjectRoleDelete = "project.role.delete" + PermissionProjectAppWrite = "project.app.write" + PermissionProjectAppDelete = "project.app.delete" + PermissionProjectAppRead = "project.app.read" + PermissionInstanceMemberWrite = "iam.member.write" + PermissionInstanceMemberDelete = "iam.member.delete" + PermissionInstanceMemberRead = "iam.member.read" + PermissionOrgMemberWrite = "org.member.write" + PermissionOrgMemberDelete = "org.member.delete" + PermissionOrgMemberRead = "org.member.read" + PermissionProjectMemberWrite = "project.member.write" + PermissionProjectMemberDelete = "project.member.delete" + PermissionProjectMemberRead = "project.member.read" + PermissionProjectGrantMemberWrite = "project.grant.member.write" + PermissionProjectGrantMemberDelete = "project.grant.member.delete" + PermissionProjectGrantMemberRead = "project.grant.member.read" + PermissionUserGrantWrite = "user.grant.write" + PermissionUserGrantRead = "user.grant.read" + PermissionUserGrantDelete = "user.grant.delete" ) // ProjectPermissionCheck is used as a check for preconditions dependent on application, project, user resourceowner and usergrants. diff --git a/internal/integration/client.go b/internal/integration/client.go index 326d6fa8b4..a89e4fa621 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -2,6 +2,7 @@ package integration import ( "context" + "encoding/base64" "fmt" "sync" "testing" @@ -24,11 +25,13 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/admin" app "github.com/zitadel/zitadel/pkg/grpc/app/v2beta" "github.com/zitadel/zitadel/pkg/grpc/auth" + authorization "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta" "github.com/zitadel/zitadel/pkg/grpc/feature/v2" feature_v2beta "github.com/zitadel/zitadel/pkg/grpc/feature/v2beta" "github.com/zitadel/zitadel/pkg/grpc/idp" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp/v2" instance "github.com/zitadel/zitadel/pkg/grpc/instance/v2beta" + internal_permission_v2beta "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" "github.com/zitadel/zitadel/pkg/grpc/object/v2" object_v3alpha "github.com/zitadel/zitadel/pkg/grpc/object/v3alpha" @@ -47,36 +50,40 @@ import ( user_pb "github.com/zitadel/zitadel/pkg/grpc/user" user_v2 "github.com/zitadel/zitadel/pkg/grpc/user/v2" user_v2beta "github.com/zitadel/zitadel/pkg/grpc/user/v2beta" + webkey_v2 "github.com/zitadel/zitadel/pkg/grpc/webkey/v2" webkey_v2beta "github.com/zitadel/zitadel/pkg/grpc/webkey/v2beta" ) type Client struct { - CC *grpc.ClientConn - Admin admin.AdminServiceClient - Mgmt mgmt.ManagementServiceClient - Auth auth.AuthServiceClient - UserV2beta user_v2beta.UserServiceClient - UserV2 user_v2.UserServiceClient - SessionV2beta session_v2beta.SessionServiceClient - SessionV2 session.SessionServiceClient - SettingsV2beta settings_v2beta.SettingsServiceClient - SettingsV2 settings.SettingsServiceClient - OIDCv2beta oidc_pb_v2beta.OIDCServiceClient - OIDCv2 oidc_pb.OIDCServiceClient - OrgV2beta org_v2beta.OrganizationServiceClient - OrgV2 org.OrganizationServiceClient - ActionV2beta action.ActionServiceClient - FeatureV2beta feature_v2beta.FeatureServiceClient - FeatureV2 feature.FeatureServiceClient - UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient - WebKeyV2Beta webkey_v2beta.WebKeyServiceClient - IDPv2 idp_pb.IdentityProviderServiceClient - UserV3Alpha user_v3alpha.ZITADELUsersClient - SAMLv2 saml_pb.SAMLServiceClient - SCIM *scim.Client - Projectv2Beta project_v2beta.ProjectServiceClient - InstanceV2Beta instance.InstanceServiceClient - AppV2Beta app.AppServiceClient + CC *grpc.ClientConn + Admin admin.AdminServiceClient + Mgmt mgmt.ManagementServiceClient + Auth auth.AuthServiceClient + UserV2beta user_v2beta.UserServiceClient + UserV2 user_v2.UserServiceClient + SessionV2beta session_v2beta.SessionServiceClient + SessionV2 session.SessionServiceClient + SettingsV2beta settings_v2beta.SettingsServiceClient + SettingsV2 settings.SettingsServiceClient + OIDCv2beta oidc_pb_v2beta.OIDCServiceClient + OIDCv2 oidc_pb.OIDCServiceClient + OrgV2beta org_v2beta.OrganizationServiceClient + OrgV2 org.OrganizationServiceClient + ActionV2beta action.ActionServiceClient + FeatureV2beta feature_v2beta.FeatureServiceClient + FeatureV2 feature.FeatureServiceClient + UserSchemaV3 userschema_v3alpha.ZITADELUserSchemasClient + WebKeyV2 webkey_v2.WebKeyServiceClient + WebKeyV2Beta webkey_v2beta.WebKeyServiceClient + IDPv2 idp_pb.IdentityProviderServiceClient + UserV3Alpha user_v3alpha.ZITADELUsersClient + SAMLv2 saml_pb.SAMLServiceClient + SCIM *scim.Client + Projectv2Beta project_v2beta.ProjectServiceClient + InstanceV2Beta instance.InstanceServiceClient + AppV2Beta app.AppServiceClient + InternalPermissionv2Beta internal_permission_v2beta.InternalPermissionServiceClient + AuthorizationV2Beta authorization.AuthorizationServiceClient } func NewDefaultClient(ctx context.Context) (*Client, error) { @@ -91,32 +98,35 @@ func newClient(ctx context.Context, target string) (*Client, error) { return nil, err } client := &Client{ - CC: cc, - Admin: admin.NewAdminServiceClient(cc), - Mgmt: mgmt.NewManagementServiceClient(cc), - Auth: auth.NewAuthServiceClient(cc), - UserV2beta: user_v2beta.NewUserServiceClient(cc), - UserV2: user_v2.NewUserServiceClient(cc), - SessionV2beta: session_v2beta.NewSessionServiceClient(cc), - SessionV2: session.NewSessionServiceClient(cc), - SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc), - SettingsV2: settings.NewSettingsServiceClient(cc), - OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc), - OIDCv2: oidc_pb.NewOIDCServiceClient(cc), - OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), - OrgV2: org.NewOrganizationServiceClient(cc), - ActionV2beta: action.NewActionServiceClient(cc), - FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), - FeatureV2: feature.NewFeatureServiceClient(cc), - UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), - WebKeyV2Beta: webkey_v2beta.NewWebKeyServiceClient(cc), - IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), - UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), - SAMLv2: saml_pb.NewSAMLServiceClient(cc), - SCIM: scim.NewScimClient(target), - Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), - InstanceV2Beta: instance.NewInstanceServiceClient(cc), - AppV2Beta: app.NewAppServiceClient(cc), + CC: cc, + Admin: admin.NewAdminServiceClient(cc), + Mgmt: mgmt.NewManagementServiceClient(cc), + Auth: auth.NewAuthServiceClient(cc), + UserV2beta: user_v2beta.NewUserServiceClient(cc), + UserV2: user_v2.NewUserServiceClient(cc), + SessionV2beta: session_v2beta.NewSessionServiceClient(cc), + SessionV2: session.NewSessionServiceClient(cc), + SettingsV2beta: settings_v2beta.NewSettingsServiceClient(cc), + SettingsV2: settings.NewSettingsServiceClient(cc), + OIDCv2beta: oidc_pb_v2beta.NewOIDCServiceClient(cc), + OIDCv2: oidc_pb.NewOIDCServiceClient(cc), + OrgV2beta: org_v2beta.NewOrganizationServiceClient(cc), + OrgV2: org.NewOrganizationServiceClient(cc), + ActionV2beta: action.NewActionServiceClient(cc), + FeatureV2beta: feature_v2beta.NewFeatureServiceClient(cc), + FeatureV2: feature.NewFeatureServiceClient(cc), + UserSchemaV3: userschema_v3alpha.NewZITADELUserSchemasClient(cc), + WebKeyV2: webkey_v2.NewWebKeyServiceClient(cc), + WebKeyV2Beta: webkey_v2beta.NewWebKeyServiceClient(cc), + IDPv2: idp_pb.NewIdentityProviderServiceClient(cc), + UserV3Alpha: user_v3alpha.NewZITADELUsersClient(cc), + SAMLv2: saml_pb.NewSAMLServiceClient(cc), + SCIM: scim.NewScimClient(target), + Projectv2Beta: project_v2beta.NewProjectServiceClient(cc), + InstanceV2Beta: instance.NewInstanceServiceClient(cc), + AppV2Beta: app.NewAppServiceClient(cc), + InternalPermissionv2Beta: internal_permission_v2beta.NewInternalPermissionServiceClient(cc), + AuthorizationV2Beta: authorization.NewAuthorizationServiceClient(cc), } return client, client.pollHealth(ctx) } @@ -236,7 +246,29 @@ func (i *Instance) CreateHumanUserWithTOTP(ctx context.Context, secret string) * return resp } -func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserResponse { +func (i *Instance) SetUserMetadata(ctx context.Context, id, key, value string) *user_v2.SetUserMetadataResponse { + resp, err := i.Client.UserV2.SetUserMetadata(ctx, &user_v2.SetUserMetadataRequest{ + UserId: id, + Metadata: []*user_v2.Metadata{{ + Key: key, + Value: []byte(base64.StdEncoding.EncodeToString([]byte(value))), + }, + }, + }) + logging.OnError(err).Panic("set user metadata") + return resp +} + +func (i *Instance) DeleteUserMetadata(ctx context.Context, id, key string) *user_v2.DeleteUserMetadataResponse { + resp, err := i.Client.UserV2.DeleteUserMetadata(ctx, &user_v2.DeleteUserMetadataRequest{ + UserId: id, + Keys: []string{key}, + }) + logging.OnError(err).Panic("delete user metadata") + return resp +} + +func (i *Instance) CreateUserTypeHuman(ctx context.Context, email string) *user_v2.CreateUserResponse { resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ OrganizationId: i.DefaultOrg.GetId(), UserType: &user_v2.CreateUserRequest_Human_{ @@ -246,7 +278,7 @@ func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserR FamilyName: "Mouse", }, Email: &user_v2.SetHumanEmail{ - Email: fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()), + Email: email, Verification: &user_v2.SetHumanEmail_ReturnCode{ ReturnCode: &user_v2.ReturnEmailVerificationCode{}, }, @@ -259,7 +291,7 @@ func (i *Instance) CreateUserTypeHuman(ctx context.Context) *user_v2.CreateUserR return resp } -func (i *Instance) CreateUserTypeMachine(ctx context.Context) *user_v2.CreateUserResponse { +func (i *Instance) CreateUserTypeMachine(ctx context.Context, orgId string) *user_v2.CreateUserResponse { resp, err := i.Client.UserV2.CreateUser(ctx, &user_v2.CreateUserRequest{ OrganizationId: i.DefaultOrg.GetId(), UserType: &user_v2.CreateUserRequest_Machine_{ @@ -626,14 +658,6 @@ func (i *Instance) AddOrgGenericOAuthProvider(ctx context.Context, name string) }, }) logging.OnError(err).Panic("create generic OAuth idp") - /* - mustAwait(func() error { - _, err := i.Client.Mgmt.GetProviderByID(ctx, &mgmt.GetProviderByIDRequest{ - Id: resp.GetId(), - }) - return err - }) - */ return resp } @@ -880,48 +904,107 @@ func (i *Instance) ActivateProjectGrant(ctx context.Context, t *testing.T, proje return resp } -func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) string { +func (i *Instance) CreateProjectUserGrant(t *testing.T, ctx context.Context, projectID, userID string) *mgmt.AddUserGrantResponse { resp, err := i.Client.Mgmt.AddUserGrant(ctx, &mgmt.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, }) require.NoError(t, err) - return resp.GetUserGrantId() + return resp } -func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) string { +func (i *Instance) CreateProjectGrantUserGrant(ctx context.Context, orgID, projectID, projectGrantID, userID string) *mgmt.AddUserGrantResponse { resp, err := i.Client.Mgmt.AddUserGrant(SetOrgID(ctx, orgID), &mgmt.AddUserGrantRequest{ UserId: userID, ProjectId: projectID, ProjectGrantId: projectGrantID, }) logging.OnError(err).Panic("create project grant user grant") - return resp.GetUserGrantId() + return resp } -func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, userID string) { - _, err := i.Client.Mgmt.AddOrgMember(ctx, &mgmt.AddOrgMemberRequest{ +func (i *Instance) CreateInstanceMembership(t *testing.T, ctx context.Context, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_Instance{Instance: true}, + }, + UserId: userID, + Roles: []string{domain.RoleIAMOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteInstanceMembership(t *testing.T, ctx context.Context, userID string) { + _, err := i.Client.Admin.RemoveIAMMember(ctx, &admin.RemoveIAMMemberRequest{ + UserId: userID, + }) + require.NoError(t, err) +} + +func (i *Instance) CreateOrgMembership(t *testing.T, ctx context.Context, orgID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_OrganizationId{OrganizationId: orgID}, + }, UserId: userID, Roles: []string{domain.RoleOrgOwner}, }) require.NoError(t, err) + return resp } -func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { - _, err := i.Client.Mgmt.AddProjectMember(ctx, &mgmt.AddProjectMemberRequest{ - ProjectId: projectID, - UserId: userID, - Roles: []string{domain.RoleProjectOwner}, +func (i *Instance) DeleteOrgMembership(t *testing.T, ctx context.Context, userID string) { + _, err := i.Client.Mgmt.RemoveOrgMember(ctx, &mgmt.RemoveOrgMemberRequest{ + UserId: userID, }) require.NoError(t, err) } -func (i *Instance) CreateProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) { - _, err := i.Client.Mgmt.AddProjectGrantMember(ctx, &mgmt.AddProjectGrantMemberRequest{ - ProjectId: projectID, - GrantId: grantID, - UserId: userID, - Roles: []string{domain.RoleProjectGrantOwner}, +func (i *Instance) CreateProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectId{ProjectId: projectID}, + }, + UserId: userID, + Roles: []string{domain.RoleProjectOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProjectMembership(t *testing.T, ctx context.Context, projectID, userID string) { + _, err := i.Client.InternalPermissionv2Beta.DeleteAdministrator(ctx, &internal_permission_v2beta.DeleteAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{Resource: &internal_permission_v2beta.ResourceType_ProjectId{ProjectId: projectID}}, + UserId: userID, + }) + require.NoError(t, err) +} + +func (i *Instance) CreateProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) *internal_permission_v2beta.CreateAdministratorResponse { + resp, err := i.Client.InternalPermissionv2Beta.CreateAdministrator(ctx, &internal_permission_v2beta.CreateAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectGrant_{ProjectGrant: &internal_permission_v2beta.ResourceType_ProjectGrant{ + ProjectId: projectID, + ProjectGrantId: grantID, + }}, + }, + UserId: userID, + Roles: []string{domain.RoleProjectGrantOwner}, + }) + require.NoError(t, err) + return resp +} + +func (i *Instance) DeleteProjectGrantMembership(t *testing.T, ctx context.Context, projectID, grantID, userID string) { + _, err := i.Client.InternalPermissionv2Beta.DeleteAdministrator(ctx, &internal_permission_v2beta.DeleteAdministratorRequest{ + Resource: &internal_permission_v2beta.ResourceType{ + Resource: &internal_permission_v2beta.ResourceType_ProjectGrant_{ProjectGrant: &internal_permission_v2beta.ResourceType_ProjectGrant{ + ProjectId: projectID, + ProjectGrantId: grantID, + }}, + }, + UserId: userID, }) require.NoError(t, err) } diff --git a/internal/integration/feature.go b/internal/integration/feature.go new file mode 100644 index 0000000000..07942fcdcd --- /dev/null +++ b/internal/integration/feature.go @@ -0,0 +1,30 @@ +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zitadel/zitadel/pkg/grpc/feature/v2" +) + +func EnsureInstanceFeature(t *testing.T, ctx context.Context, instance *Instance, features *feature.SetInstanceFeaturesRequest, assertFeatures func(t *assert.CollectT, got *feature.GetInstanceFeaturesResponse)) { + ctx = instance.WithAuthorizationToken(ctx, UserTypeIAMOwner) + _, err := instance.Client.FeatureV2.SetInstanceFeatures(ctx, features) + require.NoError(t, err) + retryDuration, tick := WaitForAndTickWithMaxDuration(ctx, 5*time.Minute) + require.EventuallyWithT(t, + func(tt *assert.CollectT) { + got, err := instance.Client.FeatureV2.GetInstanceFeatures(ctx, &feature.GetInstanceFeaturesRequest{ + Inheritance: true, + }) + require.NoError(tt, err) + assertFeatures(tt, got) + }, + retryDuration, + tick, + "timed out waiting for ensuring instance feature") +} diff --git a/internal/integration/instance.go b/internal/integration/instance.go index 66e2cf18ec..9e262cb3d9 100644 --- a/internal/integration/instance.go +++ b/internal/integration/instance.go @@ -294,11 +294,14 @@ func (i *Instance) createWebAuthNClient() { i.WebAuthN = webauthn.NewClient(i.Config.WebAuthNName, i.Domain, http_util.BuildOrigin(i.Host(), i.Config.Secure)) } +// Deprecated: WithAuthorization is misleading, as we have Zitadel resources called authorization now. +// It is aliased to WithAuthorizationToken, which sets the Authorization header with a Bearer token. +// Use WithAuthorizationToken directly instead. func (i *Instance) WithAuthorization(ctx context.Context, u UserType) context.Context { - return i.WithInstanceAuthorization(ctx, u) + return i.WithAuthorizationToken(ctx, u) } -func (i *Instance) WithInstanceAuthorization(ctx context.Context, u UserType) context.Context { +func (i *Instance) WithAuthorizationToken(ctx context.Context, u UserType) context.Context { return WithAuthorizationToken(ctx, i.Users.Get(u).Token) } diff --git a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl index adb71c42ff..0fb1c3e102 100644 --- a/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl +++ b/internal/protoc/protoc-gen-zitadel/zitadel.pb.go.tmpl @@ -5,6 +5,7 @@ package {{.GoPackageName}} import ( "github.com/zitadel/zitadel/internal/api/authz" {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/middleware"{{end}} + {{if .AuthContext}}"github.com/zitadel/zitadel/internal/api/grpc/server/connect_middleware"{{end}} ) var {{.ServiceName}}_AuthMethods = authz.MethodMapping { @@ -23,6 +24,13 @@ func (r *{{ $m.Name }}) OrganizationFromRequest() *middleware.Organization { Domain: r{{$m.OrgMethod}}.GetOrgDomain(), } } + +func (r *{{ $m.Name }}) OrganizationFromRequestConnect() *connect_middleware.Organization { + return &connect_middleware.Organization{ + ID: r{{$m.OrgMethod}}.GetOrgId(), + Domain: r{{$m.OrgMethod}}.GetOrgDomain(), + } +} {{ end }} {{ range $resp := .CustomHTTPResponses}} diff --git a/internal/query/administrators.go b/internal/query/administrators.go new file mode 100644 index 0000000000..9ab81993ef --- /dev/null +++ b/internal/query/administrators.go @@ -0,0 +1,347 @@ +package query + +import ( + "context" + "database/sql" + "slices" + "time" + + sq "github.com/Masterminds/squirrel" + + "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" + "github.com/zitadel/zitadel/internal/domain" + "github.com/zitadel/zitadel/internal/telemetry/tracing" + "github.com/zitadel/zitadel/internal/zerrors" +) + +type Administrators struct { + SearchResponse + Administrators []*Administrator +} + +type Administrator struct { + Roles database.TextArray[string] + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + + User *UserAdministrator + Org *OrgAdministrator + Instance *InstanceAdministrator + Project *ProjectAdministrator + ProjectGrant *ProjectGrantAdministrator +} + +type UserAdministrator struct { + UserID string + LoginName string + DisplayName string + ResourceOwner string +} +type OrgAdministrator struct { + OrgID string + Name string +} + +type InstanceAdministrator struct { + InstanceID string + Name string +} + +type ProjectAdministrator struct { + ProjectID string + Name string + ResourceOwner string +} + +type ProjectGrantAdministrator struct { + ProjectID string + ProjectName string + GrantID string + GrantedOrgID string + ResourceOwner string +} + +func NewAdministratorUserResourceOwnerSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(UserResourceOwnerCol, value, TextEquals) +} + +func NewAdministratorUserLoginNameSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(LoginNameNameCol, value, TextEquals) +} + +func NewAdministratorUserDisplayNameSearchQuery(value string) (SearchQuery, error) { + return NewTextQuery(HumanDisplayNameCol, value, TextEquals) +} + +func administratorInstancePermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + InstanceMemberResourceOwner, + domain.PermissionInstanceMemberRead, + OwnedRowsPermissionOption(InstanceMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorOrgPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + OrgMemberResourceOwner, + domain.PermissionOrgMemberRead, + OwnedRowsPermissionOption(OrgMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorProjectPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectMemberResourceOwner, + domain.PermissionProjectMemberRead, + WithProjectsPermissionOption(ProjectMemberProjectID), + OwnedRowsPermissionOption(ProjectMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorProjectGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + ProjectGrantMemberResourceOwner, + domain.PermissionProjectGrantMemberRead, + WithProjectsPermissionOption(ProjectMemberProjectID), + OwnedRowsPermissionOption(ProjectGrantMemberUserID), + ) + return query.JoinClause(join, args...) +} + +func administratorsCheckPermission(ctx context.Context, administrators *Administrators, permissionCheck domain.PermissionCheck) { + selfUserID := authz.GetCtxData(ctx).UserID + administrators.Administrators = slices.DeleteFunc(administrators.Administrators, + func(administrator *Administrator) bool { + if administrator.User != nil && administrator.User.UserID == selfUserID { + return false + } + if administrator.ProjectGrant != nil { + return administratorProjectGrantCheckPermission(ctx, administrator.ProjectGrant.ResourceOwner, administrator.ProjectGrant.ProjectID, administrator.ProjectGrant.GrantID, administrator.ProjectGrant.GrantedOrgID, permissionCheck) != nil + } + if administrator.Project != nil { + return permissionCheck(ctx, domain.PermissionProjectMemberRead, administrator.Project.ResourceOwner, administrator.Project.ProjectID) != nil + } + if administrator.Org != nil { + return permissionCheck(ctx, domain.PermissionOrgMemberRead, administrator.Org.OrgID, administrator.Org.OrgID) != nil + } + if administrator.Instance != nil { + return permissionCheck(ctx, domain.PermissionInstanceMemberRead, administrator.Instance.InstanceID, administrator.Instance.InstanceID) != nil + } + return true + }, + ) +} + +func administratorProjectGrantCheckPermission(ctx context.Context, resourceOwner, projectID, grantID, grantedOrgID string, permissionCheck domain.PermissionCheck) error { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, resourceOwner, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, grantedOrgID, grantID); err != nil { + if err := permissionCheck(ctx, domain.PermissionProjectGrantMemberRead, resourceOwner, projectID); err != nil { + return err + } + } + } + return nil +} + +func (q *Queries) SearchAdministrators(ctx context.Context, queries *MembershipSearchQuery, permissionCheck domain.PermissionCheck) (*Administrators, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + admins, err := q.searchAdministrators(ctx, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + administratorsCheckPermission(ctx, admins, permissionCheck) + } + return admins, nil +} + +func (q *Queries) searchAdministrators(ctx context.Context, queries *MembershipSearchQuery, permissionCheckV2 bool) (administrators *Administrators, err error) { + ctx, span := tracing.NewSpan(ctx) + defer func() { span.EndWithError(err) }() + + query, queryArgs, scan := prepareAdministratorsQuery(ctx, queries, permissionCheckV2) + eq := sq.Eq{membershipInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} + stmt, args, err := queries.toQuery(query).Where(eq).ToSql() + if err != nil { + return nil, zerrors.ThrowInvalidArgument(err, "QUERY-TODO", "Errors.Query.InvalidRequest") + } + latestState, err := q.latestState(ctx, orgMemberTable, instanceMemberTable, projectMemberTable, projectGrantMemberTable) + if err != nil { + return nil, err + } + queryArgs = append(queryArgs, args...) + + err = q.client.QueryContext(ctx, func(rows *sql.Rows) error { + administrators, err = scan(rows) + return err + }, stmt, queryArgs...) + if err != nil { + return nil, err + } + administrators.State = latestState + return administrators, nil +} + +func prepareAdministratorsQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Administrators, error)) { + query, args := getMembershipFromQuery(ctx, queries, permissionV2) + return sq.Select( + MembershipUserID.identifier(), + membershipRoles.identifier(), + MembershipCreationDate.identifier(), + MembershipChangeDate.identifier(), + membershipResourceOwner.identifier(), + membershipOrgID.identifier(), + membershipIAMID.identifier(), + membershipProjectID.identifier(), + membershipGrantID.identifier(), + ProjectGrantColumnGrantedOrgID.identifier(), + ProjectColumnResourceOwner.identifier(), + ProjectColumnName.identifier(), + OrgColumnName.identifier(), + InstanceColumnName.identifier(), + LoginNameNameCol.identifier(), + HumanDisplayNameCol.identifier(), + MachineNameCol.identifier(), + HumanAvatarURLCol.identifier(), + UserTypeCol.identifier(), + UserResourceOwnerCol.identifier(), + countColumn.identifier(), + ).From(query). + LeftJoin(join(ProjectColumnID, membershipProjectID)). + LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID)). + LeftJoin(join(OrgColumnID, membershipOrgID)). + LeftJoin(join(InstanceColumnID, membershipInstanceID)). + LeftJoin(join(HumanUserIDCol, OrgMemberUserID)). + LeftJoin(join(MachineUserIDCol, OrgMemberUserID)). + LeftJoin(join(UserIDCol, OrgMemberUserID)). + LeftJoin(join(LoginNameUserIDCol, OrgMemberUserID)). + Where( + sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, + ).PlaceholderFormat(sq.Dollar), + args, + func(rows *sql.Rows) (*Administrators, error) { + administrators := make([]*Administrator, 0) + var count uint64 + for rows.Next() { + + var ( + administrator = new(Administrator) + userID = sql.NullString{} + orgID = sql.NullString{} + instanceID = sql.NullString{} + projectID = sql.NullString{} + grantID = sql.NullString{} + grantedOrgID = sql.NullString{} + projectName = sql.NullString{} + orgName = sql.NullString{} + instanceName = sql.NullString{} + projectResourceOwner = sql.NullString{} + loginName = sql.NullString{} + displayName = sql.NullString{} + machineName = sql.NullString{} + avatarURL = sql.NullString{} + userType = sql.NullInt32{} + userResourceOwner = sql.NullString{} + ) + + err := rows.Scan( + &userID, + &administrator.Roles, + &administrator.CreationDate, + &administrator.ChangeDate, + &administrator.ResourceOwner, + &orgID, + &instanceID, + &projectID, + &grantID, + &grantedOrgID, + &projectResourceOwner, + &projectName, + &orgName, + &instanceName, + &loginName, + &displayName, + &machineName, + &avatarURL, + &userType, + &userResourceOwner, + &count, + ) + + if err != nil { + return nil, err + } + + if userID.Valid { + administrator.User = &UserAdministrator{ + UserID: userID.String, + LoginName: loginName.String, + DisplayName: displayName.String, + ResourceOwner: userResourceOwner.String, + } + } + + if orgID.Valid { + administrator.Org = &OrgAdministrator{ + OrgID: orgID.String, + Name: orgName.String, + } + } + if instanceID.Valid { + administrator.Instance = &InstanceAdministrator{ + InstanceID: instanceID.String, + Name: instanceName.String, + } + } + if projectID.Valid && grantID.Valid && grantedOrgID.Valid { + administrator.ProjectGrant = &ProjectGrantAdministrator{ + ProjectID: projectID.String, + ProjectName: projectName.String, + GrantID: grantID.String, + GrantedOrgID: grantedOrgID.String, + ResourceOwner: projectResourceOwner.String, + } + } else if projectID.Valid { + administrator.Project = &ProjectAdministrator{ + ProjectID: projectID.String, + Name: projectName.String, + ResourceOwner: projectResourceOwner.String, + } + } + + administrators = append(administrators, administrator) + } + + if err := rows.Close(); err != nil { + return nil, zerrors.ThrowInternal(err, "QUERY-TODO", "Errors.Query.CloseRows") + } + + return &Administrators{ + Administrators: administrators, + SearchResponse: SearchResponse{ + Count: count, + }, + }, nil + } +} diff --git a/internal/query/member.go b/internal/query/member.go index 584ae15d1c..8a4ca3302d 100644 --- a/internal/query/member.go +++ b/internal/query/member.go @@ -35,8 +35,17 @@ func NewMemberLastNameSearchQuery(method TextComparison, value string) (SearchQu } func NewMemberUserIDSearchQuery(value string) (SearchQuery, error) { - return NewTextQuery(membershipUserID, value, TextEquals) + return NewTextQuery(MembershipUserID, value, TextEquals) } + +func NewMemberInUserIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(MembershipUserID, list, ListIn) +} + func NewMemberResourceOwnerSearchQuery(value string) (SearchQuery, error) { return NewTextQuery(membershipResourceOwner, value, TextEquals) } diff --git a/internal/query/project_grant_member.go b/internal/query/project_grant_member.go index a9cc49c498..2f469dce80 100644 --- a/internal/query/project_grant_member.go +++ b/internal/query/project_grant_member.go @@ -128,7 +128,7 @@ func prepareProjectGrantMembersQuery() (sq.SelectBuilder, func(*sql.Rows) (*Memb LeftJoin(join(MachineUserIDCol, ProjectGrantMemberUserID)). LeftJoin(join(UserIDCol, ProjectGrantMemberUserID)). LeftJoin(join(LoginNameUserIDCol, ProjectGrantMemberUserID)). - LeftJoin(join(ProjectGrantColumnGrantID, ProjectGrantMemberGrantID)). + LeftJoin(join(ProjectGrantColumnGrantID, ProjectGrantMemberGrantID) + " AND " + ProjectGrantMemberProjectID.identifier() + " = " + ProjectGrantColumnProjectID.identifier()). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, ).PlaceholderFormat(sq.Dollar), diff --git a/internal/query/project_grant_member_test.go b/internal/query/project_grant_member_test.go index 23d1258b7c..be0f5f572e 100644 --- a/internal/query/project_grant_member_test.go +++ b/internal/query/project_grant_member_test.go @@ -46,6 +46,7 @@ var ( "LEFT JOIN projections.project_grants4 " + "ON members.grant_id = projections.project_grants4.grant_id " + "AND members.instance_id = projections.project_grants4.instance_id " + + "AND members.project_id = projections.project_grants4.project_id " + "WHERE projections.login_names3.is_primary = $1") projectGrantMembersColumns = []string{ "creation_date", diff --git a/internal/query/user_grant.go b/internal/query/user_grant.go index 05d80fe381..212972ea8c 100644 --- a/internal/query/user_grant.go +++ b/internal/query/user_grant.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" @@ -44,8 +45,9 @@ type UserGrant struct { OrgName string `json:"org_name,omitempty"` OrgPrimaryDomain string `json:"org_primary_domain,omitempty"` - ProjectID string `json:"project_id,omitempty"` - ProjectName string `json:"project_name,omitempty"` + ProjectResourceOwner string `json:"project_resource_owner,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` GrantedOrgID string `json:"granted_org_id,omitempty"` GrantedOrgName string `json:"granted_org_name,omitempty"` @@ -57,6 +59,27 @@ type UserGrants struct { UserGrants []*UserGrant } +func userGrantsCheckPermission(ctx context.Context, grants *UserGrants, permissionCheck domain.PermissionCheck) { + grants.UserGrants = slices.DeleteFunc(grants.UserGrants, + func(grant *UserGrant) bool { + return userGrantCheckPermission(ctx, grant.ResourceOwner, grant.ProjectID, grant.GrantID, grant.UserID, permissionCheck) != nil + }, + ) +} + +func userGrantCheckPermission(ctx context.Context, resourceOwner, projectID, grantID, userID string, permissionCheck domain.PermissionCheck) error { + // you should always be able to read your own permissions + if authz.GetCtxData(ctx).UserID == userID { + return nil + } + // check permission on the project grant + if grantID != "" { + return permissionCheck(ctx, domain.PermissionUserGrantRead, resourceOwner, grantID) + } + // check on project + return permissionCheck(ctx, domain.PermissionUserGrantRead, resourceOwner, projectID) +} + type UserGrantsQueries struct { SearchRequest Queries []SearchQuery @@ -70,6 +93,21 @@ func (q *UserGrantsQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { return query } +func userGrantPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserGrantsQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + UserGrantResourceOwner, + domain.PermissionUserGrantRead, + SingleOrgPermissionOption(queries.Queries), + WithProjectsPermissionOption(UserGrantProjectID), + OwnedRowsPermissionOption(UserGrantUserID), + ) + return query.JoinClause(join, args...) +} + func NewUserGrantUserIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantUserID, id, TextEquals) } @@ -86,6 +124,10 @@ func NewUserGrantResourceOwnerSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantResourceOwner, id, TextEquals) } +func NewUserGrantUserResourceOwnerSearchQuery(id string) (SearchQuery, error) { + return NewTextQuery(UserResourceOwnerCol, id, TextEquals) +} + func NewUserGrantGrantIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantGrantID, id, TextEquals) } @@ -94,6 +136,14 @@ func NewUserGrantIDSearchQuery(id string) (SearchQuery, error) { return NewTextQuery(UserGrantID, id, TextEquals) } +func NewUserGrantInIDsSearchQuery(ids []string) (SearchQuery, error) { + list := make([]interface{}, len(ids)) + for i, value := range ids { + list[i] = value + } + return NewListQuery(UserGrantID, list, ListIn) +} + func NewUserGrantUserTypeQuery(typ domain.UserType) (SearchQuery, error) { return NewNumberQuery(UserTypeCol, typ, NumberEquals) } @@ -254,7 +304,19 @@ func (q *Queries) UserGrant(ctx context.Context, shouldTriggerBulk bool, queries return grant, err } -func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool) (grants *UserGrants, err error) { +func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool, permissionCheck domain.PermissionCheck) (*UserGrants, error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + grants, err := q.userGrants(ctx, queries, shouldTriggerBulk, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + userGrantsCheckPermission(ctx, grants, permissionCheck) + } + return grants, nil +} + +func (q *Queries) userGrants(ctx context.Context, queries *UserGrantsQueries, shouldTriggerBulk bool, permissionCheckV2 bool) (grants *UserGrants, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -266,6 +328,7 @@ func (q *Queries) UserGrants(ctx context.Context, queries *UserGrantsQueries, sh } query, scan := prepareUserGrantsQuery() + query = userGrantPermissionCheckV2(ctx, query, permissionCheckV2, queries) eq := sq.Eq{UserGrantInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -316,6 +379,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro UserGrantProjectID.identifier(), ProjectColumnName.identifier(), + ProjectColumnResourceOwner.identifier(), GrantedOrgColumnId.identifier(), GrantedOrgColumnName.identifier(), @@ -326,7 +390,8 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro LeftJoin(join(HumanUserIDCol, UserGrantUserID)). LeftJoin(join(OrgColumnID, UserGrantResourceOwner)). LeftJoin(join(ProjectColumnID, UserGrantProjectID)). - LeftJoin(join(GrantedOrgColumnId, UserResourceOwnerCol)). + LeftJoin(join(ProjectGrantColumnGrantID, UserGrantGrantID) + " AND " + ProjectGrantColumnProjectID.identifier() + " = " + UserGrantProjectID.identifier()). + LeftJoin(join(GrantedOrgColumnId, ProjectGrantColumnGrantedOrgID)). LeftJoin(join(LoginNameUserIDCol, UserGrantUserID)). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, @@ -348,7 +413,8 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro orgName sql.NullString orgDomain sql.NullString - projectName sql.NullString + projectName sql.NullString + projectResourceOwner sql.NullString grantedOrgID sql.NullString grantedOrgName sql.NullString @@ -381,6 +447,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro &g.ProjectID, &projectName, + &projectResourceOwner, &grantedOrgID, &grantedOrgName, @@ -405,6 +472,7 @@ func prepareUserGrantQuery() (sq.SelectBuilder, func(*sql.Row) (*UserGrant, erro g.OrgName = orgName.String g.OrgPrimaryDomain = orgDomain.String g.ProjectName = projectName.String + g.ProjectResourceOwner = projectResourceOwner.String g.GrantedOrgID = grantedOrgID.String g.GrantedOrgName = grantedOrgName.String g.GrantedOrgDomain = grantedOrgDomain.String @@ -439,6 +507,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e UserGrantProjectID.identifier(), ProjectColumnName.identifier(), + ProjectColumnResourceOwner.identifier(), GrantedOrgColumnId.identifier(), GrantedOrgColumnName.identifier(), @@ -451,7 +520,8 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e LeftJoin(join(HumanUserIDCol, UserGrantUserID)). LeftJoin(join(OrgColumnID, UserGrantResourceOwner)). LeftJoin(join(ProjectColumnID, UserGrantProjectID)). - LeftJoin(join(GrantedOrgColumnId, UserResourceOwnerCol)). + LeftJoin(join(ProjectGrantColumnGrantID, UserGrantGrantID) + " AND " + ProjectGrantColumnProjectID.identifier() + " = " + UserGrantProjectID.identifier()). + LeftJoin(join(GrantedOrgColumnId, ProjectGrantColumnGrantedOrgID)). LeftJoin(join(LoginNameUserIDCol, UserGrantUserID)). Where( sq.Eq{LoginNameIsPrimaryCol.identifier(): true}, @@ -480,7 +550,8 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e grantedOrgName sql.NullString grantedOrgDomain sql.NullString - projectName sql.NullString + projectName sql.NullString + projectResourceOwner sql.NullString ) err := rows.Scan( @@ -509,6 +580,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e &g.ProjectID, &projectName, + &projectResourceOwner, &grantedOrgID, &grantedOrgName, @@ -532,6 +604,7 @@ func prepareUserGrantsQuery() (sq.SelectBuilder, func(*sql.Rows) (*UserGrants, e g.OrgName = orgName.String g.OrgPrimaryDomain = orgDomain.String g.ProjectName = projectName.String + g.ProjectResourceOwner = projectResourceOwner.String g.GrantedOrgID = grantedOrgID.String g.GrantedOrgName = grantedOrgName.String g.GrantedOrgDomain = grantedOrgDomain.String diff --git a/internal/query/user_grant_test.go b/internal/query/user_grant_test.go index 6a640c2ef2..dde04f2c88 100644 --- a/internal/query/user_grant_test.go +++ b/internal/query/user_grant_test.go @@ -37,6 +37,7 @@ var ( ", projections.orgs1.primary_domain" + ", projections.user_grants5.project_id" + ", projections.projects4.name" + + ", projections.projects4.resource_owner" + ", granted_orgs.id" + ", granted_orgs.name" + ", granted_orgs.primary_domain" + @@ -45,7 +46,8 @@ var ( " LEFT JOIN projections.users14_humans ON projections.user_grants5.user_id = projections.users14_humans.user_id AND projections.user_grants5.instance_id = projections.users14_humans.instance_id" + " LEFT JOIN projections.orgs1 ON projections.user_grants5.resource_owner = projections.orgs1.id AND projections.user_grants5.instance_id = projections.orgs1.instance_id" + " LEFT JOIN projections.projects4 ON projections.user_grants5.project_id = projections.projects4.id AND projections.user_grants5.instance_id = projections.projects4.instance_id" + - " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.users14.resource_owner = granted_orgs.id AND projections.users14.instance_id = granted_orgs.instance_id" + + " LEFT JOIN projections.project_grants4 ON projections.user_grants5.grant_id = projections.project_grants4.grant_id AND projections.user_grants5.instance_id = projections.project_grants4.instance_id AND projections.project_grants4.project_id = projections.user_grants5.project_id" + + " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.project_grants4.granted_org_id = granted_orgs.id AND projections.project_grants4.instance_id = granted_orgs.instance_id" + " LEFT JOIN projections.login_names3 ON projections.user_grants5.user_id = projections.login_names3.user_id AND projections.user_grants5.instance_id = projections.login_names3.instance_id" + " WHERE projections.login_names3.is_primary = $1") userGrantCols = []string{ @@ -71,6 +73,7 @@ var ( "primary_domain", "project_id", "name", // project name + "resource_owner", // project_grant resource owner "id", // granted org id "name", // granted org name "primary_domain", // granted org domain @@ -98,6 +101,7 @@ var ( ", projections.orgs1.primary_domain" + ", projections.user_grants5.project_id" + ", projections.projects4.name" + + ", projections.projects4.resource_owner" + ", granted_orgs.id" + ", granted_orgs.name" + ", granted_orgs.primary_domain" + @@ -107,7 +111,8 @@ var ( " LEFT JOIN projections.users14_humans ON projections.user_grants5.user_id = projections.users14_humans.user_id AND projections.user_grants5.instance_id = projections.users14_humans.instance_id" + " LEFT JOIN projections.orgs1 ON projections.user_grants5.resource_owner = projections.orgs1.id AND projections.user_grants5.instance_id = projections.orgs1.instance_id" + " LEFT JOIN projections.projects4 ON projections.user_grants5.project_id = projections.projects4.id AND projections.user_grants5.instance_id = projections.projects4.instance_id" + - " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.users14.resource_owner = granted_orgs.id AND projections.users14.instance_id = granted_orgs.instance_id" + + " LEFT JOIN projections.project_grants4 ON projections.user_grants5.grant_id = projections.project_grants4.grant_id AND projections.user_grants5.instance_id = projections.project_grants4.instance_id AND projections.project_grants4.project_id = projections.user_grants5.project_id" + + " LEFT JOIN projections.orgs1 AS granted_orgs ON projections.project_grants4.granted_org_id = granted_orgs.id AND projections.project_grants4.instance_id = granted_orgs.instance_id" + " LEFT JOIN projections.login_names3 ON projections.user_grants5.user_id = projections.login_names3.user_id AND projections.user_grants5.instance_id = projections.login_names3.instance_id" + " WHERE projections.login_names3.is_primary = $1") userGrantsCols = append( @@ -175,6 +180,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -182,31 +188,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -239,6 +246,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -246,31 +254,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "", - LastName: "", - Email: "", - DisplayName: "", - AvatarURL: "", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "", + LastName: "", + Email: "", + DisplayName: "", + AvatarURL: "", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -303,6 +312,7 @@ func Test_UserGrantPrepares(t *testing.T) { nil, "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -310,31 +320,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "", - OrgPrimaryDomain: "", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "", + OrgPrimaryDomain: "", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -367,6 +378,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", nil, + nil, "granted-org-id", "granted-org-name", "granted-org-domain", @@ -374,31 +386,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "", + ProjectResourceOwner: "", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -431,6 +444,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -438,31 +452,32 @@ func Test_UserGrantPrepares(t *testing.T) { ), }, object: &UserGrant{ - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, { @@ -525,6 +540,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -538,31 +554,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -598,6 +615,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -611,31 +629,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "", - LastName: "", - Email: "", - DisplayName: "", - AvatarURL: "", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "", + LastName: "", + Email: "", + DisplayName: "", + AvatarURL: "", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -671,6 +690,7 @@ func Test_UserGrantPrepares(t *testing.T) { nil, "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -684,31 +704,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeMachine, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "", - OrgPrimaryDomain: "", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeMachine, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "", + OrgPrimaryDomain: "", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -744,6 +765,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", nil, + nil, "granted-org-id", "granted-org-name", "granted-org-domain", @@ -757,31 +779,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "", + ProjectResourceOwner: "", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -817,6 +840,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -830,31 +854,32 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, @@ -890,6 +915,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -917,6 +943,7 @@ func Test_UserGrantPrepares(t *testing.T) { "primary-domain", "project-id", "project-name", + "project-resource-owner", "granted-org-id", "granted-org-name", "granted-org-domain", @@ -930,58 +957,60 @@ func Test_UserGrantPrepares(t *testing.T) { }, UserGrants: []*UserGrant{ { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, { - ID: "id", - CreationDate: testNow, - ChangeDate: testNow, - Sequence: 20211111, - Roles: database.TextArray[string]{"role-key"}, - GrantID: "grant-id", - State: domain.UserGrantStateActive, - UserID: "user-id", - Username: "username", - UserType: domain.UserTypeHuman, - UserResourceOwner: "resource-owner", - FirstName: "first-name", - LastName: "last-name", - Email: "email", - DisplayName: "display-name", - AvatarURL: "avatar-key", - PreferredLoginName: "login-name", - ResourceOwner: "ro", - OrgName: "org-name", - OrgPrimaryDomain: "primary-domain", - ProjectID: "project-id", - ProjectName: "project-name", - GrantedOrgID: "granted-org-id", - GrantedOrgName: "granted-org-name", - GrantedOrgDomain: "granted-org-domain", + ID: "id", + CreationDate: testNow, + ChangeDate: testNow, + Sequence: 20211111, + Roles: database.TextArray[string]{"role-key"}, + GrantID: "grant-id", + State: domain.UserGrantStateActive, + UserID: "user-id", + Username: "username", + UserType: domain.UserTypeHuman, + UserResourceOwner: "resource-owner", + FirstName: "first-name", + LastName: "last-name", + Email: "email", + DisplayName: "display-name", + AvatarURL: "avatar-key", + PreferredLoginName: "login-name", + ResourceOwner: "ro", + OrgName: "org-name", + OrgPrimaryDomain: "primary-domain", + ProjectID: "project-id", + ProjectName: "project-name", + ProjectResourceOwner: "project-resource-owner", + GrantedOrgID: "granted-org-id", + GrantedOrgName: "granted-org-name", + GrantedOrgDomain: "granted-org-domain", }, }, }, diff --git a/internal/query/user_membership.go b/internal/query/user_membership.go index cb7588624f..069210a830 100644 --- a/internal/query/user_membership.go +++ b/internal/query/user_membership.go @@ -63,7 +63,15 @@ type MembershipSearchQuery struct { } func NewMembershipUserIDQuery(userID string) (SearchQuery, error) { - return NewTextQuery(membershipUserID.setTable(membershipAlias), userID, TextEquals) + return NewTextQuery(MembershipUserID.setTable(membershipAlias), userID, TextEquals) +} + +func NewMembershipCreationDateQuery(timestamp time.Time, comparison TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(MembershipCreationDate.setTable(membershipAlias), timestamp, comparison) +} + +func NewMembershipChangeDateQuery(timestamp time.Time, comparison TimestampComparison) (SearchQuery, error) { + return NewTimestampQuery(MembershipChangeDate.setTable(membershipAlias), timestamp, comparison) } func NewMembershipOrgIDQuery(value string) (SearchQuery, error) { @@ -137,7 +145,7 @@ func (q *Queries) Memberships(ctx context.Context, queries *MembershipSearchQuer wg.Wait() } - query, queryArgs, scan := prepareMembershipsQuery(queries) + query, queryArgs, scan := prepareMembershipsQuery(ctx, queries, false) eq := sq.Eq{membershipInstanceID.identifier(): authz.GetInstance(ctx).InstanceID()} stmt, args, err := queries.toQuery(query).Where(eq).ToSql() if err != nil { @@ -166,7 +174,7 @@ var ( name: "members", instanceIDCol: projection.MemberInstanceID, } - membershipUserID = Column{ + MembershipUserID = Column{ name: projection.MemberUserIDCol, table: membershipAlias, } @@ -174,11 +182,11 @@ var ( name: projection.MemberRolesCol, table: membershipAlias, } - membershipCreationDate = Column{ + MembershipCreationDate = Column{ name: projection.MemberCreationDate, table: membershipAlias, } - membershipChangeDate = Column{ + MembershipChangeDate = Column{ name: projection.MemberChangeDate, table: membershipAlias, } @@ -216,11 +224,11 @@ var ( } ) -func getMembershipFromQuery(queries *MembershipSearchQuery) (string, []interface{}) { - orgMembers, orgMembersArgs := prepareOrgMember(queries) - iamMembers, iamMembersArgs := prepareIAMMember(queries) - projectMembers, projectMembersArgs := prepareProjectMember(queries) - projectGrantMembers, projectGrantMembersArgs := prepareProjectGrantMember(queries) +func getMembershipFromQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { + orgMembers, orgMembersArgs := prepareOrgMember(ctx, queries, permissionV2) + iamMembers, iamMembersArgs := prepareIAMMember(ctx, queries, permissionV2) + projectMembers, projectMembersArgs := prepareProjectMember(ctx, queries, permissionV2) + projectGrantMembers, projectGrantMembersArgs := prepareProjectGrantMember(ctx, queries, permissionV2) args := make([]interface{}, 0) args = append(append(append(append(args, orgMembersArgs...), iamMembersArgs...), projectMembersArgs...), projectGrantMembersArgs...) @@ -236,13 +244,13 @@ func getMembershipFromQuery(queries *MembershipSearchQuery) (string, []interface args } -func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Memberships, error)) { - query, args := getMembershipFromQuery(queries) +func prepareMembershipsQuery(ctx context.Context, queries *MembershipSearchQuery, permissionV2 bool) (sq.SelectBuilder, []interface{}, func(*sql.Rows) (*Memberships, error)) { + query, args := getMembershipFromQuery(ctx, queries, permissionV2) return sq.Select( - membershipUserID.identifier(), + MembershipUserID.identifier(), membershipRoles.identifier(), - membershipCreationDate.identifier(), - membershipChangeDate.identifier(), + MembershipCreationDate.identifier(), + MembershipChangeDate.identifier(), membershipSequence.identifier(), membershipResourceOwner.identifier(), membershipOrgID.identifier(), @@ -257,7 +265,7 @@ func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, ).From(query). LeftJoin(join(ProjectColumnID, membershipProjectID)). LeftJoin(join(OrgColumnID, membershipOrgID)). - LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID)). + LeftJoin(join(ProjectGrantColumnGrantID, membershipGrantID) + " AND " + membershipProjectID.identifier() + " = " + ProjectGrantColumnProjectID.identifier()). LeftJoin(join(InstanceColumnID, membershipInstanceID)). PlaceholderFormat(sq.Dollar), args, @@ -340,7 +348,7 @@ func prepareMembershipsQuery(queries *MembershipSearchQuery) (sq.SelectBuilder, } } -func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareOrgMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( OrgMemberUserID.identifier(), OrgMemberRoles.identifier(), @@ -354,6 +362,7 @@ func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { "NULL::TEXT AS "+membershipProjectID.name, "NULL::TEXT AS "+membershipGrantID.name, ).From(orgMemberTable.identifier()) + builder = administratorOrgPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == orgMemberTable.name { @@ -363,7 +372,7 @@ func prepareOrgMember(query *MembershipSearchQuery) (string, []interface{}) { return builder.MustSql() } -func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareIAMMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( InstanceMemberUserID.identifier(), InstanceMemberRoles.identifier(), @@ -377,6 +386,7 @@ func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { "NULL::TEXT AS "+membershipProjectID.name, "NULL::TEXT AS "+membershipGrantID.name, ).From(instanceMemberTable.identifier()) + builder = administratorInstancePermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == instanceMemberTable.name { @@ -386,7 +396,7 @@ func prepareIAMMember(query *MembershipSearchQuery) (string, []interface{}) { return builder.MustSql() } -func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareProjectMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( ProjectMemberUserID.identifier(), ProjectMemberRoles.identifier(), @@ -400,6 +410,7 @@ func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) ProjectMemberProjectID.identifier(), "NULL::TEXT AS "+membershipGrantID.name, ).From(projectMemberTable.identifier()) + builder = administratorProjectPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == projectMemberTable.name { @@ -410,7 +421,7 @@ func prepareProjectMember(query *MembershipSearchQuery) (string, []interface{}) return builder.MustSql() } -func prepareProjectGrantMember(query *MembershipSearchQuery) (string, []interface{}) { +func prepareProjectGrantMember(ctx context.Context, query *MembershipSearchQuery, permissionV2 bool) (string, []interface{}) { builder := sq.Select( ProjectGrantMemberUserID.identifier(), ProjectGrantMemberRoles.identifier(), @@ -424,6 +435,7 @@ func prepareProjectGrantMember(query *MembershipSearchQuery) (string, []interfac ProjectGrantMemberProjectID.identifier(), ProjectGrantMemberGrantID.identifier(), ).From(projectGrantMemberTable.identifier()) + builder = administratorProjectGrantPermissionCheckV2(ctx, builder, permissionV2) for _, q := range query.Queries { if q.Col().table.name == membershipAlias.name || q.Col().table.name == projectMemberTable.name || q.Col().table.name == projectGrantMemberTable.name { diff --git a/internal/query/user_membership_test.go b/internal/query/user_membership_test.go index b0170182d1..ce48770fd8 100644 --- a/internal/query/user_membership_test.go +++ b/internal/query/user_membership_test.go @@ -1,6 +1,7 @@ package query import ( + "context" "database/sql" "database/sql/driver" "errors" @@ -85,7 +86,7 @@ var ( ") AS members" + " LEFT JOIN projections.projects4 ON members.project_id = projections.projects4.id AND members.instance_id = projections.projects4.instance_id" + " LEFT JOIN projections.orgs1 ON members.org_id = projections.orgs1.id AND members.instance_id = projections.orgs1.instance_id" + - " LEFT JOIN projections.project_grants4 ON members.grant_id = projections.project_grants4.grant_id AND members.instance_id = projections.project_grants4.instance_id" + + " LEFT JOIN projections.project_grants4 ON members.grant_id = projections.project_grants4.grant_id AND members.instance_id = projections.project_grants4.instance_id AND members.project_id = projections.project_grants4.project_id" + " LEFT JOIN projections.instances ON members.instance_id = projections.instances.id") membershipCols = []string{ "user_id", @@ -461,7 +462,7 @@ func Test_MembershipPrepares(t *testing.T) { func prepareMembershipWrapper() func() (sq.SelectBuilder, func(*sql.Rows) (*Memberships, error)) { return func() (sq.SelectBuilder, func(*sql.Rows) (*Memberships, error)) { - builder, _, fun := prepareMembershipsQuery(&MembershipSearchQuery{}) + builder, _, fun := prepareMembershipsQuery(context.Background(), &MembershipSearchQuery{}, false) return builder, fun } } diff --git a/internal/query/user_metadata.go b/internal/query/user_metadata.go index 534c707593..385c176e0a 100644 --- a/internal/query/user_metadata.go +++ b/internal/query/user_metadata.go @@ -4,12 +4,14 @@ import ( "context" "database/sql" "errors" + "slices" "time" sq "github.com/Masterminds/squirrel" "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/eventstore/handler/v2" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -36,6 +38,28 @@ type UserMetadataSearchQueries struct { Queries []SearchQuery } +func userMetadataCheckPermission(ctx context.Context, userMetadataList *UserMetadataList, permissionCheck domain.PermissionCheck) { + userMetadataList.Metadata = slices.DeleteFunc(userMetadataList.Metadata, + func(userMetadata *UserMetadata) bool { + return userCheckPermission(ctx, userMetadata.ResourceOwner, userMetadata.UserID, permissionCheck) != nil + }, + ) +} + +func userMetadataPermissionCheckV2(ctx context.Context, query sq.SelectBuilder, enabled bool, queries *UserMetadataSearchQueries) sq.SelectBuilder { + if !enabled { + return query + } + join, args := PermissionClause( + ctx, + UserMetadataResourceOwnerCol, + domain.PermissionUserRead, + SingleOrgPermissionOption(queries.Queries), + OwnedRowsPermissionOption(UserMetadataUserIDCol), + ) + return query.JoinClause(join, args...) +} + var ( userMetadataTable = table{ name: projection.UserMetadataProjectionTable, @@ -139,7 +163,19 @@ func (q *Queries) SearchUserMetadataForUsers(ctx context.Context, shouldTriggerB return metadata, err } -func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, withOwnerRemoved bool) (metadata *UserMetadataList, err error) { +func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, permissionCheck domain.PermissionCheck) (metadata *UserMetadataList, err error) { + permissionCheckV2 := PermissionV2(ctx, permissionCheck) + users, err := q.searchUserMetadata(ctx, shouldTriggerBulk, userID, queries, permissionCheckV2) + if err != nil { + return nil, err + } + if permissionCheck != nil && !authz.GetFeatures(ctx).PermissionCheckV2 { + userMetadataCheckPermission(ctx, users, permissionCheck) + } + return users, nil +} + +func (q *Queries) searchUserMetadata(ctx context.Context, shouldTriggerBulk bool, userID string, queries *UserMetadataSearchQueries, permissionCheckV2 bool) (metadata *UserMetadataList, err error) { ctx, span := tracing.NewSpan(ctx) defer func() { span.EndWithError(err) }() @@ -151,6 +187,7 @@ func (q *Queries) SearchUserMetadata(ctx context.Context, shouldTriggerBulk bool } query, scan := prepareUserMetadataListQuery() + query = userMetadataPermissionCheckV2(ctx, query, permissionCheckV2, queries) eq := sq.Eq{ UserMetadataUserIDCol.identifier(): userID, UserMetadataInstanceIDCol.identifier(): authz.GetInstance(ctx).InstanceID(), diff --git a/internal/repository/org/aggregate.go b/internal/repository/org/aggregate.go index 58121afbcb..ff9be33085 100644 --- a/internal/repository/org/aggregate.go +++ b/internal/repository/org/aggregate.go @@ -1,6 +1,8 @@ package org import ( + "context" + "github.com/zitadel/zitadel/internal/eventstore" ) @@ -27,3 +29,7 @@ func NewAggregate(id string) *Aggregate { }, } } + +func AggregateFromWriteModel(ctx context.Context, wm *eventstore.WriteModel) *eventstore.Aggregate { + return eventstore.AggregateFromWriteModelCtx(ctx, wm, AggregateType, AggregateVersion) +} diff --git a/internal/serviceping/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) +} diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index a07ea36dc3..076577f3fa 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -448,8 +448,6 @@ Errors: Invalid: Потребителското разрешение е невалидно NotChanged: Потребителското разрешение не е променено IDMissing: ID липсва - NotActive: Потребителското разрешение не е активно - NotInactive: Предоставянето на потребител не е деактивирано NoPermissionForProject: Потребителят няма разрешения за този проект RoleKeyNotFound: Ролята не е намерена Member: diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index 6e1e939687..45ca1d8a37 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Uživatelský grant je neplatný NotChanged: Uživatelský grant nebyl změněn IDMissing: Chybí Id - NotActive: Uživatelský grant není aktivní - NotInactive: Uživatelský grant není deaktivován NoPermissionForProject: Uživatel nemá na tomto projektu žádná oprávnění RoleKeyNotFound: Role nenalezena Member: diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 79efcd128e..cbcb61febd 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Benutzer Berechtigung ist ungültig NotChanged: Benutzer Berechtigung wurde nicht verändert IDMissing: ID fehlt - NotActive: Benutzer Berechtigung ist nicht aktiv - NotInactive: Benutzer Berechtigung ist nicht deaktiviert NoPermissionForProject: Benutzer hat keine Rechte auf diesem Projekt RoleKeyNotFound: Rolle konnte nicht gefunden werden Member: diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index de019d734e..581e3426d5 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -437,8 +437,6 @@ Errors: Invalid: User grant is invalid NotChanged: User grant has not been changed IDMissing: Id missing - NotActive: User grant is not active - NotInactive: User grant is not deactivated NoPermissionForProject: User has no permissions on this project RoleKeyNotFound: Role not found Member: diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 284d4dd90d..71c4cdc595 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -436,8 +436,6 @@ Errors: Invalid: La concesión de usuario no es válida NotChanged: La concesión de usuario no ha cambiado IDMissing: Falta Id - NotActive: La concesión de usuario no está activa - NotInactive: La concesión de usuario no está inactiva NoPermissionForProject: El usuario no tiene permisos en este proyecto RoleKeyNotFound: Rol no encontrado Member: diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 0e6a48a5bb..4c038f7804 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -436,8 +436,6 @@ Errors: Invalid: La subvention d'utilisateur n'est pas valide NotChanged: L'autorisation de l'utilisateur n'a pas été modifiée. IDMissing: Id manquant - NotActive: La subvention de l'utilisateur n'est pas active - NotInactive: La subvention à l'utilisateur n'est pas désactivée NoPermissionForProject: L'utilisateur n'a aucune autorisation pour ce projet RoleKeyNotFound: Rôle non trouvé Member: diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index b18e56fc1a..7aa276c140 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -436,8 +436,6 @@ Errors: Invalid: A felhasználói jogosultság érvénytelen NotChanged: A felhasználói jogosultság nem lett módosítva IDMissing: Hiányzó azonosító - NotActive: A felhasználói jogosultság nem aktív - NotInactive: A felhasználói jogosultság nincs kikapcsolva NoPermissionForProject: A felhasználónak nincs jogosultsága ebben a projektben RoleKeyNotFound: Szerepkör nem található Member: diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index ea3602b4f3..8ea9a1eb8b 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Hibah pengguna tidak valid NotChanged: Hibah pengguna belum diubah IDMissing: Aku hilang - NotActive: Hibah pengguna tidak aktif - NotInactive: Hibah pengguna tidak dinonaktifkan NoPermissionForProject: Pengguna tidak memiliki izin pada proyek ini RoleKeyNotFound: Peran tidak ditemukan Member: diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 70878e5efe..903e948aa6 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -436,8 +436,6 @@ Errors: Invalid: User Grant non è valido NotChanged: User Grant non è stata cambiato IDMissing: ID mancante - NotActive: User Grant non è attivo - NotInactive: User Grant non è disattivato NoPermissionForProject: L'utente non ha permessi su questo progetto RoleKeyNotFound: Ruolo non trovato Member: diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index ad49cda89a..5ca2c920fa 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -437,8 +437,6 @@ Errors: Invalid: 無効なユーザーグラントです NotChanged: ユーザーグラントは変更されていません IDMissing: IDがありません - NotActive: ユーザーグラントはアクティブではありません - NotInactive: ユーザーグラントは非アクティブではありません NoPermissionForProject: ユーザーにはこのプロジェクトに許可がありません RoleKeyNotFound: ロールが見つかりません Member: diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 60c5d37917..729494c1f9 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -437,8 +437,6 @@ Errors: Invalid: 사용자 권한이 유효하지 않습니다 NotChanged: 사용자 권한이 변경되지 않았습니다 IDMissing: ID가 누락되었습니다 - NotActive: 사용자 권한이 활성 상태가 아닙니다 - NotInactive: 사용자 권한이 비활성 상태가 아닙니다 NoPermissionForProject: 사용자가 이 프로젝트에 대한 권한이 없습니다 RoleKeyNotFound: 역할을 찾을 수 없습니다 Member: diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 0636ea88d7..9ef147696d 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -435,8 +435,6 @@ Errors: Invalid: Овластувањето на корисникот е невалидно NotChanged: Овластувањето на корисникот не е променето IDMissing: ID недостасува - NotActive: Овластувањето на корисникот не е активно - NotInactive: Овластувањето на корисникот не е неактивно NoPermissionForProject: Корисникот нема овластувања за овој проект RoleKeyNotFound: Улогата не е пронајдена Member: diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index fba19c04e1..ddc831b3d4 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Gebruikerstoekenning is ongeldig NotChanged: Gebruikerstoekenning is niet veranderd IDMissing: ID ontbreekt - NotActive: Gebruikerstoekenning is niet actief - NotInactive: Gebruikerstoekenning is niet gedeactiveerd NoPermissionForProject: Gebruiker heeft geen rechten op dit project RoleKeyNotFound: Rol niet gevonden Member: diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index df8065f36b..04d3c64fa3 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Uprawnienie użytkownika jest nieprawidłowe NotChanged: Uprawnienie użytkownika nie zostało zmienione IDMissing: Brak ID - NotActive: Uprawnienie użytkownika nie jest aktywne - NotInactive: Uprawnienie użytkownika nie jest dezaktywowane NoPermissionForProject: Użytkownik nie ma uprawnień do tego projektu RoleKeyNotFound: Rola nie znaleziona Member: diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index 584a2bf048..73571476bd 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -435,8 +435,6 @@ Errors: Invalid: A concessão de usuário é inválida NotChanged: A concessão de usuário não foi alterada IDMissing: ID faltando - NotActive: A concessão de usuário não está ativa - NotInactive: A concessão de usuário não está desativada NoPermissionForProject: O usuário não possui permissões neste projeto RoleKeyNotFound: Função não encontrada Member: diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 56b8dba6d3..fede8eb85a 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -437,8 +437,6 @@ Errors: Invalid: Acordarea utilizatorului este invalidă NotChanged: Acordarea utilizatorului nu a fost schimbată IDMissing: Id lipsă - NotActive: Acordarea utilizatorului nu este activă - NotInactive: Acordarea utilizatorului nu este dezactivată NoPermissionForProject: Utilizatorul nu are permisiuni pentru acest proiect RoleKeyNotFound: Rolul nu a fost găsit Member: diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index c10219aac8..39654d8b12 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -430,8 +430,6 @@ Errors: Invalid: Допуск пользователя недействителен NotChanged: Допуск пользователя не был изменён IDMissing: ID отсутствует - NotActive: Допуск пользователя неактивен - NotInactive: Допуск пользователя не деактивирован NoPermissionForProject: Пользователь не имеет прав доступа к данному проекту RoleKeyNotFound: Роль не найдена Member: diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index 7f67a10463..daf8da5cf2 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -436,8 +436,6 @@ Errors: Invalid: Användarbeviljandet är ogiltigt NotChanged: Användarbeviljandet har inte ändrats IDMissing: Id saknas - NotActive: Användarbeviljandet är inte aktivt - NotInactive: Användarbeviljandet är inte inaktivt NoPermissionForProject: Användaren har inga behörigheter i detta projekt RoleKeyNotFound: Rollen hittades inte Member: diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 117f6096fc..fcb0257ffa 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -436,8 +436,6 @@ Errors: Invalid: 用户授权无效 NotChanged: 用户授权未更改 IDMissing: 没有 ID - NotActive: 用户授权不是启用状态 - NotInactive: 用户授权不是停用状态 NoPermissionForProject: 用户对此项目没有权限 RoleKeyNotFound: 角色不存在 Member: diff --git a/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/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/app/(login)/saml-post/route.ts b/login/apps/login/src/app/(login)/saml-post/route.ts index f2834f3884..a2061a18e2 100644 --- a/login/apps/login/src/app/(login)/saml-post/route.ts +++ b/login/apps/login/src/app/(login)/saml-post/route.ts @@ -1,22 +1,41 @@ +import { getSAMLFormCookie } from "@/lib/saml"; import { NextRequest, NextResponse } from "next/server"; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const url = searchParams.get("url"); - const relayState = searchParams.get("RelayState"); - const samlResponse = searchParams.get("SAMLResponse"); + const id = searchParams.get("id"); - if (!url || !relayState || !samlResponse) { - return new NextResponse("Missing required parameters", { status: 400 }); + if (!url) { + return new NextResponse("Missing url parameter", { status: 400 }); } + if (!id) { + return new NextResponse("Missing id parameter", { status: 400 }); + } + + const formData = await getSAMLFormCookie(id); + + const formDataParsed = formData ? JSON.parse(formData) : null; + + if (!formDataParsed) { + return new NextResponse("SAML form data not found", { status: 404 }); + } + + // Generate hidden input fields for all key-value pairs in formDataParsed + const hiddenInputs = Object.entries(formDataParsed) + .map( + ([key, value]) => + ``, + ) + .join("\n "); + // Respond with an HTML form that auto-submits via POST const html = `

- - + ${hiddenInputs} diff --git a/login/apps/login/src/app/login/route.ts b/login/apps/login/src/app/login/route.ts index db67efa229..7b57e1a5e9 100644 --- a/login/apps/login/src/app/login/route.ts +++ b/login/apps/login/src/app/login/route.ts @@ -520,16 +520,24 @@ export async function GET(request: NextRequest) { if (url && binding.case === "redirect") { return NextResponse.redirect(url); } else if (url && binding.case === "post") { - const redirectUrl = constructUrl(request, "/saml-post"); + // Create HTML form that auto-submits via POST and escape the SAML cookie + const html = ` + + + + + + +
+ + + `; - redirectUrl.searchParams.set("url", url); - redirectUrl.searchParams.set("RelayState", binding.value.relayState); - redirectUrl.searchParams.set( - "SAMLResponse", - binding.value.samlResponse, - ); - - return NextResponse.redirect(redirectUrl.toString()); + return new NextResponse(html, { + headers: { "Content-Type": "text/html" }, + }); } else { console.log( "could not create response, redirect user to choose other account", diff --git a/login/apps/login/src/lib/saml.ts b/login/apps/login/src/lib/saml.ts index e85084f022..e1b5f4c080 100644 --- a/login/apps/login/src/lib/saml.ts +++ b/login/apps/login/src/lib/saml.ts @@ -4,7 +4,9 @@ import { createResponse, getLoginSettings } from "@/lib/zitadel"; import { create } from "@zitadel/client"; import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; +import { v4 as uuidv4 } from "uuid"; import { constructUrl } from "./service-url"; import { isSessionValid } from "./session"; @@ -17,6 +19,37 @@ type LoginWithSAMLAndSession = { request: NextRequest; }; +export async function getSAMLFormUID() { + return uuidv4(); +} + +export async function setSAMLFormCookie(value: string): Promise { + const cookiesList = await cookies(); + + const uid = await getSAMLFormUID(); + + await cookiesList.set({ + name: uid, + value: value, + httpOnly: true, + path: "/", + maxAge: 5 * 60, // 5 minutes + }); + + return uid; +} + +export async function getSAMLFormCookie(uid: string): Promise { + const cookiesList = await cookies(); + + const cookie = cookiesList.get(uid); + if (!cookie || !cookie.value) { + return null; + } + + return cookie.value; +} + export async function loginWithSAMLAndSession({ serviceUrl, samlRequest, 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..d8b4e5fb51 100644 --- a/login/apps/login/src/lib/zitadel.ts +++ b/login/apps/login/src/lib/zitadel.ts @@ -52,6 +52,7 @@ import { } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; import { unstable_cacheLife as cacheLife } from "next/cache"; import { getUserAgent } from "./fingerprint"; +import { setSAMLFormCookie } from "./saml"; import { createServiceForHost } from "./service"; const useCache = process.env.DEBUG !== "true"; @@ -854,15 +855,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 +871,7 @@ export async function searchUsers({ query: { case: "orQuery", value: { - queries: emailAndPhoneOrQueries, + queries: orQuery, }, }, }), @@ -903,7 +904,7 @@ export async function searchUsers({ } if (emailOrPhoneResult.result.length == 1) { - return loginNameResult; + return emailOrPhoneResult; } return { error: "User not found in the system" }; @@ -981,18 +982,15 @@ export async function startIdentityProviderFlow({ value: urls, }, }) - .then((resp) => { + .then(async (resp) => { if (resp.nextStep.case === "authUrl" && resp.nextStep.value) { return resp.nextStep.value; } else if (resp.nextStep.case === "formData" && resp.nextStep.value) { const formData: FormData = resp.nextStep.value; const redirectUrl = "/saml-post"; - const params = new URLSearchParams({ url: formData.url }); - - Object.entries(formData.fields).forEach(([k, v]) => { - params.append(k, v); - }); + const dataId = await setSAMLFormCookie(JSON.stringify(formData.fields)); + const params = new URLSearchParams({ url: formData.url, id: dataId }); return `${redirectUrl}?${params.toString()}`; } else { 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" ] diff --git a/pkg/grpc/app/v2beta/application.go b/pkg/grpc/app/v2beta/application.go index bbce4289f9..5cef08db46 100644 --- a/pkg/grpc/app/v2beta/application.go +++ b/pkg/grpc/app/v2beta/application.go @@ -2,4 +2,4 @@ package app type ApplicationConfig = isApplication_Config -type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata \ No newline at end of file +type MetaType = isUpdateSAMLApplicationConfigurationRequest_Metadata diff --git a/pkg/grpc/internal_permission/v2beta/resource.go b/pkg/grpc/internal_permission/v2beta/resource.go new file mode 100644 index 0000000000..a57f60242e --- /dev/null +++ b/pkg/grpc/internal_permission/v2beta/resource.go @@ -0,0 +1,3 @@ +package internal_permission + +type Resource = isAdministrator_Resource diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto index 0ee6ad86d8..c995bce16a 100644 --- a/proto/zitadel/auth.proto +++ b/proto/zitadel/auth.proto @@ -859,6 +859,11 @@ service AuthService { }; } + // List My Authorizations / User Grants + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter with your users ID to search for your authorizations on granted and owned projects. + // + // Returns a list of the authorizations/user grants the authenticated user has. User grants consist of an organization, a project and 1-n roles. rpc ListMyUserGrants(ListMyUserGrantsRequest) returns (ListMyUserGrantsResponse) { option (google.api.http) = { post: "/usergrants/me/_search" @@ -869,9 +874,8 @@ service AuthService { }; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { - tags: "User Authorizations/Grants" - summary: "List My Authorizations/Grants"; - description: "Returns a list of the authorizations/user grants the authenticated user has. User grants consist of an organization, a project and 1-n roles." + tags: "User Authorizations/Grants"; + deprecated: true; }; } @@ -908,6 +912,11 @@ service AuthService { }; } + // List My Project Roles + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter with your users ID and the project ID filter to search for your authorizations on a granted and an owned project. + // + // Returns a list of roles for the authenticated user and for the requesting project. rpc ListMyProjectPermissions(ListMyProjectPermissionsRequest) returns (ListMyProjectPermissionsResponse) { option (google.api.http) = { post: "/permissions/me/_search" @@ -919,8 +928,7 @@ service AuthService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Authorizations/Grants" - summary: "List My Project Roles"; - description: "Returns a list of roles for the authenticated user and for the requesting project (based on the token)." + deprecated: true; }; } diff --git a/proto/zitadel/authorization/v2beta/authorization.proto b/proto/zitadel/authorization/v2beta/authorization.proto new file mode 100644 index 0000000000..aedd4c8b3c --- /dev/null +++ b/proto/zitadel/authorization/v2beta/authorization.proto @@ -0,0 +1,181 @@ +syntax = "proto3"; + +package zitadel.authorization.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; + +import "zitadel/filter/v2beta/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta;authorization"; + +message Authorization { + // ID is the unique identifier of the authorization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + + // ID is the unique identifier of the project the user was granted the authorization for. + string project_id = 2; + // Name is the name of the project the user was granted the authorization for. + string project_name = 3; + // OrganizationID is the ID of the organization the project belongs to. + string project_organization_id = 4; + // ID of the granted project, only provided if it is a granted project. + optional string project_grant_id = 5; + // ID of the organization the project is granted to, only provided if it is a granted project. + optional string granted_organization_id = 6; + + // The unique identifier of the organization the authorization belongs to. + string organization_id = 7 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // CreationDate is the timestamp when the authorization was created. + google.protobuf.Timestamp creation_date = 8 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; + // ChangeDate is the timestamp when the authorization was last updated. + // In case the authorization was not updated, this field is equal to the creation date. + google.protobuf.Timestamp change_date = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + // State is the current state of the authorization. + State state = 10; + User user = 11; + // Roles contains the roles the user was granted for the project. + repeated string roles = 12; +} + +enum State { + STATE_UNSPECIFIED = 0; + // An active authorization grants the user access with the roles specified on the project. + STATE_ACTIVE = 1; + // An inactive authorization temporarily deactivates the granted access and roles. + // ZITADEL will not include the specific authorization in any authorization information like an access token. + // But the information can still be accessed using the API. + STATE_INACTIVE = 2; +} + +message User { + // ID represents the ID of the user who was granted the authorization. + string id = 1; + // PreferredLoginName represents the preferred login name of the granted user. + string preferred_login_name = 2; + // DisplayName represents the public display name of the granted user. + string display_name = 3; + // AvatarURL is the URL to the user's public avatar image. + string avatar_url = 4; + // The organization the user belong to. + // This does not have to correspond with the authorizations organization. + string organization_id = 5; +} + +message AuthorizationsSearchFilter { + oneof filter { + option (validate.required) = true; + + // Search for authorizations by their IDs. + zitadel.filter.v2beta.InIDsFilter authorization_ids = 1; + // Search for an organizations authorizations by its ID. + zitadel.filter.v2beta.IDFilter organization_id = 2; + // Search for authorizations by their state. + StateQuery state = 3; + // Search for authorizations by the ID of the user who was granted the authorization. + zitadel.filter.v2beta.IDFilter user_id = 4; + // Search for authorizations by the ID of the organisation the user is part of. + zitadel.filter.v2beta.IDFilter user_organization_id = 5; + // Search for authorizations by the preferred login name of the granted user. + UserPreferredLoginNameQuery user_preferred_login_name = 6; + // Search for authorizations by the public display name of the granted user. + UserDisplayNameQuery user_display_name = 7; + // Search for authorizations by the ID of the project the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + zitadel.filter.v2beta.IDFilter project_id = 8; + // Search for authorizations by the name of the project the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + ProjectNameQuery project_name = 9; + // Search for authorizations by the key of the role the user was granted. + RoleKeyQuery role_key = 10; + // Search for authorizations by the ID of the project grant the user was granted the authorization for. + // This will also include authorizations granted for project grants of the same project. + zitadel.filter.v2beta.IDFilter project_grant_id = 11; + } +} + +message StateQuery { + // Specify the state of the authorization to search for. + State state = 1 [(validate.rules).enum = {defined_only: true, not_in: [0]}]; +} + +message UserPreferredLoginNameQuery { + // Specify the preferred login name of the granted user to search for. + string login_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the preferred login name. Default is EQUAL. + // For example, to search for all authorizations granted to a user with + // a preferred login name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message UserDisplayNameQuery { + // Specify the public display name of the granted user to search for. + string display_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the display name. Default is EQUAL. + // For example, to search for all authorizations granted to a user with + // a display name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message ProjectNameQuery { + // Specify the name of the project the user was granted the authorization for to search for. + // Note that this will also include authorizations granted for project grants of the same project. + string name = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the project name. Default is EQUAL. + // For example, to search for all authorizations granted on a project with + // a name containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message OrganizationNameQuery { + // Specify the name of the organization the authorization was granted for to search for. + // This can either be the organization the project or the project grant is part of. + string name = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the organization name. Default is EQUAL. + // For example, to search for all authorizations with an organization name containing a specific string, + // use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message RoleKeyQuery { + // Specify the key of the role the user was granted to search for. + string key = 1 [(validate.rules).string = {max_len: 200}]; + // Specify the method to search for the role key. Default is EQUAL. + // For example, to search for all authorizations starting with a specific role key, + // use STARTS_WITH or STARTS_WITH_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +enum AuthorizationFieldName { + AUTHORIZATION_FIELD_NAME_UNSPECIFIED = 0; + AUTHORIZATION_FIELD_NAME_CREATED_DATE = 1; + AUTHORIZATION_FIELD_NAME_CHANGED_DATE = 2; + AUTHORIZATION_FIELD_NAME_ID = 3; + AUTHORIZATION_FIELD_NAME_USER_ID = 4; + AUTHORIZATION_FIELD_NAME_PROJECT_ID = 5; + AUTHORIZATION_FIELD_NAME_ORGANIZATION_ID = 6; + AUTHORIZATION_FIELD_NAME_USER_ORGANIZATION_ID = 7; +} diff --git a/proto/zitadel/authorization/v2beta/authorization_service.proto b/proto/zitadel/authorization/v2beta/authorization_service.proto new file mode 100644 index 0000000000..5020154883 --- /dev/null +++ b/proto/zitadel/authorization/v2beta/authorization_service.proto @@ -0,0 +1,456 @@ +syntax = "proto3"; + +package zitadel.authorization.v2beta; + +import "protoc-gen-openapiv2/options/annotations.proto"; +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "google/api/annotations.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; +import "zitadel/authorization/v2beta/authorization.proto"; +import "zitadel/filter/v2beta/filter.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/authorization/v2beta;authorization"; + +// AuthorizationService provides methods to manage authorizations for users within your projects and applications. +// +// For managing permissions and roles for ZITADEL internal resources, like organizations, projects, +// users, etc., please use the InternalPermissionService. +service AuthorizationService { + + // List Authorizations + // + // ListAuthorizations returns all authorizations matching the request and necessary permissions. + // + // Required permissions: + // - "user.grant.read" + // - no permissions required for listing own authorizations + rpc ListAuthorizations(ListAuthorizationsRequest) returns (ListAuthorizationsResponse) { + option (google.api.http) = { + // The only reason why it is used here is to avoid a conflict with the ListUsers endpoint, which already handles POST /v2/users. + post: "/v2beta/authorizations/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all authorizations matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // Create Authorization + // + // CreateAuthorization creates a new authorization for a user in an owned or granted project. + // + // Required permissions: + // - "user.grant.write" + rpc CreateAuthorization(CreateAuthorizationRequest) returns (CreateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The newly created authorization"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid create request"; + }; + }; + responses: { + key: "409" + value: { + description: "The authorization already exists."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + }; + }; + } + + // Update Authorization + // + // UpdateAuthorization updates the authorization. + // + // Note that any role keys previously granted to the user and not present in the request will be revoked. + // + // Required permissions: + // - "user.grant.write" + rpc UpdateAuthorization(UpdateAuthorizationRequest) returns (UpdateAuthorizationResponse) { + option (google.api.http) = { + patch: "/v2beta/authorizations/{id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "OK"; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization or one of the roles do not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + } + } + }; + } + + // Delete Authorization + // + // DeleteAuthorization deletes the authorization. + // + // In case the authorization is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the deletion date in the response to verify if the authorization was deleted by the request. + // + // Required permissions: + // - "user.grant.delete" + rpc DeleteAuthorization(DeleteAuthorizationRequest) returns (DeleteAuthorizationResponse) { + option (google.api.http) = { + delete: "/v2beta/authorizations/{id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was deleted successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // Activate Authorization + // + // ActivateAuthorization activates an existing but inactive authorization. + // + // In case the authorization is already active, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the authorization was activated by the request. + // + // Required permissions: + // - "user.grant.write" + rpc ActivateAuthorization(ActivateAuthorizationRequest) returns (ActivateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations/{id}/activate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was activated successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } + + // Deactivate Authorization + // + // DeactivateAuthorization deactivates an existing and active authorization. + // + // In case the authorization is already inactive, the request will return a successful response as + // the desired state is already achieved. + // You can check the change date in the response to verify if the authorization was deactivated by the request. + // + // Required permissions: + // - "user.grant.write" + rpc DeactivateAuthorization(DeactivateAuthorizationRequest) returns (DeactivateAuthorizationResponse) { + option (google.api.http) = { + post: "/v2beta/authorizations/{id}/deactivate" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "The authorization was deactivated successfully."; + }; + }; + responses: { + key: "404"; + value: { + description: "Authorization not found."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + }; + }; + }; + }; + }; + } +} + +message ListAuthorizationsRequest { + // Paginate through the results using a limit, offset and sorting. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional AuthorizationFieldName sorting_column = 2 [ + (validate.rules).enum = {defined_only: true}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"AUTHORIZATION_FIELD_NAME_CREATED_DATE\"" + } + ]; + // Define the criteria to query for. + repeated AuthorizationsSearchFilter filters = 3; +} + +message ListAuthorizationsResponse { + // Details contains the pagination information. + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated Authorization authorizations = 2; +} + +message CreateAuthorizationRequest { + // UserID is the ID of the user who should be granted the authorization. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // Project ID is the ID of the project the user should be authorized for. + string project_id = 2 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // OrganizationID is the ID of the organization on which the authorization should be created. + // The organization must either own the project or have a grant for the project. + // If omitted, the authorization is created on the projects organization. + optional string organization_id = 3 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // RoleKeys are the keys of the roles the user should be granted. + repeated string role_keys = 4 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "[\"user\",\"admin\"]"; + } + ]; +} + +message CreateAuthorizationResponse { + // ID is the unique identifier of the newly created authorization. + string id = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"69629012906488334\""; + } + ]; + // CreationDate is the timestamp when the authorization was created. + google.protobuf.Timestamp creation_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message UpdateAuthorizationRequest { + // ID is the unique identifier of the authorization. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; + // RoleKeys are the keys of the roles the user should be granted. + // Note that any role keys previously granted to the user and not present in the list will be revoked. + repeated string role_keys = 2 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "[\"user\",\"admin\"]"; + } + ]; +} + +message UpdateAuthorizationResponse { + // ChangeDate is the timestamp when the authorization was last updated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message DeleteAuthorizationRequest { + // ID is the unique identifier of the authorization that should be deleted. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message DeleteAuthorizationResponse { + // DeletionDate is the timestamp when the authorization was deleted. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message ActivateAuthorizationRequest { + // ID is the unique identifier of the authorization that should be activated. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message ActivateAuthorizationResponse { + // ChangeDate is the last timestamp when the authorization was changed / activated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} + +message DeactivateAuthorizationRequest { + // ID is the unique identifier of the authorization that should be deactivated. + string id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"163840776835432345\""; + } + ]; +} + +message DeactivateAuthorizationResponse { + // ChangeDate is the last timestamp when the authorization was changed / deactivated. + google.protobuf.Timestamp change_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2024-12-18T07:50:47.492Z\""; + } + ]; +} diff --git a/proto/zitadel/filter/v2beta/filter.proto b/proto/zitadel/filter/v2beta/filter.proto index 2265fa4125..098808fed0 100644 --- a/proto/zitadel/filter/v2beta/filter.proto +++ b/proto/zitadel/filter/v2beta/filter.proto @@ -86,7 +86,18 @@ message TimestampFilter { message InIDsFilter { // Defines the ids to query for. repeated string ids = 1 [ + (validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; example: "[\"69629023906488334\",\"69622366012355662\"]"; } ]; diff --git a/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto b/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto new file mode 100644 index 0000000000..3a27b89a4f --- /dev/null +++ b/proto/zitadel/internal_permission/v2beta/internal_permission_service.proto @@ -0,0 +1,384 @@ +syntax = "proto3"; + +package zitadel.internal_permission.v2beta; + +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/struct.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; + +import "zitadel/protoc_gen_zitadel/v2/options.proto"; + +import "google/protobuf/timestamp.proto"; +import "zitadel/filter/v2beta/filter.proto"; +import "zitadel/internal_permission/v2beta/query.proto"; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta;internal_permission"; + +option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { + info: { + title: "Internal Permission Service"; + version: "2.0-beta"; + description: "This API is intended to manage internal permissions in ZITADEL. This service is in beta state. It can AND will continue breaking until a stable version is released."; + contact:{ + name: "ZITADEL" + url: "https://zitadel.com" + email: "hi@zitadel.com" + } + license: { + name: "Apache 2.0", + url: "https://github.com/zitadel/zitadel/blob/main/LICENSING.md"; + }; + }; + schemes: HTTPS; + schemes: HTTP; + + consumes: "application/json"; + consumes: "application/grpc"; + + produces: "application/json"; + produces: "application/grpc"; + + consumes: "application/grpc-web+proto"; + produces: "application/grpc-web+proto"; + + host: "$CUSTOM-DOMAIN"; + base_path: "/"; + + external_docs: { + description: "Detailed information about ZITADEL", + url: "https://zitadel.com/docs" + } + security_definitions: { + security: { + key: "OAuth2"; + value: { + type: TYPE_OAUTH2; + flow: FLOW_ACCESS_CODE; + authorization_url: "$CUSTOM-DOMAIN/oauth/v2/authorize"; + token_url: "$CUSTOM-DOMAIN/oauth/v2/token"; + scopes: { + scope: { + key: "openid"; + value: "openid"; + } + scope: { + key: "urn:zitadel:iam:org:project:id:zitadel:aud"; + value: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + } + } + security: { + security_requirement: { + key: "OAuth2"; + value: { + scope: "openid"; + scope: "urn:zitadel:iam:org:project:id:zitadel:aud"; + } + } + } + responses: { + key: "403"; + value: { + description: "Returned when the user does not have permission to access the resource."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } + responses: { + key: "404"; + value: { + description: "Returned when the resource does not exist."; + schema: { + json_schema: { + ref: "#/definitions/rpcStatus"; + } + } + } + } +}; + + +// InternalPermissionService provides methods to manage permissions for resource +// and their management in ZITADEL itself. +// +// If you want to manage permissions and roles within your project or application, +// please use the AuthorizationsService. +service InternalPermissionService { + // ListAdministrators returns all administrators and its roles matching the request and necessary permissions. + // + // Required permissions depend on the resource type: + // - "iam.member.read" for instance administrators + // - "org.member.read" for organization administrators + // - "project.member.read" for project administrators + // - "project.grant.member.read" for project grant administrators + // - no permissions required for listing own administrator roles + rpc ListAdministrators(ListAdministratorsRequest) returns (ListAdministratorsResponse) { + option (google.api.http) = { + post: "/v2beta/administrators/search", + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "A list of all administrators matching the query"; + }; + }; + responses: { + key: "400"; + value: { + description: "invalid list query"; + }; + }; + }; + } + + // CreateAdministrator grants a administrator role to a user for a specific resource. + // + // Note that the roles are specific to the resource type. + // This means that if you want to grant a user the administrator role for an organization and a project, + // you need to create two administrator roles. + // + // Required permissions depend on the resource type: + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + // - "project.member.write" for project administrators + // - "project.grant.member.write" for project grant administrators + rpc CreateAdministrator(CreateAdministratorRequest) returns (CreateAdministratorResponse) { + option (google.api.http) = { + post: "/v2beta/administrators" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator created successfully"; + }; + }; + responses: { + key: "409" + value: { + description: "The administrator to create already exists."; + } + }; + }; + } + + // UpdateAdministrator updates the specific administrator role. + // + // Note that any role previously granted to the user and not present in the request will be revoked. + // + // Required permissions depend on the resource type: + // - "iam.member.write" for instance administrators + // - "org.member.write" for organization administrators + // - "project.member.write" for project administrators + // - "project.grant.member.write" for project grant administrators + rpc UpdateAdministrator(UpdateAdministratorRequest) returns (UpdateAdministratorResponse) { + option (google.api.http) = { + post: "/v2beta/administrators/{user_id}" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator successfully updated or left unchanged"; + }; + }; + responses: { + key: "404" + value: { + description: "The administrator to update does not exist."; + } + }; + }; + } + + // DeleteAdministrator revokes a administrator role from a user. + // + // In case the administrator role is not found, the request will return a successful response as + // the desired state is already achieved. + // You can check the deletion date in the response to verify if the administrator role was deleted during the request. + // + // Required permissions depend on the resource type: + // - "iam.member.delete" for instance administrators + // - "org.member.delete" for organization administrators + // - "project.member.delete" for project administrators + // - "project.grant.member.delete" for project grant administrators + rpc DeleteAdministrator(DeleteAdministratorRequest) returns (DeleteAdministratorResponse) { + option (google.api.http) = { + delete: "/v2beta/administrators/{user_id}" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + value: { + description: "Administrator deleted successfully"; + }; + }; + }; + } +} + +message ListAdministratorsRequest { + // List limitations and ordering. + optional zitadel.filter.v2beta.PaginationRequest pagination = 1; + // The field the result is sorted by. The default is the creation date. Beware that if you change this, your result pagination might be inconsistent. + optional AdministratorFieldName sorting_column = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + default: "\"ADMINISTRATOR_FIELD_NAME_CREATION_DATE\"" + } + ]; + // Filter the administrator roles to be returned. + repeated AdministratorSearchFilter filters = 3; +} + +message ListAdministratorsResponse { + zitadel.filter.v2beta.PaginationResponse pagination = 1; + repeated Administrator administrators = 2; +} + +message GetAdministratorRequest { + // ID is the unique identifier of the administrator. + string id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; +} + +message GetAdministratorResponse { + Administrator administrator = 1; +} + +message CreateAdministratorRequest { + // UserID is the ID of the user who should be granted the administrator role. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be granted for. + ResourceType resource = 2; + + // Roles are the roles that should be granted to the user for the specified resource. + // Note that roles are currently specific to the resource type. + // This means that if you want to grant a user the administrator role for an organization and a project, + // you need to create two administrator roles. + repeated string roles = 3 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }]; +} + +message ResourceType { + message ProjectGrant { + // ProjectID is required to grant administrator privileges for a specific project. + string project_id = 1; + // ProjectGrantID is required to grant administrator privileges for a specific project grant. + string project_grant_id = 2; + } + + // Resource is the type of the resource the administrator roles should be granted for. + oneof resource { + option (validate.required) = true; + + // Instance is the resource type for granting administrator privileges on the instance level. + bool instance = 1 [(validate.rules).bool = {const: true}]; + // OrganizationID is required to grant administrator privileges for a specific organization. + string organization_id = 2; + // ProjectID is required to grant administrator privileges for a specific project. + string project_id = 3; + // ProjectGrantID is required to grant administrator privileges for a specific project grant. + ProjectGrant project_grant = 4; + } +} + +message CreateAdministratorResponse { + // CreationDate is the timestamp when the administrator role was created. + google.protobuf.Timestamp creation_date = 1; +} + +message UpdateAdministratorRequest { + // UserID is the ID of the user who should have his administrator roles update. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be granted for. + ResourceType resource = 2; + + // Roles are the roles that the user should be granted. + // Note that any role previously granted to the user and not present in the list will be revoked. + repeated string roles = 3 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 200 + } + } + }]; +} + +message UpdateAdministratorResponse { + // ChangeDate is the timestamp when the administrator role was last updated. + google.protobuf.Timestamp change_date = 1; +} + +message DeleteAdministratorRequest { + // UserID is the ID of the user who should have his administrator roles removed. + string user_id = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Resource is the type of the resource the administrator roles should be removed for. + ResourceType resource = 2; +} + +message DeleteAdministratorResponse { + // DeletionDate is the timestamp when the administrator role was deleted. + // Note that the deletion date is only guaranteed to be set if the deletion was successful during the request. + // In case the deletion occurred in a previous request, the deletion date might not be set. + google.protobuf.Timestamp deletion_date = 1; +} \ No newline at end of file diff --git a/proto/zitadel/internal_permission/v2beta/query.proto b/proto/zitadel/internal_permission/v2beta/query.proto new file mode 100644 index 0000000000..b23183cd50 --- /dev/null +++ b/proto/zitadel/internal_permission/v2beta/query.proto @@ -0,0 +1,166 @@ +syntax = "proto3"; + +import "google/protobuf/timestamp.proto"; +import "validate/validate.proto"; +import "zitadel/filter/v2beta/filter.proto"; + +package zitadel.internal_permission.v2beta; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/internal_permission/v2beta;internal_permission"; + +message Administrator { + // CreationDate is the timestamp when the administrator role was granted. + google.protobuf.Timestamp creation_date = 1; + // ChangeDate is the timestamp when the administrator role was last updated. + // In case the administrator role was not updated, this field is equal to the creation date. + google.protobuf.Timestamp change_date = 2; + // User is the user who was granted the administrator role. + User user = 3; + // Resource is the type of the resource the administrator roles were granted for. + oneof resource { + // Instance is returned if the administrator roles were granted on the instance level. + bool instance = 4; + // Organization provides information about the organization the administrator roles were granted for. + Organization organization = 5; + // Project provides information about the project the administrator roles were granted for. + Project project = 6; + // ProjectGrant provides information about the project grant the administrator roles were granted for. + ProjectGrant project_grant = 7; + } + // Roles are the roles that were granted to the user for the specified resource. + repeated string roles = 8; +} + +message User { + // ID is the unique identifier of the user. + string id = 1; + // PreferredLoginName is the preferred login name of the user. This value is unique across the whole instance. + string preferred_login_name = 2; + // DisplayName is the public display name of the user. + // By default it's the user's given name and family name, their username or their email address. + string display_name = 3; + // The organization the user belong to. + string organization_id = 4; +} + +message Organization { + // ID is the unique identifier of the organization the user was granted the administrator role for. + string id = 1; + // Name is the name of the organization the user was granted the administrator role for. + string name = 2; +} +message Project { + // ID is the unique identifier of the project the user was granted the administrator role for. + string id = 1; + // Name is the name of the project the user was granted the administrator role for. + string name = 2; + // OrganizationID is the ID of the organization the project belongs to. + string organization_id = 3; +} +message ProjectGrant { + // ID is the unique identifier of the project grant the user was granted the administrator role for. + string id = 1; + // ProjectID is the ID of the project the project grant belongs to. + string project_id = 2; + // ProjectName is the name of the project the project grant belongs to. + string project_name = 3; + // OrganizationID is the ID of the organization the project grant belongs to. + string organization_id = 4; + // OrganizationID is the ID of the organization the project grant belongs to. + string granted_organization_id = 5; +} + +message AdministratorSearchFilter{ + oneof filter { + option (validate.required) = true; + // Search for administrator roles by their creation date. + zitadel.filter.v2beta.TimestampFilter creation_date = 1; + // Search for administrator roles by their change date. + zitadel.filter.v2beta.TimestampFilter change_date = 2; + // Search for administrators roles by the IDs of the users who was granted the administrator role. + zitadel.filter.v2beta.InIDsFilter in_user_ids_filter = 3; + // Search for administrators roles by the ID of the organization the user is part of. + zitadel.filter.v2beta.IDFilter user_organization_id = 4; + // Search for administrators roles by the preferred login name of the user. + UserPreferredLoginNameFilter user_preferred_login_name = 5; + // Search for administrators roles by the display name of the user. + UserDisplayNameFilter user_display_name = 6; + // Search for administrators roles granted for a specific resource. + ResourceFilter resource = 7; + // Search for administrators roles granted with a specific role. + RoleFilter role = 8; + + // Combine multiple authorization queries with an AND operation. + AndFilter and = 9; + // Combine multiple authorization queries with an OR operation. + // For example, to search for authorizations of multiple OrganizationIDs. + OrFilter or = 10; + // Negate an authorization query. + NotFilter not = 11; + } +} + +message UserPreferredLoginNameFilter { + // Search for administrators by the preferred login name of the user. + string preferred_login_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the preferred login name. Default is EQUAL. + // For example, to search for all administrator roles of a user with a preferred login name + // containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message UserDisplayNameFilter { + // Search for administrators by the display name of the user. + string display_name = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; + // Specify the method to search for the display name. Default is EQUAL. + // For example, to search for all administrator roles of a user with a display name + // containing a specific string, use CONTAINS or CONTAINS_IGNORE_CASE. + zitadel.filter.v2beta.TextFilterMethod method = 2 [(validate.rules).enum.defined_only = true]; +} + +message ResourceFilter { + // Search for administrators by the granted resource. + oneof resource { + // Search for administrators granted on the instance level. + bool instance = 1; + // Search for administrators granted on a specific organization. + string organization_id = 2; + // Search for administrators granted on a specific project. + string project_id = 3; + // Search for administrators granted on a specific project grant. + string project_grant_id = 4; + } +} + +message RoleFilter { + // Search for administrators by the granted role. + string role_key = 1 [(validate.rules).string = { + min_len: 1 + max_len: 200 + }]; +} + +message AndFilter { + repeated AdministratorSearchFilter queries = 1; +} + +message OrFilter { + repeated AdministratorSearchFilter queries = 1; +} + +message NotFilter { + AdministratorSearchFilter query = 1; +} + +enum AdministratorFieldName { + ADMINISTRATOR_FIELD_NAME_UNSPECIFIED = 0; + ADMINISTRATOR_FIELD_NAME_USER_ID = 1; + ADMINISTRATOR_FIELD_NAME_CREATION_DATE = 2; + ADMINISTRATOR_FIELD_NAME_CHANGE_DATE = 3; +} diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index bb62e2eba6..09095e3b78 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -4177,6 +4177,11 @@ service ManagementService { }; } + // Get User Grant By ID + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and filter by its ID. + // + // Returns a user grant per ID. A user grant is a role a user has for a specific project and organization. rpc GetUserGrantByID(GetUserGrantByIDRequest) returns (GetUserGrantByIDResponse) { option (google.api.http) = { get: "/users/{user_id}/grants/{grant_id}" @@ -4188,8 +4193,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "User Grant By ID"; - description: "Returns a user grant per ID. A user grant is a role a user has for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4201,6 +4205,11 @@ service ManagementService { }; } + // Search User Grants + // + // Deprecated: [List authorizations](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-list-authorizations.api.mdx) and pass the user ID filter to search for a users grants on owned or granted projects. + // + // Returns a list of user grants that match the search queries. User grants are the roles users have for a specific project and organization. rpc ListUserGrants(ListUserGrantRequest) returns (ListUserGrantResponse) { option (google.api.http) = { post: "/users/grants/_search" @@ -4213,8 +4222,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Search User Grants"; - description: "Returns a list of user grants that match the search queries. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4226,6 +4234,12 @@ service ManagementService { }; } + + // Add User Grant + // + // Deprecated: [Add an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-create-authorization.api.mdx) to grant a user access to an owned or granted project. + // + // Add a user grant for a specific user. User grants are the roles users have for a specific project and organization. rpc AddUserGrant(AddUserGrantRequest) returns (AddUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants" @@ -4238,8 +4252,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Add User Grant"; - description: "Add a user grant for a specific user. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4251,6 +4264,11 @@ service ManagementService { }; } + // Update User Grant + // + // Deprecated: [Update an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-update-authorization.api.mdx) to update a user's roles on an owned or granted project. + // + // Update the roles of a user grant. User grants are the roles users have for a specific project and organization. rpc UpdateUserGrant(UpdateUserGrantRequest) returns (UpdateUserGrantResponse) { option (google.api.http) = { put: "/users/{user_id}/grants/{grant_id}" @@ -4263,8 +4281,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Update User Grants"; - description: "Update the roles of a user grant. User grants are the roles users have for a specific project and organization." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4276,6 +4293,11 @@ service ManagementService { }; } + // Deactivate User Grant + // + // Deprecated: [Deactivate an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-deactivate-authorization.api.mdx) to disable a user's access to an owned or granted project. + // + // Deactivate the user grant. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. An error will be returned if the user grant is already deactivated. rpc DeactivateUserGrant(DeactivateUserGrantRequest) returns (DeactivateUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants/{grant_id}/_deactivate" @@ -4288,8 +4310,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Deactivate User Grant"; - description: "Deactivate the user grant. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. An error will be returned if the user grant is already deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4301,6 +4322,11 @@ service ManagementService { }; } + // Reactivate User Grant + // + // Deprecated: [Activate an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-activate-authorization.api.mdx) to enable a user's access to an owned or granted project. + // + // Reactivate a deactivated user grant. The user will be able to use the granted project again. An error will be returned if the user grant is not deactivated. rpc ReactivateUserGrant(ReactivateUserGrantRequest) returns (ReactivateUserGrantResponse) { option (google.api.http) = { post: "/users/{user_id}/grants/{grant_id}/_reactivate" @@ -4313,8 +4339,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Reactivate User Grant"; - description: "Reactivate a deactivated user grant. The user will be able to use the granted project again. An error will be returned if the user grant is not deactivated." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4326,6 +4351,11 @@ service ManagementService { }; } + // Remove User Grant + // + // Deprecated: [Delete an authorization](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-delete-authorization.api.mdx) to remove a users access to an owned or granted project. + // + // Removes the user grant from the user. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. rpc RemoveUserGrant(RemoveUserGrantRequest) returns (RemoveUserGrantResponse) { option (google.api.http) = { delete: "/users/{user_id}/grants/{grant_id}" @@ -4337,8 +4367,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Remove User Grant"; - description: "Removes the user grant from the user. The user will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; @@ -4350,6 +4379,11 @@ service ManagementService { }; } + // Bulk Remove User Grants + // + // Deprecated: [Delete authorizations one after the other](apis/resources/authorization_service_v2/zitadel-authorization-v-2-beta-authorization-service-delete-authorization.api.mdx) to remove access for multiple users on multiple owned or granted projects. + // + // Remove a list of user grants. The users will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested. rpc BulkRemoveUserGrant(BulkRemoveUserGrantRequest) returns (BulkRemoveUserGrantResponse) { option (google.api.http) = { delete: "/user_grants/_bulk" @@ -4362,8 +4396,7 @@ service ManagementService { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { tags: "User Grants"; - summary: "Bulk Remove User Grants"; - description: "Remove a list of user grants. The users will not be able to use the granted project anymore. Also, the roles will not be included in the tokens when requested." + deprecated: true; parameters: { headers: { name: "x-zitadel-orgid"; diff --git a/proto/zitadel/metadata/v2/metadata.proto b/proto/zitadel/metadata/v2/metadata.proto new file mode 100644 index 0000000000..c04548ba4e --- /dev/null +++ b/proto/zitadel/metadata/v2/metadata.proto @@ -0,0 +1,57 @@ +syntax = "proto3"; + +import "zitadel/filter/v2/filter.proto"; +import "protoc-gen-openapiv2/options/annotations.proto"; +import "validate/validate.proto"; +import "google/protobuf/timestamp.proto"; + +package zitadel.metadata.v2; + +option go_package = "github.com/zitadel/zitadel/pkg/grpc/metadata/v2"; + +message Metadata { + google.protobuf.Timestamp creation_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + google.protobuf.Timestamp change_date = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; + string key = 3 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata key", + example: "\"key1\""; + } + ]; + bytes value = 4 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "metadata value is base64 encoded, make sure to decode to get the value", + example: "\"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\""; + } + ]; +} + +message MetadataSearchFilter { + oneof filter { + option (validate.required) = true; + MetadataKeyFilter key_filter = 1; + } +} + +message MetadataKeyFilter { + string key = 1 [ + (validate.rules).string = {max_len: 200}, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"key\"" + } + ]; + zitadel.filter.v2.TextFilterMethod method = 2 [ + (validate.rules).enum.defined_only = true, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + description: "defines which text equality method is used"; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/project/v2beta/project_service.proto b/proto/zitadel/project/v2beta/project_service.proto index cb7110bc91..66f221b911 100644 --- a/proto/zitadel/project/v2beta/project_service.proto +++ b/proto/zitadel/project/v2beta/project_service.proto @@ -451,7 +451,7 @@ service ProjectService { // - `project.role.read` rpc ListProjectRoles (ListProjectRolesRequest) returns (ListProjectRolesResponse) { option (google.api.http) = { - delete: "/v2beta/projects/{project_id}/roles/search" + post: "/v2beta/projects/{project_id}/roles/search" }; option (zitadel.protoc_gen_zitadel.v2.options) = { diff --git a/proto/zitadel/system.proto b/proto/zitadel/system.proto index 09b5559fb9..9b65fec600 100644 --- a/proto/zitadel/system.proto +++ b/proto/zitadel/system.proto @@ -118,7 +118,7 @@ service SystemService { // Returns a list of ZITADEL instances // - // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/instance-service-list-instances.api.mdx) instead to list instances + // Deprecated: Use [ListInstances](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-instances.api.mdx) instead to list instances rpc ListInstances(ListInstancesRequest) returns (ListInstancesResponse) { option (google.api.http) = { post: "/instances/_search" @@ -136,7 +136,7 @@ service SystemService { // Returns the detail of an instance // - // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/instance-service-get-instance.api.mdx) instead to get the details of the instance in context + // Deprecated: Use [GetInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-get-instance.api.mdx) instead to get the details of the instance in context rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse) { option (google.api.http) = { get: "/instances/{instance_id}"; @@ -171,7 +171,7 @@ service SystemService { // Updates name of an existing instance // - // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/instance-service-update-instance.api.mdx) instead to update the name of the instance in context + // Deprecated: Use [UpdateInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-update-instance.api.mdx) instead to update the name of the instance in context rpc UpdateInstance(UpdateInstanceRequest) returns (UpdateInstanceResponse) { option (google.api.http) = { put: "/instances/{instance_id}" @@ -203,7 +203,7 @@ service SystemService { // Removes an instance // This might take some time // - // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/instance-service-delete-instance.api.mdx) instead to delete an instance + // Deprecated: Use [DeleteInstance](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-delete-instance.api.mdx) instead to delete an instance rpc RemoveInstance(RemoveInstanceRequest) returns (RemoveInstanceResponse) { option (google.api.http) = { delete: "/instances/{instance_id}" @@ -234,7 +234,7 @@ service SystemService { // Checks if a domain exists // - // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/instance-service-list-custom-domains.api.mdx) instead to check existence of an instance + // Deprecated: Use [ListCustomDomains](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-list-custom-domains.api.mdx) instead to check existence of an instance rpc ExistsDomain(ExistsDomainRequest) returns (ExistsDomainResponse) { option (google.api.http) = { post: "/domains/{domain}/_exists"; @@ -270,7 +270,7 @@ service SystemService { // Adds a domain to an instance // - // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context + // Deprecated: Use [AddCustomDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-add-custom-domain.api.mdx) instead to add a custom domain to the instance in context rpc AddDomain(AddDomainRequest) returns (AddDomainResponse) { option (google.api.http) = { post: "/instances/{instance_id}/domains"; @@ -288,7 +288,7 @@ service SystemService { // Removes the domain of an instance // - // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context + // Deprecated: Use [RemoveDomain](apis/resources/instance_service_v2/zitadel-instance-v-2-beta-instance-service-remove-custom-domain.api.mdx) instead to remove a custom domain from the instance in context rpc RemoveDomain(RemoveDomainRequest) returns (RemoveDomainResponse) { option (google.api.http) = { delete: "/instances/{instance_id}/domains/{domain}"; diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index 349f3c6c54..7ed12f0143 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -22,6 +22,7 @@ import "zitadel/user/v2/key.proto"; import "zitadel/user/v2/pat.proto"; import "zitadel/user/v2/query.proto"; import "zitadel/filter/v2/filter.proto"; +import "zitadel/metadata/v2/metadata.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/user/v2;user"; @@ -1793,6 +1794,84 @@ service UserService { } }; } + // Set User Metadata + // + // Sets a list of key value pairs. Existing metadata entries with matching keys are overwritten. Existing metadata entries without matching keys are untouched. To remove metadata entries, use [DeleteUserMetadata](apis/resources/user_service_v2/user-service-delete-user-metadata.api.mdx). For HTTP requests, make sure the bytes array value is base64 encoded. + // + // Required permission: + // - `user.write` + rpc SetUserMetadata(SetUserMetadataRequest) returns (SetUserMetadataResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/metadata" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + responses: { + key: "400" + value: { + description: "User not found"; + } + }; + }; + } + + // List User Metadata + // + // List metadata of an user filtered by query. + // + // Required permission: + // - `user.read` + rpc ListUserMetadata(ListUserMetadataRequest) returns (ListUserMetadataResponse) { + option (google.api.http) = { + post: "/v2/users/{user_id}/metadata/search" + body: "*" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = {auth_option: { + permission: "user.read" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + } + + // Delete User Metadata + // + // Delete metadata objects from an user with a specific key. + // + // Required permission: + // - `user.write` + rpc DeleteUserMetadata(DeleteUserMetadataRequest) returns (DeleteUserMetadataResponse) { + option (google.api.http) = { + delete: "/v2/users/{user_id}/metadata" + }; + + option (zitadel.protoc_gen_zitadel.v2.options) = { + auth_option: { + permission: "authenticated" + } + }; + + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + responses: { + key: "200"; + }; + }; + } } message AddHumanUserRequest{ @@ -1890,6 +1969,13 @@ message CreateUserRequest{ example: "\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\""; } ]; + + // Metadata to bet set. The values have to be base64 encoded. + repeated Metadata metadata = 9 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; } message Machine { // The machine users name is a human readable field that helps identifying the user. @@ -3456,3 +3542,79 @@ message ListPersonalAccessTokensResponse { zitadel.filter.v2.PaginationResponse pagination = 1; repeated PersonalAccessToken result = 2; } + +message Metadata { + // Key in the metadata key/value pair. + string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + // Value in the metadata key/value pair. + bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}]; +} + +message SetUserMetadataRequest{ + // ID of the user under which the metadata gets set. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + // Metadata to bet set. The values have to be base64 encoded. + repeated Metadata metadata = 2 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; +} + +message SetUserMetadataResponse{ + // The timestamp of the update of the user metadata. + google.protobuf.Timestamp set_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} + +message ListUserMetadataRequest { + // ID of the user under which the metadata is to be listed. + string user_id = 1 [ + (validate.rules).string = {min_len: 1, max_len: 200}, + (google.api.field_behavior) = REQUIRED, + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + min_length: 1; + max_length: 200; + example: "\"69629012906488334\""; + } + ]; + + // List limitations and ordering. + optional zitadel.filter.v2.PaginationRequest pagination = 2; + // Define the criteria to query for. + repeated zitadel.metadata.v2.MetadataSearchFilter filters = 3; +} + +message ListUserMetadataResponse { + // Pagination of the users metadata results. + zitadel.filter.v2.PaginationResponse pagination = 1; + // The user metadata requested. + repeated zitadel.metadata.v2.Metadata metadata = 2; +} + +message DeleteUserMetadataRequest { + // ID of the user which metadata is to be deleted is stored on. + string user_id = 1; + // The keys for the user metadata to be deleted. + repeated string keys = 2 [(validate.rules).repeated.items.string = {min_len: 1, max_len: 200}]; +} + +message DeleteUserMetadataResponse{ + // The timestamp of the deletion of the user metadata. + google.protobuf.Timestamp deletion_date = 1 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "\"2025-01-23T10:34:18.051Z\""; + } + ]; +} \ No newline at end of file diff --git a/proto/zitadel/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