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 <mail@conblem.me>
Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
Co-authored-by: Livio Spring <livio.a@gmail.com>
This commit is contained in:
Gayathri Vijayan
2025-09-12 11:16:49 +02:00
committed by GitHub
parent b892fc9b28
commit d7f202d20f
31 changed files with 358 additions and 31 deletions

View File

@@ -2,14 +2,17 @@
title="{{ 'PROJECT.PAGES.CREATE' | translate }}"
[createSteps]="1"
[currentCreateStep]="1"
(closed)="close()"
(closed)="location.back()"
>
<h1>{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}</h1>
<form cdkFocusRegionStart (ngSubmit)="saveProject()">
<form *ngIf="userService.user$ | async as user" cdkFocusRegionStart [formGroup]="form" (ngSubmit)="saveProject(user)">
<mat-slide-toggle class="example-margin" color="primary" [formControl]="form.controls.selfAccount">
{{ 'PROJECT.PAGES.USERSELFACCOUNT' | translate }}
</mat-slide-toggle>
<h1>{{ 'PROJECT.PAGES.CREATE_DESC' | translate }}</h1>
<div class="column">
<cnsl-form-field class="formfield" hintLabel="The name is required!">
<cnsl-label>{{ 'PROJECT.NAME' | translate }}</cnsl-label>
<input cnslInput cdkFocusInitial autofocus [(ngModel)]="project.name" [ngModelOptions]="{ standalone: true }" />
<input cnslInput cdkFocusInitial autofocus [formControl]="form.controls.name" />
</cnsl-form-field>
</div>
@@ -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 }}
</button>
</form></cnsl-create-layout
>
</form>
</cnsl-create-layout>

View File

@@ -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<typeof this.buildForm>;
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();
}
}

View File

@@ -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 {}

View File

@@ -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<GetDefaultPasswordComplexityPolicyResponse> {
return this.grpcService.mgmtNew.getDefaultPasswordComplexityPolicy({});
}
public addProject(req: MessageInitShape<typeof AddProjectRequestSchema>): Promise<AddProjectResponse> {
return this.grpcService.mgmtNew.addProject(req);
}
}

View File

@@ -1959,6 +1959,7 @@
"CREATE_DESC": "Въведете името на вашия проект.",
"ROLE": "Роля",
"NOITEMS": "Без проекти",
"USERSELFACCOUNT": "Използвайте личния си акаунт като собственик на проекта.",
"ZITADELPROJECT": "Това принадлежи на проекта ZITADEL. ",
"TYPE": {
"OWNED": "Притежавани проекти",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "プロジェクトの名前を入力します。",
"ROLE": "ロール",
"NOITEMS": "プロジェクトはありません",
"USERSELFACCOUNT": "プロジェクトの所有者としては、ご自身の個人アカウントをご利用ください。",
"ZITADELPROJECT": "これはZITADELプロジェクトに属します。注意変更した場合、ZITADELは意図したとおりに動作しないことがあります。",
"TYPE": {
"OWNED": "所有プロジェクト",

View File

@@ -1960,6 +1960,7 @@
"CREATE_DESC": "프로젝트의 이름을 입력하세요.",
"ROLE": "역할",
"NOITEMS": "프로젝트가 없습니다",
"USERSELFACCOUNT": "프로젝트 소유자로 개인 계정을 사용해 주십시오",
"ZITADELPROJECT": "이 프로젝트는 ZITADEL 프로젝트에 속해 있습니다. 변경 시 ZITADEL이 의도한 대로 작동하지 않을 수 있습니다.",
"TYPE": {
"OWNED": "소유한 프로젝트",

View File

@@ -1961,6 +1961,7 @@
"CREATE_DESC": "Внесете го името на вашиот проект.",
"ROLE": "Улога",
"NOITEMS": "Нема проекти",
"USERSELFACCOUNT": "Користете ја вашата лична корисничка сметка како сопственик на проектот.",
"ZITADELPROJECT": "Ова припаѓа на ZITADEL проектот. Внимание: Ако направите промени, ZITADEL може да не работи како што е предвидено.",
"TYPE": {
"OWNED": "Сопствени проекти",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -2045,6 +2045,7 @@
"CREATE_DESC": "Введите название вашего проекта.",
"ROLE": "Роль",
"NOITEMS": "Нет проектов",
"USERSELFACCOUNT": "Используйте свою личную учетную запись в качестве владельца проекта.",
"ZITADELPROJECT": "Это принадлежит проекту ZITADEL. Осторожно: Если вы внесёте изменения, ZITADEL может вести себя не так, как предполагалось.",
"TYPE": {
"OWNED": "Собственные проекты",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1963,6 +1963,7 @@
"CREATE_DESC": "插入您的项目名称。",
"ROLE": "角色",
"NOITEMS": "没有项目",
"USERSELFACCOUNT": "使用您的个人账户作为项目所有者",
"ZITADELPROJECT": "这属于 ZITADEL 项目。注意如果您修改了设置ZITADEL 可能无法正常运行。",
"TYPE": {
"OWNED": "拥有的项目",

View File

@@ -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{

View File

@@ -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) {

View File

@@ -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:

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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\"}";
};