From 247e2785052957c86c3e0b16148617d26d6be511 Mon Sep 17 00:00:00 2001 From: Vitor Bari Buccianti Date: Fri, 6 Mar 2026 14:57:52 +0100 Subject: [PATCH] feat: delete metadata on SetUserMetadata api (#11776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Currently, to delete metadata a key, callers must use a separate [DeleteMetadata](https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.DeleteUserMetadata) API. This increases the complexity on client-side for operations where metadata need to be synchronized. # How the Problems Are Solved Introduce the behavior to [SetUserMetadata](https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.SetUserMetadata): When a metadata entry is passed with an empty value ("" / empty bytes): - If the key exists → delete the key - If the key does not exist → no-op (idempotent, no error) # Additional Changes - Updated [User Metadata page](https://zitadel.com/docs/guides/manage/customize/user-metadata#manage-user-metadata-through-the-management-api) # Additional Context Similar behavior will be applied to [UpdateUser](https://zitadel.com/docs/reference/api/user/zitadel.user.v2.UserService.UpdateUser) and [SetOrganizationMetadata](https://zitadel.com/docs/reference/api/org/zitadel.org.v2.OrganizationService.SetOrganizationMetadata) Breaking change? Currently, sending an empty value returns an error — no client should be intentionally relying on that error as part of a working workflow. The change goes from "rejected input" to "accepted input with defined semantics." In other words, it relaxes a restriction rather than tightening one. Existing valid calls continue to work exactly as before. The only scenario where it could be "breaking" is if someone explicitly depends on the error response for empty values (e.g., using it as a validation check), which would be unusual. --------- Co-authored-by: Gayathri Vijayan Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../guides/manage/customize/user-metadata.mdx | 19 ++- .../user/v2/integration_test/metadata_test.go | 154 ++++++++++++++---- .../user/v2/integration_test/user_test.go | 38 +++-- internal/command/user_metadata.go | 16 +- internal/command/user_metadata_test.go | 41 +++-- proto/zitadel/user/v2/user_service.proto | 6 +- 6 files changed, 205 insertions(+), 69 deletions(-) diff --git a/apps/docs/content/guides/manage/customize/user-metadata.mdx b/apps/docs/content/guides/manage/customize/user-metadata.mdx index 445a77369..a58486741 100644 --- a/apps/docs/content/guides/manage/customize/user-metadata.mdx +++ b/apps/docs/content/guides/manage/customize/user-metadata.mdx @@ -34,7 +34,7 @@ You will find more information in our guides on how to [authenticate users](/gui Use one of these methods to get the metadata for the currently logged in user. -In case you want to manage metadata for other users than the currently logged in user, then you must use the [management service](#manage-user-metadata-through-the-management-api). +In case you want to manage metadata for other users than the currently logged in user, then you must use the [UserService API](#manage-user-metadata-through-the-api). ### Request metadata from userinfo endpoint @@ -217,12 +217,17 @@ An example response for your search looks like this: When you build your own registration UI you have the possibility to have custom fields and add them to the metadata of your user. Learn everything about how to build your own registration UI [here](/guides/integrate/onboarding/end-users#build-your-own-registration-form). -## Manage user metadata through the management API +## Manage user metadata through the API The previous methods allowed you to retrieve metadata only for the `sub` in the access token. -In case you want to get the metadata for another user, you need to use the management service. -The user that calls the management service must have [administrator permissions](/guides/manage/console/administrators). -A user can be either a User (Human) or a Service Account. +In case you want to manage metadata for another user, use the [User Service API](/reference/api/user). +The calling user must have the `user.write` permission to set or delete metadata. -You can get [metadata of a user filtered by your query](/apis/resources/mgmt/management-service-list-user-metadata) or [get a metadata object from a user by a specific key](/apis/resources/mgmt/management-service-get-user-metadata). -The management service allows you to set and delete metadata, see the [API documentation for users](/reference/api/user). +Use [SetUserMetadata](/apis/resources/user_service_v2/user-service-set-user-metadata) to insert or update metadata entries. +Existing keys not included in the request are left unchanged. +To delete a metadata entry, pass the key with an empty value — if the key does not exist, it is ignored. +You can mix inserts, updates, and deletions in a single request. + +Alternatively, use [DeleteUserMetadata](/apis/resources/user_service_v2/user-service-delete-user-metadata) to delete one or more keys explicitly. + +Use [ListUserMetadata](/apis/resources/user_service_v2/user-service-list-user-metadata) to retrieve metadata for a user with optional filtering by key. diff --git a/internal/api/grpc/user/v2/integration_test/metadata_test.go b/internal/api/grpc/user/v2/integration_test/metadata_test.go index fcced5e6b..cc8be1a63 100644 --- a/internal/api/grpc/user/v2/integration_test/metadata_test.go +++ b/internal/api/grpc/user/v2/integration_test/metadata_test.go @@ -25,7 +25,7 @@ func TestServer_SetUserMetadata(t *testing.T) { ctx context.Context dep func(request *user.SetUserMetadataRequest) req *user.SetUserMetadataRequest - setDate bool + assert func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) wantErr bool }{ { @@ -48,7 +48,14 @@ func TestServer_SetUserMetadata(t *testing.T) { req: &user.SetUserMetadataRequest{ Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, }, - setDate: true, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + assert.WithinRange(t, setResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, }, { name: "set user metadata, multiple", @@ -63,7 +70,16 @@ func TestServer_SetUserMetadata(t *testing.T) { {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, }, }, - setDate: true, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + assert.WithinRange(t, setResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.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")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, }, { name: "set user metadata on non existent user", @@ -84,7 +100,14 @@ func TestServer_SetUserMetadata(t *testing.T) { req: &user.SetUserMetadataRequest{ Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}}, }, - setDate: true, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + assert.WithinRange(t, setResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, }, { name: "update user metadata with same value", @@ -96,37 +119,100 @@ func TestServer_SetUserMetadata(t *testing.T) { req: &user.SetUserMetadataRequest{ Metadata: []*user.Metadata{{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}}, }, - setDate: true, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + // assert that the set date is not updated, i.e., it is before testStartTime, because no change to the metadata should be made + assert.True(t, setResponse.GetSetDate().AsTime().Before(testStartTime)) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, + }, + { + name: "delete user metadata", + ctx: Instance.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{{Key: "key1", Value: nil}}, + }, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + assert.WithinRange(t, setResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + assert.Len(t, getResponse.GetMetadata(), 0) + }, + }, + { + name: "delete non-existent user metadata key is noop", + ctx: Instance.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{ + {Key: "key2", Value: nil}, // delete non-existent key; should be a no-op + }, + }, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + // assert that the set date is not updated, i.e., it is before testStartTime, because no change to the metadata should be made + assert.True(t, setResponse.GetSetDate().AsTime().Before(testStartTime)) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, + }, + { + name: "update and delete user metadata", + ctx: Instance.WithAuthorizationToken(OrgCTX, integration.UserTypeIAMOwner), + dep: func(req *user.SetUserMetadataRequest) { + req.UserId = Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key1", "value1") + Instance.SetUserMetadata(iamOwnerCTX, req.UserId, "key2", "value2") + }, + req: &user.SetUserMetadataRequest{ + Metadata: []*user.Metadata{ + {Key: "key1", Value: nil}, // delete + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated")))}, + }, + }, + assert: func(t *testing.T, setResponse *user.SetUserMetadataResponse, testStartTime time.Time, getResponse *user.ListUserMetadataResponse) { + assert.WithinRange(t, setResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated")))}, + } + assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - creationDate := time.Now().UTC() if tt.dep != nil { tt.dep(tt.req) } + testStartTime := time.Now().UTC() got, err := Client.SetUserMetadata(tt.ctx, tt.req) - changeDate := time.Now().UTC() if tt.wantErr { require.Error(t, err) return } require.NoError(t, err) - assertSetUserMetadataResponse(t, creationDate, changeDate, tt.setDate, got) - }) - } -} + Instance.TriggerUserByID(tt.ctx, tt.req.GetUserId()) -func assertSetUserMetadataResponse(t *testing.T, creationDate, changeDate time.Time, expectedSetDat bool, actualResp *user.SetUserMetadataResponse) { - if expectedSetDat { - if !changeDate.IsZero() { - assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, changeDate) - } else { - assert.WithinRange(t, actualResp.GetSetDate().AsTime(), creationDate, time.Now().UTC()) - } - } else { - assert.Nil(t, actualResp.SetDate) + gotMetadataResponse, err := Client.ListUserMetadata(tt.ctx, &user.ListUserMetadataRequest{ + UserId: tt.req.GetUserId(), + }) + require.NoError(t, err) + + tt.assert(t, got, testStartTime, gotMetadataResponse) + }) } } @@ -445,13 +531,13 @@ func TestServer_DeleteUserMetadata(t *testing.T) { name: "delete", ctx: iamOwnerCTX, prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { - creationDate := time.Now().UTC() + testStartTime := time.Now().UTC() userID := Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() request.UserId = userID key := "key1" Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") request.Keys = []string{key} - return creationDate, time.Time{} + return testStartTime, time.Time{} }, req: &user.DeleteUserMetadataRequest{}, wantDeletionDate: true, @@ -460,13 +546,13 @@ func TestServer_DeleteUserMetadata(t *testing.T) { name: "delete, empty list", ctx: iamOwnerCTX, prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { - creationDate := time.Now().UTC() + testStartTime := time.Now().UTC() userID := Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() request.UserId = userID key := "key1" Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) - return creationDate, time.Now().UTC() + return testStartTime, time.Now().UTC() }, req: &user.DeleteUserMetadataRequest{}, wantErr: true, @@ -475,14 +561,14 @@ func TestServer_DeleteUserMetadata(t *testing.T) { name: "delete, already removed", ctx: iamOwnerCTX, prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { - creationDate := time.Now().UTC() + testStartTime := time.Now().UTC() userID := Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() request.UserId = userID key := "key1" Instance.SetUserMetadata(iamOwnerCTX, userID, key, "value1") Instance.DeleteUserMetadata(iamOwnerCTX, userID, key) request.Keys = []string{key} - return creationDate, time.Now().UTC() + return testStartTime, time.Now().UTC() }, req: &user.DeleteUserMetadataRequest{}, wantErr: true, @@ -491,7 +577,7 @@ func TestServer_DeleteUserMetadata(t *testing.T) { name: "delete, multiple", ctx: iamOwnerCTX, prepare: func(request *user.DeleteUserMetadataRequest) (time.Time, time.Time) { - creationDate := time.Now().UTC() + testStartTime := time.Now().UTC() userID := Instance.CreateUserTypeHuman(iamOwnerCTX, integration.Email()).GetId() request.UserId = userID key1 := "key1" @@ -501,7 +587,7 @@ func TestServer_DeleteUserMetadata(t *testing.T) { key3 := "key3" Instance.SetUserMetadata(iamOwnerCTX, userID, key3, "value1") request.Keys = []string{key1, key2, key3} - return creationDate, time.Time{} + return testStartTime, time.Time{} }, req: &user.DeleteUserMetadataRequest{}, wantDeletionDate: true, @@ -509,9 +595,9 @@ func TestServer_DeleteUserMetadata(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var creationDate, deletionDate time.Time + var testStartTime, deletionDate time.Time if tt.prepare != nil { - creationDate, deletionDate = tt.prepare(tt.req) + testStartTime, deletionDate = tt.prepare(tt.req) } got, err := Instance.Client.UserV2.DeleteUserMetadata(tt.ctx, tt.req) if tt.wantErr { @@ -519,17 +605,17 @@ func TestServer_DeleteUserMetadata(t *testing.T) { return } assert.NoError(t, err) - assertDeleteProjectResponse(t, creationDate, deletionDate, tt.wantDeletionDate, got) + assertDeleteProjectResponse(t, testStartTime, deletionDate, tt.wantDeletionDate, got) }) } } -func assertDeleteProjectResponse(t *testing.T, creationDate, deletionDate time.Time, expectedDeletionDate bool, actualResp *user.DeleteUserMetadataResponse) { +func assertDeleteProjectResponse(t *testing.T, testStartTime, deletionDate time.Time, expectedDeletionDate bool, actualResp *user.DeleteUserMetadataResponse) { if expectedDeletionDate { if !deletionDate.IsZero() { - assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, deletionDate) + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), testStartTime, deletionDate) } else { - assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), creationDate, time.Now().UTC()) + assert.WithinRange(t, actualResp.GetDeletionDate().AsTime(), testStartTime, time.Now().UTC()) } } else { assert.Nil(t, actualResp.DeletionDate) 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 181b42f93..c95b5c0d5 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -21,6 +21,7 @@ import ( "github.com/zitadel/zitadel/pkg/grpc/auth" "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt "github.com/zitadel/zitadel/pkg/grpc/management" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" "github.com/zitadel/zitadel/pkg/grpc/object/v2" user_v1 "github.com/zitadel/zitadel/pkg/grpc/user" "github.com/zitadel/zitadel/pkg/grpc/user/v2" @@ -3585,10 +3586,11 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { }, 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"]) + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + } + assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3622,9 +3624,10 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { }, 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"]) + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3704,10 +3707,12 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { }, 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"]) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, + } + assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3800,14 +3805,19 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { } } -func getMetadataMap(getMetadataResponse *user.ListUserMetadataResponse) map[string][]byte { - metadataByKey := make(map[string][]byte, len(getMetadataResponse.GetMetadata())) - for _, md := range getMetadataResponse.GetMetadata() { +func getMetadataMap(metadata []*metadata.Metadata) map[string][]byte { + metadataByKey := make(map[string][]byte, len(metadata)) + for _, md := range metadata { metadataByKey[md.Key] = md.Value } return metadataByKey } +func assertMetadataEquals(t *testing.T, expected []*metadata.Metadata, actual []*metadata.Metadata) { + assert.Equal(t, len(expected), len(actual)) + assert.Equal(t, getMetadataMap(expected), getMetadataMap(actual)) +} + func TestServer_CreateUser_Permission(t *testing.T) { newOrgOwnerEmail := integration.Email() newOrg := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), newOrgOwnerEmail) diff --git a/internal/command/user_metadata.go b/internal/command/user_metadata.go index ac50f8a46..ebc5b1619 100644 --- a/internal/command/user_metadata.go +++ b/internal/command/user_metadata.go @@ -74,8 +74,22 @@ func (c *Commands) BulkSetUserMetadata(ctx context.Context, userID, resourceOwne } userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel) for _, data := range metadatas { + existingValue, keyExists := setMetadata.metadataList[data.Key] + + // if value is empty, a metadata remove event has to be pushed + if len(data.Value) == 0 { + // Ignore deletion if key does not exist + if !keyExists { + continue + } + + event := user.NewMetadataRemovedEvent(ctx, userAgg, data.Key) + events = append(events, event) + continue + } + // if no change to metadata no event has to be pushed - if existingValue, ok := setMetadata.metadataList[data.Key]; ok && bytes.Equal(existingValue, data.Value) { + if keyExists && bytes.Equal(existingValue, data.Value) { continue } event, err := c.setUserMetadata(ctx, userAgg, data) diff --git a/internal/command/user_metadata_test.go b/internal/command/user_metadata_test.go index 13091d200..e134a5439 100644 --- a/internal/command/user_metadata_test.go +++ b/internal/command/user_metadata_test.go @@ -491,7 +491,7 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { }, }, { - name: "invalid metadata, pre condition error", + name: "empty metadata value, ok", fields: fields{ eventstore: expectEventstore( expectFilter( @@ -523,7 +523,9 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { }, }, res: res{ - err: zerrors.IsErrorInvalidArgument, + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, }, }, { @@ -584,17 +586,34 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { ), ), ), - expectFilter(), + expectFilter( + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key1", + []byte("value1"), + )), + eventFromEventPusher( + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key2", + []byte("value2"), + )), + ), expectPush( - user.NewMetadataSetEvent(context.Background(), + user.NewMetadataRemovedEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - "key", - []byte("value"), + "key1", ), user.NewMetadataSetEvent(context.Background(), &user.NewAggregate("user1", "org1").Aggregate, - "key1", - []byte("value1"), + "key2", + []byte("update"), + ), + user.NewMetadataSetEvent(context.Background(), + &user.NewAggregate("user1", "org1").Aggregate, + "key3", + []byte("add"), ), ), ), @@ -604,8 +623,10 @@ func TestCommandSide_BulkSetUserMetadata(t *testing.T) { orgID: "org1", userID: "user1", metadataList: []*domain.Metadata{ - {Key: "key", Value: []byte("value")}, - {Key: "key1", Value: []byte("value1")}, + {Key: "key1"}, // delete + {Key: "key2", Value: []byte("update")}, + {Key: "key3", Value: []byte("add")}, + {Key: "key4"}, // ignore }, }, res: res{ diff --git a/proto/zitadel/user/v2/user_service.proto b/proto/zitadel/user/v2/user_service.proto index fc1085f49..73b1cc82a 100644 --- a/proto/zitadel/user/v2/user_service.proto +++ b/proto/zitadel/user/v2/user_service.proto @@ -1912,7 +1912,7 @@ service UserService { } // Set User Metadata // - // Sets a list of key value pairs. Existing metadata entries with matching keys are overwritten. Existing metadata entries without matching keys are untouched. To remove metadata entries, use [DeleteUserMetadata](/docs/reference/api/user/zitadel.user.v2.UserService.DeleteUserMetadata). For HTTP requests, make sure the bytes array value is base64 encoded. + // Sets a list of key value pairs. Existing metadata entries with matching keys are overwritten. Existing metadata entries without matching keys are untouched. To remove existing metadata entries, pass an empty metadata value or use [DeleteUserMetadata](/docs/reference/api/user/zitadel.user.v2.UserService.DeleteUserMetadata). If no metadata entry exists for a given key, passing an empty value for that key has no effect (no-op). For HTTP requests, make sure the bytes array value is base64 encoded. // // Required permission: // - `user.write` @@ -3736,8 +3736,8 @@ message ListPersonalAccessTokensResponse { message Metadata { // Key in the metadata key/value pair. string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; - // Value in the metadata key/value pair. - bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}]; + // Value in the metadata key/value pair. On updates, an empty value will delete the metadata entry. + bytes value = 2 [(validate.rules).bytes = {max_len: 500000}]; } message SetUserMetadataRequest{