From 4aacfa842f3be82bd1e0396baded92067be20e79 Mon Sep 17 00:00:00 2001 From: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:34:43 +0100 Subject: [PATCH] feat: set metadata at the user level in CreateUser request (#11719) # Which Problems Are Solved Currently, metadata can be set/updated only for human users, but it should be available also for service accounts (machine users). # How the Problems Are Solved This is achieved by: - adding a metadata field at the root level in `CreateUserRequest`, which makes it available for both `human` and `machine` user types - returning an error when both the root level and human-level `metadata` fields are set in the `CreateUserRequest` - setting `human` and `machine` metadata from the root `metadata` field in the server layer - pushing `user.metadata.set` event during machine user creation in the command layer - adding integration tests # Additional Changes N/A # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11369 --------- Co-authored-by: Vitor Bari Buccianti Co-authored-by: Silvan <27845747+adlerhurst@users.noreply.github.com> --- internal/api/grpc/user/v2/human.go | 25 +- .../user/v2/integration_test/user_test.go | 379 ++++++++++++------ internal/api/grpc/user/v2/machine.go | 10 +- internal/api/grpc/user/v2/user.go | 4 +- internal/command/user_machine.go | 37 +- internal/command/user_machine_test.go | 69 ++++ internal/static/i18n/ar.yaml | 2 + internal/static/i18n/bg.yaml | 2 + internal/static/i18n/cs.yaml | 2 + internal/static/i18n/de.yaml | 2 + internal/static/i18n/en.yaml | 2 + internal/static/i18n/es.yaml | 2 + internal/static/i18n/fr.yaml | 2 + internal/static/i18n/hu.yaml | 2 + internal/static/i18n/id.yaml | 2 + internal/static/i18n/it.yaml | 2 + internal/static/i18n/ja.yaml | 2 + internal/static/i18n/ko.yaml | 2 + internal/static/i18n/mk.yaml | 2 + internal/static/i18n/nl.yaml | 2 + internal/static/i18n/pl.yaml | 2 + internal/static/i18n/pt.yaml | 2 + internal/static/i18n/ro.yaml | 2 + internal/static/i18n/ru.yaml | 2 + internal/static/i18n/sv.yaml | 2 + internal/static/i18n/tr.yaml | 2 + internal/static/i18n/uk.yaml | 2 + internal/static/i18n/zh.yaml | 2 + proto/zitadel/user/v2/user_service.proto | 16 +- 29 files changed, 440 insertions(+), 144 deletions(-) diff --git a/internal/api/grpc/user/v2/human.go b/internal/api/grpc/user/v2/human.go index 03af0f75b..05391bce1 100644 --- a/internal/api/grpc/user/v2/human.go +++ b/internal/api/grpc/user/v2/human.go @@ -15,14 +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) (*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(), - } +func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string, metadata []*user.Metadata) (*connect.Response[user.CreateUserResponse], error) { + if len(metadata) > 0 && len(humanPb.Metadata) > 0 { //nolint:staticcheck + return nil, zerrors.ThrowInvalidArgument(nil, "USERv2-j0z7uy", "Errors.User.Metadata.Conflicting") } + if len(metadata) == 0 && len(humanPb.Metadata) > 0 { //nolint:staticcheck + metadata = humanPb.Metadata //nolint:staticcheck + } + metadataEntries := setMetadataEntries(metadata) addHumanPb := &user.AddHumanUserRequest{ Username: userName, UserId: userId, @@ -69,6 +69,17 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs }), nil } +func setMetadataEntries(metadata []*user.Metadata) []*user.SetMetadataEntry { + metadataEntries := make([]*user.SetMetadataEntry, len(metadata)) + for i, metadataEntry := range metadata { + metadataEntries[i] = &user.SetMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } + return metadataEntries +} + 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 { 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 9a959cb0b..181b42f93 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -3111,7 +3111,7 @@ func TestServer_CreateUser(t *testing.T) { }, }, { - name: "with metadata", + name: "error - metadata on both root and human", testCase: func(runId string) testCase { username := fmt.Sprintf("donald.duck+%s", runId) email := username + "@example.com" @@ -3135,16 +3135,16 @@ func TestServer_CreateUser(t *testing.T) { }, 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")))}, }, }, }, + Metadata: []*user.Metadata{ + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + }, }, }, - want: &user.CreateUserResponse{ - Id: "is generated", - }, + wantErr: true, } }, }, @@ -3514,154 +3514,271 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { type testCase struct { name string args args - assert func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) + assert func( + t *testing.T, + createResponse *user.CreateUserResponse, + getResponse *user.GetUserByIDResponse, + getMetadataResponse *user.ListUserMetadataResponse, + ) } tests := []struct { name string testCase func(runId string) testCase - }{{ - name: "human given username", - testCase: func(runId string) testCase { - username := fmt.Sprintf("donald.duck+%s", runId) - email := username + "@example.com" - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - Username: &username, - UserType: &user.CreateUserRequest_Human_{ - Human: &user.CreateUserRequest_Human{ - Profile: &user.SetHumanProfile{ - GivenName: "Donald", - FamilyName: "Duck", - }, - Email: &user.SetHumanEmail{ - Email: email, + }{ + { + name: "human given username", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, }, }, }, }, - }, - assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, username, getResponse.GetUser().GetUsername()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, }, - }, { - name: "human username default to email", - testCase: func(runId string) testCase { - username := fmt.Sprintf("donald.duck+%s", runId) - email := username + "@example.com" - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - UserType: &user.CreateUserRequest_Human_{ - Human: &user.CreateUserRequest_Human{ - Profile: &user.SetHumanProfile{ - GivenName: "Donald", - FamilyName: "Duck", - }, - Email: &user.SetHumanEmail{ - Email: email, + { + name: "human with embedded metadata", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + }, }, }, }, }, - }, - assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, email, getResponse.GetUser().GetUsername()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + metadataByKey := getMetadataMap(getMetadataResponse) + assert.Len(t, getMetadataResponse.GetMetadata(), 2) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"]) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value2"))), metadataByKey["key2"]) + }, + } + }, }, - }, { - name: "service accountname given", - testCase: func(runId string) testCase { - username := fmt.Sprintf("donald.duck+%s", runId) - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - Username: &username, - UserType: &user.CreateUserRequest_Machine_{ - Machine: &user.CreateUserRequest_Machine{ - Name: "donald", + { + name: "human with metadata", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, + }, + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, }, }, }, - }, - assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, username, getResponse.GetUser().GetUsername()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + metadataByKey := getMetadataMap(getMetadataResponse) + assert.Len(t, getMetadataResponse.GetMetadata(), 1) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"]) + }, + } + }, }, - }, { - name: "service accountname default to generated id", - testCase: func(runId string) testCase { - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - UserType: &user.CreateUserRequest_Machine_{ - Machine: &user.CreateUserRequest_Machine{ - Name: "donald", + { + name: "human username default to email", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + email := username + "@example.com" + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Human_{ + Human: &user.CreateUserRequest_Human{ + Profile: &user.SetHumanProfile{ + GivenName: "Donald", + FamilyName: "Duck", + }, + Email: &user.SetHumanEmail{ + Email: email, + }, + }, }, }, }, - }, - assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, email, getResponse.GetUser().GetUsername()) + }, + } + }, }, - }, { - name: "service accountname default to given id", - testCase: func(runId string) testCase { - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - UserId: &runId, - UserType: &user.CreateUserRequest_Machine_{ - Machine: &user.CreateUserRequest_Machine{ - Name: "donald", + { + name: "service accountname given", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, }, }, }, - }, - assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, runId, getResponse.GetUser().GetUsername()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + }, + } + }, }, - }, { - name: "machine access token type jwt", - testCase: func(runId string) testCase { - return testCase{ - args: args{ - ctx: OrgCTX, - req: &user.CreateUserRequest{ - OrganizationId: Instance.DefaultOrg.Id, - UserId: &runId, - UserType: &user.CreateUserRequest_Machine_{ - Machine: &user.CreateUserRequest_Machine{ - Name: "donald", - AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + { + name: "service account with metadata", + testCase: func(runId string) testCase { + username := fmt.Sprintf("donald.duck+%s", runId) + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + Username: &username, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + Metadata: []*user.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, }, }, }, - }, - assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) { - assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType()) - }, - } + assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) { + assert.Equal(t, username, getResponse.GetUser().GetUsername()) + metadataByKey := getMetadataMap(getMetadataResponse) + assert.Len(t, getMetadataResponse.GetMetadata(), 2) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"]) + assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value2"))), metadataByKey["key2"]) + }, + } + }, }, - }} + { + name: "service accountname default to generated id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername()) + }, + } + }, + }, + { + name: "service accountname default to given id", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, runId, getResponse.GetUser().GetUsername()) + }, + } + }, + }, + { + name: "machine access token type jwt", + testCase: func(runId string) testCase { + return testCase{ + args: args{ + ctx: OrgCTX, + req: &user.CreateUserRequest{ + OrganizationId: Instance.DefaultOrg.Id, + UserId: &runId, + UserType: &user.CreateUserRequest_Machine_{ + Machine: &user.CreateUserRequest_Machine{ + Name: "donald", + AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, + }, + }, + }, + }, + assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) { + assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType()) + }, + } + }, + }, + } for i, tt := range tests { t.Run(tt.name, func(t *testing.T) { now := time.Now() @@ -3674,11 +3791,23 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { UserId: createResponse.GetId(), }) require.NoError(t, err) - test.assert(t, createResponse, getResponse) + gotMetadataResponse, err := Client.ListUserMetadata(test.args.ctx, &user.ListUserMetadataRequest{ + UserId: createResponse.GetId(), + }) + require.NoError(t, err) + test.assert(t, createResponse, getResponse, gotMetadataResponse) }) } } +func getMetadataMap(getMetadataResponse *user.ListUserMetadataResponse) map[string][]byte { + metadataByKey := make(map[string][]byte, len(getMetadataResponse.GetMetadata())) + for _, md := range getMetadataResponse.GetMetadata() { + metadataByKey[md.Key] = md.Value + } + return metadataByKey +} + func TestServer_CreateUser_Permission(t *testing.T) { newOrgOwnerEmail := integration.Email() newOrg := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), newOrgOwnerEmail) diff --git a/internal/api/grpc/user/v2/machine.go b/internal/api/grpc/user/v2/machine.go index a3823058e..74645387d 100644 --- a/internal/api/grpc/user/v2/machine.go +++ b/internal/api/grpc/user/v2/machine.go @@ -12,7 +12,14 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) -func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*connect.Response[user.CreateUserResponse], error) { +func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string, reqMetadata []*user.Metadata) (*connect.Response[user.CreateUserResponse], error) { + metadata := make([]*command.AddMetadataEntry, len(reqMetadata)) + for i, metadataEntry := range reqMetadata { + metadata[i] = &command.AddMetadataEntry{ + Key: metadataEntry.GetKey(), + Value: metadataEntry.GetValue(), + } + } cmd := &command.Machine{ Username: userName, Name: machinePb.Name, @@ -22,6 +29,7 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea ResourceOwner: orgId, AggregateID: userId, }, + Metadata: metadata, } details, err := s.command.AddMachine( ctx, diff --git a/internal/api/grpc/user/v2/user.go b/internal/api/grpc/user/v2/user.go index a45192876..f99ee6d62 100644 --- a/internal/api/grpc/user/v2/user.go +++ b/internal/api/grpc/user/v2/user.go @@ -414,9 +414,9 @@ func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *connect.Request[u 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.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId) + return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId, req.Msg.GetMetadata()) case *user.CreateUserRequest_Machine_: - return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId()) + return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId(), req.Msg.GetMetadata()) default: return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented") } diff --git a/internal/command/user_machine.go b/internal/command/user_machine.go index c28161700..a20e5dbd3 100644 --- a/internal/command/user_machine.go +++ b/internal/command/user_machine.go @@ -26,6 +26,7 @@ type Machine struct { Description string AccessTokenType domain.OIDCTokenType PermissionCheck PermissionCheck + Metadata []*AddMetadataEntry } func (m *Machine) IsZero() bool { @@ -40,11 +41,9 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati if a.ID == "" { return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p2mi", "Errors.User.UserIDMissing") } - if machine.Name == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bs9Ds", "Errors.User.Invalid") - } - if machine.Username == "" { - return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bm9Ds", "Errors.User.Invalid") + err = machine.Validate() + if err != nil { + return nil, err } return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) { ctx, span := tracing.NewSpan(ctx) @@ -65,9 +64,18 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati if err != nil { return nil, err } - return []eventstore.Command{ + cmds := []eventstore.Command{ user.NewMachineAddedEvent(ctx, &a.Aggregate, machine.Username, machine.Name, machine.Description, domainPolicy.UserLoginMustBeDomain || orgScopedUsername, machine.AccessTokenType), - }, nil + } + for _, metadataEntry := range machine.Metadata { + cmds = append(cmds, user.NewMetadataSetEvent( + ctx, + &a.Aggregate, + metadataEntry.Key, + metadataEntry.Value, + )) + } + return cmds, nil }, nil } } @@ -214,3 +222,18 @@ func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, fil } return writeModel, err } + +func (m *Machine) Validate() (err error) { + if m.Name == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-bs9Ds", "Errors.User.Invalid") + } + if m.Username == "" { + return zerrors.ThrowInvalidArgument(nil, "COMMAND-bm9Ds", "Errors.User.Invalid") + } + for _, metadataEntry := range m.Metadata { + if err := metadataEntry.Valid(); err != nil { + return err + } + } + return nil +} diff --git a/internal/command/user_machine_test.go b/internal/command/user_machine_test.go index 32664b5c1..c12360a6f 100644 --- a/internal/command/user_machine_test.go +++ b/internal/command/user_machine_test.go @@ -105,6 +105,64 @@ func TestCommandSide_AddMachine(t *testing.T) { err: zerrors.IsPreconditionFailed, }, }, + { + name: "add machine with invalid metadata key, error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + Metadata: []*AddMetadataEntry{ + { + Key: "", + Value: []byte("value1"), + }, + }, + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, + { + name: "add machine with invalid metadata value, error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), + }, + args: args{ + ctx: context.Background(), + machine: &Machine{ + ObjectRoot: models.ObjectRoot{ + ResourceOwner: "org1", + }, + Description: "description", + Name: "name", + Username: "username", + Metadata: []*AddMetadataEntry{ + { + Key: "key1", + Value: []byte(""), + }, + }, + }, + }, + res: res{ + err: zerrors.IsErrorInvalidArgument, + }, + }, { name: "add machine, ok", fields: fields{ @@ -131,6 +189,11 @@ func TestCommandSide_AddMachine(t *testing.T) { true, domain.OIDCTokenTypeBearer, ), + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key1", + []byte("value1"), + ), ), ), idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"), @@ -144,6 +207,12 @@ func TestCommandSide_AddMachine(t *testing.T) { Description: "description", Name: "name", Username: "username", + Metadata: []*AddMetadataEntry{ + { + Key: "key1", + Value: []byte("value1"), + }, + }, }, }, res: res{ diff --git a/internal/static/i18n/ar.yaml b/internal/static/i18n/ar.yaml index a744b46e4..68e566d51 100644 --- a/internal/static/i18n/ar.yaml +++ b/internal/static/i18n/ar.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "تعذر توليد السر" PAT: NotFound: "رمز الوصول الشخصي غير موجود" + Metadata: + Conflicting: "يجب عدم تعيين البيانات الوصفية في كلٍ من حقول [user] و[human] في الوقت نفسه" NotHuman: "يجب أن يكون المستخدم شخصياً" NotMachine: "يجب أن يكون المستخدم تقنياً" WrongType: "غير مسموح لنوع المستخدم هذا" diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index a9370e936..f71cd3a35 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Тайната не можа да бъде генерирана" PAT: NotFound: "Личен токен за достъп не е намерен" + Metadata: + Conflicting: "Метаданните не трябва да бъдат зададени едновременно в полетата [user] и [human]" NotHuman: "Потребителят трябва да е личен" NotMachine: "Потребителят трябва да е техничен" WrongType: "Не е разрешено за този тип потребител" diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index f1744b7b5..9eb51cf0f 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Tajemství nelze vygenerovat" PAT: NotFound: "Osobní přístupový token nenalezen" + Metadata: + Conflicting: "Metadata nesmí být nastavena současně v polích [user] a [human]" NotHuman: "Uživatel musí být fyzická osoba" NotMachine: "Uživatel musí být systémový uživatel / technická entita" WrongType: "Nepovolen pro tento typ uživatele" diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index 5be62857b..c4d5cc1da 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Secret konnte nicht generiert werden" PAT: NotFound: "Persönliches Access Token nicht gefunden" + Metadata: + Conflicting: "Metadaten dürfen nicht gleichzeitig in den Feldern [user] und [human] gesetzt sein" NotHuman: "Der Benutzer muss eine Person sein" NotMachine: "Der Benutzer muss technisch sein" WrongType: "Für diesen Benutzertyp nicht erlaubt" diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index c85f774b1..c1e5490c0 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "Secret could not be generated" PAT: NotFound: "Personal Access Token not found" + Metadata: + Conflicting: "Metadata must not be set on both the user and human fields" NotHuman: "The User must be personal" NotMachine: "The User must be technical" WrongType: "Not allowed for this user type" diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index 04cc0375f..4c0a4d412 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "El secreto no pudo generarse" PAT: NotFound: "Token de acceso personal no encontrado" + Metadata: + Conflicting: "Los metadatos no deben definirse simultáneamente en los campos [user] y [human]" NotHuman: "El usuario debe ser personal" NotMachine: "El usuario debe ser técnico" WrongType: "Tipo de usuario no permitido" diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index 5cf583bab..c7624b295 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Secret n'a pas pu être généré" PAT: NotFound: "Token d'accès personnel non trouvé" + Metadata: + Conflicting: "Les métadonnées ne doivent pas être définies simultanément dans les champs [user] et [human]" NotHuman: "L'utilisateur doit être personnel" NotMachine: "L'utilisateur doit être technique" WrongType: "Non autorisé pour ce type d'utilisateur" diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index f75c5468d..66a64ec8d 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "A titkot nem sikerült generálni" PAT: NotFound: "A személyes hozzáférési token nem található" + Metadata: + Conflicting: "A metaadatok nem lehetnek egyszerre beállítva a [user] és a [human] mezőkben" NotHuman: "A felhasználónak személyesnek kell lennie" NotMachine: "A felhasználónak technikai jellegűnek kell lennie" WrongType: "Ez a felhasználótípus számára nem engedélyezett" diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index 33df92c48..e591b2064 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Rahasia tidak dapat dibuat" PAT: NotFound: "Token Akses Pribadi tidak ditemukan" + Metadata: + Conflicting: "Metadata tidak boleh diatur secara bersamaan pada field [user] dan [human]" NotHuman: "Pengguna harus bersifat pribadi" NotMachine: "Pengguna harus teknis" WrongType: "Tidak diizinkan untuk jenis pengguna ini" diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index c7cc6dbd3..534b6dda2 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Non è stato possibile generare il Secret" PAT: NotFound: "Personal Access Token non trovato" + Metadata: + Conflicting: "I metadati non devono essere impostati contemporaneamente nei campi [user] e [human]" NotHuman: "L'utente deve essere personale" NotMachine: "L'utente deve essere tecnico" WrongType: "Non consentito per questo tipo di utente" diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index ef1e4d0ff..8581b88b0 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "シークレットの生成に失敗しました" PAT: NotFound: "パーソナルアクセストークンが見つかりません" + Metadata: + Conflicting: "メタデータは [user] と [human] の両方のフィールドに同時に設定してはなりません" NotHuman: "ユーザーはパーソナルである必要があります" NotMachine: "ユーザーはテクニカルである必要があります" WrongType: "このユーザータイプは許可されていません" diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index b7d851363..a7cbcf954 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "시크릿을 생성할 수 없습니다" PAT: NotFound: "개인 액세스 토큰을 찾을 수 없습니다" + Metadata: + Conflicting: "메타데이터는 [user] 및 [human] 필드에 동시에 설정되어서는 안 됩니다" NotHuman: "사용자는 개인이어야 합니다" NotMachine: "사용자는 기술적이어야 합니다" WrongType: "이 사용자 유형에는 허용되지 않습니다" diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 5b18c6c18..37ae0d2c1 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Тајната не може да биде генерирана" PAT: NotFound: "Личниот токен за пристап не е пронајден" + Metadata: + Conflicting: "Метаподатоците не смеат да бидат поставени истовремено во полињата [user] и [human]" NotHuman: "Корисникот мора да биде личност" NotMachine: "Корисникот мора да биде технички" WrongType: "Не е дозволено за овој тип на корисник" diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index ec654c3f4..ba1a0d6fd 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Geheim kon niet worden gegenereerd" PAT: NotFound: "Persoonlijk toegangstoken niet gevonden" + Metadata: + Conflicting: "Metadata mogen niet tegelijkertijd in de velden [user] en [human] worden ingesteld" NotHuman: "De gebruiker moet persoonlijk zijn" NotMachine: "De gebruiker moet technisch zijn" WrongType: "Niet toegestaan voor dit gebruikerstype" diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 90252b948..f941dbbcf 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Sekret nie mógł zostać wygenerowany" PAT: NotFound: "Osobisty token dostępu nie znaleziony" + Metadata: + Conflicting: "Metadane nie mogą być ustawione jednocześnie w polach [user] i [human]" NotHuman: "Użytkownik musi być osobą" NotMachine: "Użytkownik musi być techniczny" WrongType: "Niedozwolone dla tego typu użytkownika" diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index f6b7318ed..8c5bd3862 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Não foi possível gerar o segredo" PAT: NotFound: "Token de Acesso Pessoal não encontrado" + Metadata: + Conflicting: "Os metadados não devem ser definidos simultaneamente nos campos [user] e [human]" NotHuman: "O usuário deve ser pessoal" NotMachine: "O usuário deve ser técnico" WrongType: "Não permitido para este tipo de usuário" diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index aa70b6ba7..309a6c476 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "Secretul nu a putut fi generat" PAT: NotFound: "Token-ul de acces personal nu a fost găsit" + Metadata: + Conflicting: "Metadatele nu trebuie setate simultan în câmpurile [user] și [human]" NotHuman: "Utilizatorul trebuie să fie personal" NotMachine: "Utilizatorul trebuie să fie tehnic" WrongType: "Nu este permis pentru acest tip de utilizator" diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index b3b813aa1..007f46d64 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "Ключ не может быть сгенерирован" PAT: NotFound: "Токен личного доступа не найден" + Metadata: + Conflicting: "Метаданные не должны быть заданы одновременно в полях [user] и [human]" NotHuman: "Пользователь должен быть персональным" NotMachine: "Пользователь должен быть техническим" WrongType: "Запрещено для данного типа пользователя" diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index d8b914bac..afed8046c 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Hemlig kod kunde inte genereras" PAT: NotFound: "Personlig åtkomst-token hittades inte" + Metadata: + Conflicting: "Metadata får inte anges samtidigt i fälten [user] och [human]" NotHuman: "Användaren måste vara en person" NotMachine: "Användaren måste vara en maskin" WrongType: "Inte tillåtet för denna användartyp" diff --git a/internal/static/i18n/tr.yaml b/internal/static/i18n/tr.yaml index 393ea979f..1e1c6c6fd 100644 --- a/internal/static/i18n/tr.yaml +++ b/internal/static/i18n/tr.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "Gizli anahtar oluşturulamadı" PAT: NotFound: "Kişisel Erişim Token'ı bulunamadı" + Metadata: + Conflicting: "Meta veriler [user] ve [human] alanlarında aynı anda ayarlanmamalıdır" NotHuman: "Kullanıcı kişisel olmalı" NotMachine: "Kullanıcı teknik olmalı" WrongType: "Bu kullanıcı türü için izin verilmiyor" diff --git a/internal/static/i18n/uk.yaml b/internal/static/i18n/uk.yaml index 88a829ff4..2b3de29bb 100644 --- a/internal/static/i18n/uk.yaml +++ b/internal/static/i18n/uk.yaml @@ -131,6 +131,8 @@ Errors: CouldNotGenerate: "Не вдалося згенерувати секрет" PAT: NotFound: "Персональний токен доступу не знайдено" + Metadata: + Conflicting: "Метадані не повинні бути встановлені одночасно в полях [user] і [human]" NotHuman: "Користувач повинен бути особистим" NotMachine: "Користувач повинен бути технічним" WrongType: "Не дозволено для цього типу користувача" diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index 80508b534..3f0d6b9d4 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -132,6 +132,8 @@ Errors: CouldNotGenerate: "无法生成秘密" PAT: NotFound: "未找到个人访问令牌" + Metadata: + Conflicting: "元数据不得同时在 [user] 和 [human] 字段中设置" NotHuman: "用户必须是个人" NotMachine: "用户必须是技术人员" WrongType: "此用户类型不允许" diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index c7dd8a681..fc1085f49 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -2078,7 +2078,7 @@ message CreateUserRequest{ // An Implementation of RFC 6238 is used, with HMAC-SHA-1 and time-step of 30 seconds. // Currently no other options are supported, and if anything different is used the validation will fail. optional string totp_secret = 8 [ - (validate.rules).string = {min_len: 1 max_len: 200}, + (validate.rules).string = {min_len: 1, max_len: 200}, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { min_length: 1, max_length: 200, @@ -2086,8 +2086,12 @@ message CreateUserRequest{ } ]; - // Metadata to bet set. The values have to be base64 encoded. + // Deprecated: Use the root-level `metadata` field in `CreateUserRequest` instead. + // If both metadata fields are set, the validation will fail. + // + // Metadata to be set. The values have to be base64 encoded. repeated Metadata metadata = 9 [ + deprecated = true, (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" } @@ -2177,6 +2181,14 @@ message CreateUserRequest{ // You can change it to JWT by specifying the access token type in the machine field. Machine machine = 5; } + + // Metadata to be set. The values have to be base64 encoded. + repeated Metadata metadata = 6 [ + (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = { + example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]" + } + ]; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { example: "{\"organizationId\":\"69629026806489455\",\"userId\":\"163840776835432345\",\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"nickName\":\"Mini\",\"displayName\":\"Minnie Mouse\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_FEMALE\"},\"email\":{\"email\":\"mini@mouse.com\",\"sendCode\":{\"urlTemplate\":\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"idpLinks\":[{\"idpId\":\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\",\"userId\":\"6516849804890468048461403518\",\"userName\":\"user@external.com\"}],\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}"; };