From d7f202d20fb50574c60257d8c2b9711b2eb74b59 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:16:49 +0200 Subject: [PATCH] fix(project): include an option to add project members during project creation (#10654) # Which Problems Are Solved When a project is created by a user with only the `PROJECT_CREATOR` role, they can no longer view/manage the created project. Although the project is created, the user sees the following error: `No matching permissions found (AUTH-3jknH)`. This is due to the [removal](https://github.com/zitadel/zitadel/pull/9317) of auto-assignment of the `PROJECT_OWNER` role when a project is newly created. # How the Problems Are Solved By introducing optional fields in the CreateProject API to include a list of users and a list of project member roles to be assigned to the users. When there are no roles mentioned, the `PROJECT_OWNER` role is assigned by default to all the users mentioned in the list. # Additional Changes N/A # Additional Context - Closes #10561 - Closes #10592 - Should be backported as this issue is not specific to v4 --------- Co-authored-by: conblem Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com> Co-authored-by: Livio Spring --- .../project-create.component.html | 17 +- .../project-create.component.ts | 46 ++++-- .../project-create/project-create.module.ts | 5 +- console/src/app/services/new-mgmt.service.ts | 7 +- console/src/assets/i18n/bg.json | 1 + console/src/assets/i18n/cs.json | 1 + console/src/assets/i18n/de.json | 1 + console/src/assets/i18n/en.json | 1 + console/src/assets/i18n/es.json | 1 + console/src/assets/i18n/fr.json | 1 + console/src/assets/i18n/hu.json | 1 + console/src/assets/i18n/id.json | 1 + console/src/assets/i18n/it.json | 1 + console/src/assets/i18n/ja.json | 1 + console/src/assets/i18n/ko.json | 1 + console/src/assets/i18n/mk.json | 1 + console/src/assets/i18n/nl.json | 1 + console/src/assets/i18n/pl.json | 1 + console/src/assets/i18n/pt.json | 1 + console/src/assets/i18n/ro.json | 1 + console/src/assets/i18n/ru.json | 1 + console/src/assets/i18n/sv.json | 1 + console/src/assets/i18n/tr.json | 1 + console/src/assets/i18n/zh.json | 1 + .../api/grpc/management/project_converter.go | 16 ++ .../v2beta/integration_test/project_test.go | 51 +++++- internal/api/grpc/project/v2beta/project.go | 16 ++ internal/command/project.go | 38 +++++ internal/command/project_test.go | 151 ++++++++++++++++++ proto/zitadel/management.proto | 8 + .../project/v2beta/project_service.proto | 14 +- 31 files changed, 358 insertions(+), 31 deletions(-) diff --git a/console/src/app/pages/projects/project-create/project-create.component.html b/console/src/app/pages/projects/project-create/project-create.component.html index 2dd98dec0e4..011804c7c94 100644 --- a/console/src/app/pages/projects/project-create/project-create.component.html +++ b/console/src/app/pages/projects/project-create/project-create.component.html @@ -2,14 +2,17 @@ title="{{ 'PROJECT.PAGES.CREATE' | translate }}" [createSteps]="1" [currentCreateStep]="1" - (closed)="close()" + (closed)="location.back()" > -

{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}

-
+ + + {{ 'PROJECT.PAGES.USERSELFACCOUNT' | translate }} + +

{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}

{{ 'PROJECT.NAME' | translate }} - +
@@ -17,12 +20,12 @@ color="primary" mat-raised-button class="continue-button" - [disabled]="!project.name" + [disabled]="!form.valid" cdkFocusInitial type="submit" data-e2e="continue-button" > {{ 'ACTIONS.CONTINUE' | translate }} -
+ + diff --git a/console/src/app/pages/projects/project-create/project-create.component.ts b/console/src/app/pages/projects/project-create/project-create.component.ts index a20e3ae73b5..72f898b86b3 100644 --- a/console/src/app/pages/projects/project-create/project-create.component.ts +++ b/console/src/app/pages/projects/project-create/project-create.component.ts @@ -1,10 +1,12 @@ import { Location } from '@angular/common'; import { Component } from '@angular/core'; import { Router } from '@angular/router'; -import { AddProjectRequest, AddProjectResponse } from 'src/app/proto/generated/zitadel/management_pb'; import { Breadcrumb, BreadcrumbService, BreadcrumbType } from 'src/app/services/breadcrumb.service'; -import { ManagementService } from 'src/app/services/mgmt.service'; import { ToastService } from 'src/app/services/toast.service'; +import { NewMgmtService } from 'src/app/services/new-mgmt.service'; +import { UserService } from 'src/app/services/user.service'; +import { FormBuilder, FormControl, Validators } from '@angular/forms'; +import { User } from '@zitadel/proto/zitadel/user/v2/user_pb'; @Component({ selector: 'cnsl-project-create', @@ -12,13 +14,15 @@ import { ToastService } from 'src/app/services/toast.service'; styleUrls: ['./project-create.component.scss'], }) export class ProjectCreateComponent { - public project: AddProjectRequest.AsObject = new AddProjectRequest().toObject(); + protected readonly form: ReturnType; constructor( - private router: Router, - private toast: ToastService, - private mgmtService: ManagementService, - private _location: Location, + private readonly router: Router, + private readonly toast: ToastService, + private readonly newMgmtService: NewMgmtService, + private readonly fb: FormBuilder, + protected readonly location: Location, + protected readonly userService: UserService, breadcrumbService: BreadcrumbService, ) { const bread: Breadcrumb = { @@ -26,21 +30,31 @@ export class ProjectCreateComponent { routerLink: ['/org'], }; breadcrumbService.setBreadcrumb([bread]); + + this.form = this.buildForm(); } - public saveProject(): void { - this.mgmtService - .addProject(this.project) - .then((resp: AddProjectResponse.AsObject) => { + private buildForm() { + return this.fb.group({ + name: new FormControl('', { nonNullable: true, validators: [Validators.required] }), + selfAccount: new FormControl(true, { nonNullable: true, validators: [Validators.required] }), + }); + } + + protected saveProject(user: User): void { + const { name, selfAccount } = this.form.getRawValue(); + + this.newMgmtService + .addProject({ + name, + admins: selfAccount ? [{ userId: user.userId }] : undefined, + }) + .then((resp) => { this.toast.showInfo('PROJECT.TOAST.CREATED', true); - this.router.navigate(['projects', resp.id], { queryParams: { new: true } }); + return this.router.navigate(['projects', resp.id], { queryParams: { new: true } }); }) .catch((error) => { this.toast.showError(error); }); } - - public close(): void { - this._location.back(); - } } diff --git a/console/src/app/pages/projects/project-create/project-create.module.ts b/console/src/app/pages/projects/project-create/project-create.module.ts index 34ddbbd0d08..df27e4055d3 100644 --- a/console/src/app/pages/projects/project-create/project-create.module.ts +++ b/console/src/app/pages/projects/project-create/project-create.module.ts @@ -1,7 +1,7 @@ import { A11yModule } from '@angular/cdk/a11y'; import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; -import { FormsModule } from '@angular/forms'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; @@ -10,6 +10,7 @@ import { InputModule } from 'src/app/modules/input/input.module'; import { ProjectCreateRoutingModule } from './project-create-routing.module'; import { ProjectCreateComponent } from './project-create.component'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; @NgModule({ declarations: [ProjectCreateComponent], @@ -23,6 +24,8 @@ import { ProjectCreateComponent } from './project-create.component'; MatButtonModule, MatIconModule, TranslateModule, + MatSlideToggleModule, + ReactiveFormsModule, ], }) export default class ProjectCreateModule {} diff --git a/console/src/app/services/new-mgmt.service.ts b/console/src/app/services/new-mgmt.service.ts index 9e0c9dc6e42..94b0b67aa75 100644 --- a/console/src/app/services/new-mgmt.service.ts +++ b/console/src/app/services/new-mgmt.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { GrpcService } from './grpc.service'; import { + AddProjectRequestSchema, + AddProjectResponse, GenerateMachineSecretRequestSchema, GenerateMachineSecretResponse, GetDefaultPasswordComplexityPolicyResponse, @@ -19,7 +21,6 @@ import { ResendHumanInitializationResponse, ResendHumanPhoneVerificationRequestSchema, ResendHumanPhoneVerificationResponse, - SendHumanResetPasswordNotificationRequest_Type, SendHumanResetPasswordNotificationRequestSchema, SendHumanResetPasswordNotificationResponse, SetUserMetadataRequestSchema, @@ -98,4 +99,8 @@ export class NewMgmtService { public getDefaultPasswordComplexityPolicy(): Promise { return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({}); } + + public addProject(req: MessageInitShape): Promise { + return this.grpcService.mgmtNew.addProject(req); + } } diff --git a/console/src/assets/i18n/bg.json b/console/src/assets/i18n/bg.json index 64a5b8cc82e..d0af170acce 100644 --- a/console/src/assets/i18n/bg.json +++ b/console/src/assets/i18n/bg.json @@ -1959,6 +1959,7 @@ "CREATE_DESC": "Въведете името на вашия проект.", "ROLE": "Роля", "NOITEMS": "Без проекти", + "USERSELFACCOUNT": "Използвайте личния си акаунт като собственик на проекта.", "ZITADELPROJECT": "Това принадлежи на проекта ZITADEL. ", "TYPE": { "OWNED": "Притежавани проекти", diff --git a/console/src/assets/i18n/cs.json b/console/src/assets/i18n/cs.json index c0c25ab602d..a67623440ff 100644 --- a/console/src/assets/i18n/cs.json +++ b/console/src/assets/i18n/cs.json @@ -1961,6 +1961,7 @@ "CREATE_DESC": "Vložte název vašeho projektu.", "ROLE": "Role", "NOITEMS": "Žádné projekty", + "USERSELFACCOUNT": "Použijte svůj osobní účet jako vlastník projektu.", "ZITADELPROJECT": "Tato nastavení jsou základní nastavení instance projektu ZITADEL. Pozor: Pokud provedete změny, ZITADEL se nemusí chovat podle očekávání.", "TYPE": { "OWNED": "Vlastní projekty", diff --git a/console/src/assets/i18n/de.json b/console/src/assets/i18n/de.json index 83fbb8bdc91..8167952ac67 100644 --- a/console/src/assets/i18n/de.json +++ b/console/src/assets/i18n/de.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "Gebe den Namen ein.", "ROLE": "Rolle", "NOITEMS": "Keine Projekte", + "USERSELFACCOUNT": "Verwenden Sie Ihr persönliches Konto als Projektinhaber.", "ZITADELPROJECT": "Diese Einstellungen gehören zum ZITADEL-Projekt. Wenn Du diese änderst, verhält sich ZITADEL möglicherweise nicht wie beabsichtigt.", "TYPE": { "OWNED": "Eigene Projekte", diff --git a/console/src/assets/i18n/en.json b/console/src/assets/i18n/en.json index 4e650c280ef..00cf13eaa16 100644 --- a/console/src/assets/i18n/en.json +++ b/console/src/assets/i18n/en.json @@ -1963,6 +1963,7 @@ "CREATE_DESC": "Insert your project's name.", "ROLE": "Role", "NOITEMS": "No projects", + "USERSELFACCOUNT": "Use your personal account as project owner.", "ZITADELPROJECT": "This belongs to the ZITADEL project. Beware: If you make changes ZITADEL may not behave as intended.", "TYPE": { "OWNED": "Owned Projects", diff --git a/console/src/assets/i18n/es.json b/console/src/assets/i18n/es.json index 1a7974806a1..e41ccf78d25 100644 --- a/console/src/assets/i18n/es.json +++ b/console/src/assets/i18n/es.json @@ -1961,6 +1961,7 @@ "CREATE_DESC": "Inserta el nombre de tu proyecto.", "ROLE": "Rol", "NOITEMS": "Sin proyectos", + "USERSELFACCOUNT": "Utiliza tu cuenta personal como propietario del proyecto.", "ZITADELPROJECT": "Esto pertenece al proyecto ZITADEL. Cuidado: Si haces cambio puede que ZITADEL no se comporte como se espera.", "TYPE": { "OWNED": "Proyectos propios", diff --git a/console/src/assets/i18n/fr.json b/console/src/assets/i18n/fr.json index 585dc01a3c7..f55d8f6c50c 100644 --- a/console/src/assets/i18n/fr.json +++ b/console/src/assets/i18n/fr.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "Insérez le nom de votre projet.", "ROLE": "Rôle", "NOITEMS": "Aucun projet", + "USERSELFACCOUNT": "Utilisez votre compte personnel en tant que propriétaire du projet.", "ZITADELPROJECT": "Ceci appartient au projet ZITADEL. Attention : si vous faites de changements, ZITADEL pourrait ne pas fonctionner correctement.", "TYPE": { "OWNED": "Projets possédés", diff --git a/console/src/assets/i18n/hu.json b/console/src/assets/i18n/hu.json index c505d82b63d..ab482f326d1 100644 --- a/console/src/assets/i18n/hu.json +++ b/console/src/assets/i18n/hu.json @@ -1958,6 +1958,7 @@ "CREATE_DESC": "Add meg a projekted nevét.", "ROLE": "Szerepkör", "NOITEMS": "Nincsenek projektek", + "USERSELFACCOUNT": "Használja személyes fiókját a projekt tulajdonosaként.", "ZITADELPROJECT": "Ez a ZITADEL projekthez tartozik. Vigyázz: Ha változtatásokat hajtasz végre, a ZITADEL lehet, hogy nem fog a szándékod szerint működni.", "TYPE": { "OWNED": "Saját Projektek", diff --git a/console/src/assets/i18n/id.json b/console/src/assets/i18n/id.json index 9be2153bb5e..6d651e304e3 100644 --- a/console/src/assets/i18n/id.json +++ b/console/src/assets/i18n/id.json @@ -1819,6 +1819,7 @@ "CREATE_DESC": "Masukkan nama proyek Anda.", "ROLE": "Peran", "NOITEMS": "Tidak ada proyek", + "USERSELFACCOUNT": "Gunakan akun pribadi Anda sebagai pemilik proyek.", "ZITADELPROJECT": "Ini milik proyek ZITADEL. Hati-hati: Jika Anda membuat perubahan, ZITADEL mungkin tidak berfungsi sebagaimana mestinya.", "TYPE": { "OWNED": "Proyek Milik", diff --git a/console/src/assets/i18n/it.json b/console/src/assets/i18n/it.json index 6c18318b1c7..08dbf679f75 100644 --- a/console/src/assets/i18n/it.json +++ b/console/src/assets/i18n/it.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "Inserisci il nome del tuo progetto.", "ROLE": "Ruolo", "NOITEMS": "Nessun progetto", + "USERSELFACCOUNT": "Utilizza il tuo account personale come proprietario del progetto.", "ZITADELPROJECT": "Questo appartiene al progetto ZITADEL. Attenzione: se fai delle modifiche ZITADEL potrebbe non comportarsi come previsto.", "TYPE": { "OWNED": "Progetti proprietari", diff --git a/console/src/assets/i18n/ja.json b/console/src/assets/i18n/ja.json index 2a4dea9ff19..e3d75102480 100644 --- a/console/src/assets/i18n/ja.json +++ b/console/src/assets/i18n/ja.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "プロジェクトの名前を入力します。", "ROLE": "ロール", "NOITEMS": "プロジェクトはありません", + "USERSELFACCOUNT": "プロジェクトの所有者としては、ご自身の個人アカウントをご利用ください。", "ZITADELPROJECT": "これはZITADELプロジェクトに属します。注意:変更した場合、ZITADELは意図したとおりに動作しないことがあります。", "TYPE": { "OWNED": "所有プロジェクト", diff --git a/console/src/assets/i18n/ko.json b/console/src/assets/i18n/ko.json index 8ebbfef3b2d..9caca028379 100644 --- a/console/src/assets/i18n/ko.json +++ b/console/src/assets/i18n/ko.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "프로젝트의 이름을 입력하세요.", "ROLE": "역할", "NOITEMS": "프로젝트가 없습니다", + "USERSELFACCOUNT": "프로젝트 소유자로 개인 계정을 사용해 주십시오", "ZITADELPROJECT": "이 프로젝트는 ZITADEL 프로젝트에 속해 있습니다. 변경 시 ZITADEL이 의도한 대로 작동하지 않을 수 있습니다.", "TYPE": { "OWNED": "소유한 프로젝트", diff --git a/console/src/assets/i18n/mk.json b/console/src/assets/i18n/mk.json index ba482652770..c0fee920bd4 100644 --- a/console/src/assets/i18n/mk.json +++ b/console/src/assets/i18n/mk.json @@ -1961,6 +1961,7 @@ "CREATE_DESC": "Внесете го името на вашиот проект.", "ROLE": "Улога", "NOITEMS": "Нема проекти", + "USERSELFACCOUNT": "Користете ја вашата лична корисничка сметка како сопственик на проектот.", "ZITADELPROJECT": "Ова припаѓа на ZITADEL проектот. Внимание: Ако направите промени, ZITADEL може да не работи како што е предвидено.", "TYPE": { "OWNED": "Сопствени проекти", diff --git a/console/src/assets/i18n/nl.json b/console/src/assets/i18n/nl.json index 1a176feeb01..6898331e83e 100644 --- a/console/src/assets/i18n/nl.json +++ b/console/src/assets/i18n/nl.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "Voer de naam van uw project in.", "ROLE": "Rol", "NOITEMS": "Geen projecten", + "USERSELFACCOUNT": "Gebruik uw persoonlijke account als projecteigenaar.", "ZITADELPROJECT": "Dit behoort tot het ZITADEL-project. Pas op: Als u wijzigingen aanbrengt, werkt ZITADEL mogelijk niet zoals bedoeld.", "TYPE": { "OWNED": "Eigen Projecten", diff --git a/console/src/assets/i18n/pl.json b/console/src/assets/i18n/pl.json index a860ca0634a..8c541034a03 100644 --- a/console/src/assets/i18n/pl.json +++ b/console/src/assets/i18n/pl.json @@ -1959,6 +1959,7 @@ "CREATE_DESC": "Wprowadź nazwę projektu.", "ROLE": "Rola", "NOITEMS": "Brak projektów", + "USERSELFACCOUNT": "Użyj swojego konta osobistego jako właściciel projektu.", "ZITADELPROJECT": "To należy do projektu ZITADEL. Uwaga: Jeśli wprowadzisz zmiany, ZITADEL może nie działać zgodnie z oczekiwaniami.", "TYPE": { "OWNED": "Własne Projekty", diff --git a/console/src/assets/i18n/pt.json b/console/src/assets/i18n/pt.json index 88b31bddcc6..d334b1cfd25 100644 --- a/console/src/assets/i18n/pt.json +++ b/console/src/assets/i18n/pt.json @@ -1960,6 +1960,7 @@ "CREATE_DESC": "Insira o nome do seu projeto.", "ROLE": "Função", "NOITEMS": "Nenhum projeto", + "USERSELFACCOUNT": "Use a sua conta pessoal como proprietário do projeto.", "ZITADELPROJECT": "Isto pertence ao projeto ZITADEL. Atenção: Se você fizer alterações, o ZITADEL pode não funcionar como o esperado.", "TYPE": { "OWNED": "Projetos Próprios", diff --git a/console/src/assets/i18n/ro.json b/console/src/assets/i18n/ro.json index f8926f0e2b8..76e18fda9f7 100644 --- a/console/src/assets/i18n/ro.json +++ b/console/src/assets/i18n/ro.json @@ -1958,6 +1958,7 @@ "CREATE_DESC": "Introduceți numele proiectului dvs.", "ROLE": "Rol", "NOITEMS": "Niciun proiect", + "USERSELFACCOUNT": "Utilizați contul personal ca proprietar al proiectului.", "ZITADELPROJECT": "Acesta aparține proiectului ZITADEL. Atenție: Dacă faceți modificări, ZITADEL ar putea să nu se comporte conform intențiilor.", "TYPE": { "OWNED": "Proiecte deținute", diff --git a/console/src/assets/i18n/ru.json b/console/src/assets/i18n/ru.json index 79bd4abe6aa..c06a7d25fc8 100644 --- a/console/src/assets/i18n/ru.json +++ b/console/src/assets/i18n/ru.json @@ -2045,6 +2045,7 @@ "CREATE_DESC": "Введите название вашего проекта.", "ROLE": "Роль", "NOITEMS": "Нет проектов", + "USERSELFACCOUNT": "Используйте свою личную учетную запись в качестве владельца проекта.", "ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.", "TYPE": { "OWNED": "Собственные проекты", diff --git a/console/src/assets/i18n/sv.json b/console/src/assets/i18n/sv.json index 17c2c1c362c..18fd8089064 100644 --- a/console/src/assets/i18n/sv.json +++ b/console/src/assets/i18n/sv.json @@ -1964,6 +1964,7 @@ "CREATE_DESC": "Ange ditt projekts namn.", "ROLE": "Roll", "NOITEMS": "Inga projekt", + "USERSELFACCOUNT": "Använd ditt personliga konto som projektägare.", "ZITADELPROJECT": "Detta tillhör ZITADEL-projektet. Observera: Om du gör ändringar kan ZITADEL inte fungera som avsett.", "TYPE": { "OWNED": "Ägda Projekt", diff --git a/console/src/assets/i18n/tr.json b/console/src/assets/i18n/tr.json index 73b360f6312..c9b31ea25fb 100644 --- a/console/src/assets/i18n/tr.json +++ b/console/src/assets/i18n/tr.json @@ -1963,6 +1963,7 @@ "CREATE_DESC": "Projenizin adını girin.", "ROLE": "Rol", "NOITEMS": "Proje yok", + "USERSELFACCOUNT": "Proje sahibi olarak kişisel hesabınızı kullanın.", "ZITADELPROJECT": "Bu ZITADEL projesine aittir. Dikkat: Değişiklik yaparsanız ZITADEL amaçlandığı gibi davranmayabilir.", "TYPE": { "OWNED": "Sahip Olunan Projeler", diff --git a/console/src/assets/i18n/zh.json b/console/src/assets/i18n/zh.json index fa12176e749..047bd79c99e 100644 --- a/console/src/assets/i18n/zh.json +++ b/console/src/assets/i18n/zh.json @@ -1963,6 +1963,7 @@ "CREATE_DESC": "插入您的项目名称。", "ROLE": "角色", "NOITEMS": "没有项目", + "USERSELFACCOUNT": "使用您的个人账户作为项目所有者", "ZITADELPROJECT": "这属于 ZITADEL 项目。注意:如果您修改了设置,ZITADEL 可能无法正常运行。", "TYPE": { "OWNED": "拥有的项目", diff --git a/internal/api/grpc/management/project_converter.go b/internal/api/grpc/management/project_converter.go index 8ea0014800b..994b6769c66 100644 --- a/internal/api/grpc/management/project_converter.go +++ b/internal/api/grpc/management/project_converter.go @@ -18,6 +18,7 @@ import ( ) func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, resourceOwner string) *command.AddProject { + admins := projectCreateAdminsToCommand(req.GetAdmins()) return &command.AddProject{ ObjectRoot: models.ObjectRoot{ AggregateID: projectID, @@ -28,6 +29,7 @@ func ProjectCreateToCommand(req *mgmt_pb.AddProjectRequest, projectID string, re ProjectRoleCheck: req.ProjectRoleCheck, HasProjectCheck: req.HasProjectCheck, PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), + Admins: admins, } } @@ -56,6 +58,20 @@ func privateLabelingSettingToDomain(setting proj_pb.PrivateLabelingSetting) doma } } +func projectCreateAdminsToCommand(requestAdmins []*mgmt_pb.AddProjectRequest_Admin) []*command.AddProjectAdmin { + if len(requestAdmins) == 0 { + return nil + } + admins := make([]*command.AddProjectAdmin, len(requestAdmins)) + for i, admin := range requestAdmins { + admins[i] = &command.AddProjectAdmin{ + ID: admin.GetUserId(), + Roles: admin.GetRoles(), + } + } + return admins +} + func AddProjectRoleRequestToCommand(req *mgmt_pb.AddProjectRoleRequest, resourceOwner string) *command.AddProjectRole { return &command.AddProjectRole{ ObjectRoot: models.ObjectRoot{ diff --git a/internal/api/grpc/project/v2beta/integration_test/project_test.go b/internal/api/grpc/project/v2beta/integration_test/project_test.go index 0b591f5bd12..869e64ce79d 100644 --- a/internal/api/grpc/project/v2beta/integration_test/project_test.go +++ b/internal/api/grpc/project/v2beta/integration_test/project_test.go @@ -96,6 +96,12 @@ func TestServer_CreateProject_Permission(t *testing.T) { iamOwnerCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner) orgResp := instance.CreateOrganization(iamOwnerCtx, integration.OrganizationName(), integration.Email()) + // user with ORG_PROJECT_CREATOR role in same org + user1Id, token1 := getOrgProjectCreator(t, iamOwnerCtx, orgResp.GetOrganizationId(), orgResp.GetOrganizationId()) + + // user with ORG_PROJECT_CREATOR role in a different org + _, token2 := getOrgProjectCreator(t, iamOwnerCtx, orgResp.GetOrganizationId(), instance.DefaultOrg.GetId()) + type want struct { id bool creationDate bool @@ -136,7 +142,7 @@ func TestServer_CreateProject_Permission(t *testing.T) { }, { name: "with ORG_PROJECT_CREATOR permission, same organization, ok", - ctx: integration.WithAuthorizationToken(CTX, getOrgProjectCreatorToken(t, iamOwnerCtx, orgResp.GetOrganizationId(), orgResp.GetOrganizationId())), + ctx: integration.WithAuthorizationToken(CTX, token1), req: &project.CreateProjectRequest{ Name: integration.ProjectName(), OrganizationId: orgResp.GetOrganizationId(), @@ -148,7 +154,7 @@ func TestServer_CreateProject_Permission(t *testing.T) { }, { name: "with ORG_PROJECT_CREATOR permission, other organization, ok", - ctx: integration.WithAuthorizationToken(CTX, getOrgProjectCreatorToken(t, iamOwnerCtx, orgResp.GetOrganizationId(), instance.DefaultOrg.GetId())), + ctx: integration.WithAuthorizationToken(CTX, token2), req: &project.CreateProjectRequest{ Name: integration.ProjectName(), OrganizationId: instance.DefaultOrg.GetId(), @@ -158,6 +164,43 @@ func TestServer_CreateProject_Permission(t *testing.T) { creationDate: true, }, }, + { + name: "with ORG_PROJECT_CREATOR permission, with admins and roles, ok", + ctx: integration.WithAuthorizationToken(CTX, token1), + req: &project.CreateProjectRequest{ + Name: integration.ProjectName(), + OrganizationId: orgResp.GetOrganizationId(), + Admins: []*project.CreateProjectRequest_Admin{ + { + UserId: user1Id, + Roles: []string{"role1", "role2"}, + }, + }, + }, + want: want{ + id: true, + creationDate: true, + }, + }, + { + name: "with ORG_PROJECT_CREATOR permission, missing user from the admins list, ok", + ctx: integration.WithAuthorizationToken(CTX, token1), + req: &project.CreateProjectRequest{ + Name: integration.ProjectName(), + OrganizationId: orgResp.GetOrganizationId(), + Admins: []*project.CreateProjectRequest_Admin{ + { + UserId: user1Id, + Roles: []string{"role1", "role2"}, + }, + { + UserId: "random_user", + Roles: []string{"role1", "role2"}, + }, + }, + }, + wantErr: true, + }, { name: "organization owner, ok", ctx: instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), @@ -199,7 +242,7 @@ func TestServer_CreateProject_Permission(t *testing.T) { } } -func getOrgProjectCreatorToken(t *testing.T, ctx context.Context, orgId1, orgId2 string) string { +func getOrgProjectCreator(t *testing.T, ctx context.Context, orgId1, orgId2 string) (string, string) { // create a machine user in Org 1 userResp := instance.CreateUserTypeMachine(ctx, orgId1) @@ -213,7 +256,7 @@ func getOrgProjectCreatorToken(t *testing.T, ctx context.Context, orgId1, orgId2 }) require.NoError(t, err) - return instance.CreatePersonalAccessToken(ctx, userResp.GetId()).Token + return userResp.GetId(), instance.CreatePersonalAccessToken(ctx, userResp.GetId()).Token } func assertCreateProjectResponse(t *testing.T, creationDate, changeDate time.Time, expectedCreationDate, expectedID bool, actualResp *project.CreateProjectResponse) { diff --git a/internal/api/grpc/project/v2beta/project.go b/internal/api/grpc/project/v2beta/project.go index 95f08ea61e5..55f6a87b401 100644 --- a/internal/api/grpc/project/v2beta/project.go +++ b/internal/api/grpc/project/v2beta/project.go @@ -31,6 +31,7 @@ func (s *Server) CreateProject(ctx context.Context, req *connect.Request[project } func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddProject { + admins := projectCreateAdminsToCommand(req.GetAdmins()) var aggregateID string if req.Id != nil { aggregateID = *req.Id @@ -45,9 +46,24 @@ func projectCreateToCommand(req *project_pb.CreateProjectRequest) *command.AddPr ProjectRoleCheck: req.AuthorizationRequired, HasProjectCheck: req.ProjectAccessRequired, PrivateLabelingSetting: privateLabelingSettingToDomain(req.PrivateLabelingSetting), + Admins: admins, } } +func projectCreateAdminsToCommand(requestAdmins []*project_pb.CreateProjectRequest_Admin) []*command.AddProjectAdmin { + if len(requestAdmins) == 0 { + return nil + } + admins := make([]*command.AddProjectAdmin, len(requestAdmins)) + for i, admin := range requestAdmins { + admins[i] = &command.AddProjectAdmin{ + ID: admin.GetUserId(), + Roles: admin.GetRoles(), + } + } + return admins +} + func privateLabelingSettingToDomain(setting project_pb.PrivateLabelingSetting) domain.PrivateLabelingSetting { switch setting { case project_pb.PrivateLabelingSetting_PRIVATE_LABELING_SETTING_ALLOW_LOGIN_USER_RESOURCE_OWNER_POLICY: diff --git a/internal/command/project.go b/internal/command/project.go index 4d060b6233b..aceb20912cd 100644 --- a/internal/command/project.go +++ b/internal/command/project.go @@ -27,6 +27,12 @@ type AddProject struct { ProjectRoleCheck bool HasProjectCheck bool PrivateLabelingSetting domain.PrivateLabelingSetting + Admins []*AddProjectAdmin +} + +type AddProjectAdmin struct { + ID string + Roles []string } func (p *AddProject) IsValid() error { @@ -74,6 +80,12 @@ func (c *Commands) AddProject(ctx context.Context, add *AddProject) (_ *domain.O add.HasProjectCheck, add.PrivateLabelingSetting), } + projectMemberEvents, err := c.addProjectMemberEvents(ctx, add.ResourceOwner, add.AggregateID, add.Admins) + if err != nil { + return nil, err + } + events = append(events, projectMemberEvents...) + postCommit, err := c.projectCreatedMilestone(ctx, &events) if err != nil { return nil, err @@ -187,6 +199,32 @@ func (c *Commands) checkProjectExists(ctx context.Context, projectID, resourceOw return agg.ResourceOwner, nil } +func (c *Commands) addProjectMemberEvents(ctx context.Context, resourceOwner, projectID string, admins []*AddProjectAdmin) ([]eventstore.Command, error) { + if len(admins) == 0 { + return nil, nil + } + events := make([]eventstore.Command, 0) + for _, admin := range admins { + _, err := c.checkUserExists(ctx, admin.ID, "") + if err != nil { + return nil, err + } + + roles := admin.Roles + if len(roles) == 0 { + roles = append(roles, domain.RoleProjectOwner) + } + + projectMemberWriteModel := NewProjectMemberWriteModel(projectID, admin.ID, resourceOwner) + events = append(events, project.NewProjectMemberAddedEvent(ctx, + ProjectAggregateFromWriteModelWithCTX(ctx, &projectMemberWriteModel.WriteModel), + admin.ID, + roles..., + )) + } + return events, nil +} + type ChangeProject struct { models.ObjectRoot diff --git a/internal/command/project_test.go b/internal/command/project_test.go index 6c03420f6bc..46ea878b1dd 100644 --- a/internal/command/project_test.go +++ b/internal/command/project_test.go @@ -6,6 +6,7 @@ import ( "github.com/muhlemmer/gu" "github.com/stretchr/testify/assert" + "golang.org/x/text/language" "github.com/zitadel/zitadel/internal/api/authz" "github.com/zitadel/zitadel/internal/domain" @@ -14,6 +15,7 @@ import ( "github.com/zitadel/zitadel/internal/id" id_mock "github.com/zitadel/zitadel/internal/id/mock" "github.com/zitadel/zitadel/internal/repository/project" + "github.com/zitadel/zitadel/internal/repository/user" "github.com/zitadel/zitadel/internal/zerrors" ) @@ -195,6 +197,155 @@ func TestCommandSide_AddProject(t *testing.T) { }, }, }, + { + name: "project, with admins and no roles, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectPush( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + ), + project.NewProjectMemberAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", []string{"PROJECT_OWNER"}..., + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &AddProject{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"}, + Name: "project", + ProjectRoleAssertion: true, + ProjectRoleCheck: true, + HasProjectCheck: true, + PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + Admins: []*AddProjectAdmin{ + { + ID: "user1", + }, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "project, with admins and specific roles, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter( + eventFromEventPusher( + user.NewHumanAddedEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "username1", + "firstname1", + "lastname1", + "nickname1", + "displayname1", + language.German, + domain.GenderMale, + "email1", + true, + ), + ), + ), + expectPush( + project.NewProjectAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "project", true, true, true, + domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + ), + project.NewProjectMemberAddedEvent( + context.Background(), + &project.NewAggregate("project1", "org1").Aggregate, + "user1", []string{"role1", "role2"}..., + ), + ), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &AddProject{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"}, + Name: "project", + ProjectRoleAssertion: true, + ProjectRoleCheck: true, + HasProjectCheck: true, + PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + Admins: []*AddProjectAdmin{ + { + ID: "user1", + Roles: []string{"role1", "role2"}, + }, + }, + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, + { + name: "project, admin user not found", + fields: fields{ + eventstore: expectEventstore( + expectFilter(), + expectFilter(), + ), + checkPermission: newMockPermissionCheckAllowed(), + }, + args: args{ + ctx: authz.WithInstanceID(context.Background(), "instanceID"), + project: &AddProject{ + ObjectRoot: models.ObjectRoot{AggregateID: "project1", ResourceOwner: "org1"}, + Name: "project", + ProjectRoleAssertion: true, + ProjectRoleCheck: true, + HasProjectCheck: true, + PrivateLabelingSetting: domain.PrivateLabelingSettingAllowLoginUserResourceOwnerPolicy, + Admins: []*AddProjectAdmin{ + { + ID: "user2", + Roles: []string{"role1", "role2"}, + }, + }, + }, + }, + res: res{ + err: zerrors.IsPreconditionFailed, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto index 19dfa9153a7..479acdc1f36 100644 --- a/proto/zitadel/management.proto +++ b/proto/zitadel/management.proto @@ -9850,6 +9850,12 @@ message ListProjectChangesResponse { } message AddProjectRequest { + message Admin { + string user_id = 1; + // specify the Project Member Roles for the provided user (default is PROJECT_OWNER if roles are empty + repeated string roles = 3; + } + string name = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, (google.api.field_behavior) = REQUIRED, @@ -9880,6 +9886,8 @@ message AddProjectRequest { description: "Define which private labeling/branding should trigger when getting to a login of this project."; } ]; + // List of users and Project Member roles (PROJECT_OWNER, by default) to be assigned to those users. + repeated Admin admins = 6; } message AddProjectResponse { diff --git a/proto/zitadel/project/v2beta/project_service.proto b/proto/zitadel/project/v2beta/project_service.proto index 66f221b9116..bfeae8573eb 100644 --- a/proto/zitadel/project/v2beta/project_service.proto +++ b/proto/zitadel/project/v2beta/project_service.proto @@ -6,14 +6,16 @@ import "google/api/annotations.proto"; import "google/api/field_behavior.proto"; import "google/protobuf/duration.proto"; import "google/protobuf/struct.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/project/v2beta/query.proto"; -import "google/protobuf/timestamp.proto"; import "zitadel/filter/v2beta/filter.proto"; +import "zitadel/project/v2beta/query.proto"; +import "zitadel/user/v2/user.proto"; +import "zitadel/user/v2/user_service.proto"; option go_package = "github.com/zitadel/zitadel/pkg/grpc/project/v2beta;project"; @@ -669,6 +671,12 @@ service ProjectService { } message CreateProjectRequest { + message Admin { + string user_id = 1; + // specify the Project Member Roles for the provided user (default is PROJECT_OWNER if roles are empty + repeated string roles = 3; + } + // The unique identifier of the organization the project belongs to. string organization_id = 1 [ (validate.rules).string = {min_len: 1, max_len: 200}, @@ -707,6 +715,8 @@ message CreateProjectRequest { PrivateLabelingSetting private_labeling_setting = 7 [ (validate.rules).enum = {defined_only: true} ]; + // List of users and Project Member roles (PROJECT_OWNER, by default) to be assigned to those users. + repeated Admin admins = 8; option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { example: "{\"organizationId\":\"69629026806489455\",\"name\":\"MyProject\",\"projectRoleAssertion\":true,\"projectRoleCheck\":true,\"hasProjectCheck\":true,\"privateLabelingSetting\":\"PRIVATE_LABELING_SETTING_UNSPECIFIED\"}"; };