From 62cb5bbb83c213d656ed5b71b3231fb310ec475d Mon Sep 17 00:00:00 2001 From: Vitor Bari Buccianti Date: Tue, 10 Mar 2026 11:09:51 +0100 Subject: [PATCH] feat: delete metadata on SetOrganizationMetadata api (#11790) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Which Problems Are Solved Currently, to delete an Organization metadata key, callers must use a separate [DeleteMetadata](https://zitadel.com/docs/reference/api/org/zitadel.org.v2.OrganizationService.DeleteOrganizationMetadata) API. This increases the complexity on client-side for operations where metadata need to be synchronized. # How the Problems Are Solved Introduce deletion behavior to [SetOrganizationMetadata](https://zitadel.com/docs/reference/api/org/zitadel.org.v2.OrganizationService.SetOrganizationMetadata): 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 n/a # Additional Context Relates to #11776 --------- Co-authored-by: Gayathri Vijayan <66356931+grvijayan@users.noreply.github.com> Co-authored-by: Gayathri Vijayan --- .../grpc/org/v2/integration_test/org_test.go | 202 +++++++++++------- .../user/v2/integration_test/metadata_test.go | 12 +- .../user/v2/integration_test/user_test.go | 27 +-- internal/command/org_metadata.go | 36 +++- internal/command/org_metadata_test.go | 97 ++++++--- internal/command/user_metadata.go | 1 + internal/integration/assert.go | 15 ++ internal/integration/client.go | 13 ++ proto/zitadel/org/v2/org_service.proto | 6 +- 9 files changed, 267 insertions(+), 142 deletions(-) diff --git a/internal/api/grpc/org/v2/integration_test/org_test.go b/internal/api/grpc/org/v2/integration_test/org_test.go index 9298e63b3..7ad3b57d7 100644 --- a/internal/api/grpc/org/v2/integration_test/org_test.go +++ b/internal/api/grpc/org/v2/integration_test/org_test.go @@ -4,6 +4,7 @@ package org_test import ( "context" + "encoding/base64" "errors" "os" "slices" @@ -17,6 +18,7 @@ import ( "github.com/zitadel/zitadel/internal/integration" "github.com/zitadel/zitadel/pkg/grpc/admin" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" "github.com/zitadel/zitadel/pkg/grpc/org/v2" "github.com/zitadel/zitadel/pkg/grpc/user/v2" ) @@ -280,7 +282,7 @@ func TestServer_UpdateOrganization(t *testing.T) { name: "update org with non existent org id", ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), req: &org.UpdateOrganizationRequest{ - OrganizationId: "non existant org id", + OrganizationId: "non existent org id", // Name: "", }, wantErr: true, @@ -1029,125 +1031,159 @@ func TestServer_ValidateOrganizationDomain(t *testing.T) { } func TestServer_SetOrganizationMetadata(t *testing.T) { - orgs, _, _ := createOrgs(CTX, t, Client, 1) - orgId := orgs[0].OrganizationId - tests := []struct { name string ctx context.Context - setupFunc func() - orgId string - key string - value string + setupFunc func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) + req *org.SetOrganizationMetadataRequest + assert func(ttt *assert.CollectT, setMetadataResponse *org.SetOrganizationMetadataResponse, testStartTime time.Time, listMetadataResponse *org.ListOrganizationMetadataResponse) err error }{ { - name: "no permission", - ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), - orgId: orgId, - key: "key1", - value: "value1", - err: errors.New("membership not found"), + name: "no permission", + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeOrgOwner), + setupFunc: func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = orgID + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + }, + }, + err: errors.New("membership not found"), }, { - name: "set org metadata", - ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), - orgId: orgId, - key: "key1", - value: "value1", + name: "set org metadata", + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), + setupFunc: func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = orgID + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + }, + }, + assert: func(ttt *assert.CollectT, setMetadataResponse *org.SetOrganizationMetadataResponse, testStartTime time.Time, listMetadataResponse *org.ListOrganizationMetadataResponse) { + assert.WithinRange(ttt, setMetadataResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + integration.AssertMetadataEquals(ttt, expectedMetadata, listMetadataResponse.GetMetadata()) + }, }, { - name: "set org metadata on non existant org", - ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), - orgId: "non existant orgid", - key: "key2", - value: "value2", - err: errors.New("Organisation not found"), + name: "set org metadata on non existent org", + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), + setupFunc: func(ctx context.Context, _ string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = "wrong-org-id" + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated-value1")))}, + }, + }, + err: errors.New("Organisation not found"), }, { name: "update org metadata", ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), - setupFunc: func() { - _, err := Client.SetOrganizationMetadata(CTX, &org.SetOrganizationMetadataRequest{ - OrganizationId: orgId, - Metadata: []*org.Metadata{ - { - Key: "key3", - Value: []byte("value3"), - }, - }, - }) - require.NoError(t, err) + setupFunc: func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = orgID + Instance.SetOrganizationMetadata(ctx, orgID, "key1", "value1") + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated-value1")))}, + }, + }, + assert: func(ttt *assert.CollectT, setMetadataResponse *org.SetOrganizationMetadataResponse, testStartTime time.Time, listMetadataResponse *org.ListOrganizationMetadataResponse) { + assert.WithinRange(ttt, setMetadataResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated-value1")))}, + } + integration.AssertMetadataEquals(ttt, expectedMetadata, listMetadataResponse.GetMetadata()) }, - orgId: orgId, - key: "key4", - value: "value4", }, { name: "update org metadata with same value", ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), - setupFunc: func() { - _, err := Client.SetOrganizationMetadata(CTX, &org.SetOrganizationMetadataRequest{ - OrganizationId: orgId, - Metadata: []*org.Metadata{ - { - Key: "key5", - Value: []byte("value5"), - }, - }, - }) - require.NoError(t, err) + setupFunc: func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = orgID + Instance.SetOrganizationMetadata(ctx, orgID, "key1", "value1") + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + }, + }, + assert: func(ttt *assert.CollectT, setMetadataResponse *org.SetOrganizationMetadataResponse, testStartTime time.Time, listMetadataResponse *org.ListOrganizationMetadataResponse) { + assert.True(ttt, setMetadataResponse.GetSetDate().AsTime().Before(testStartTime)) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, + } + integration.AssertMetadataEquals(ttt, expectedMetadata, listMetadataResponse.GetMetadata()) + }, + }, + { + name: "delete and update org metadata", + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), + setupFunc: func(ctx context.Context, orgID string, req *org.SetOrganizationMetadataRequest) { + req.OrganizationId = orgID + Instance.SetOrganizationMetadata(ctx, orgID, "key1", "value1") + Instance.SetOrganizationMetadata(ctx, orgID, "key2", "value2") + }, + req: &org.SetOrganizationMetadataRequest{ + Metadata: []*org.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated-value1")))}, // updating + {Key: "key2"}, // deleting + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, // adding + {Key: "key4"}, // deleting non-existent + }, + }, + assert: func(ttt *assert.CollectT, setMetadataResponse *org.SetOrganizationMetadataResponse, testStartTime time.Time, listMetadataResponse *org.ListOrganizationMetadataResponse) { + assert.WithinRange(ttt, setMetadataResponse.GetSetDate().AsTime(), testStartTime, time.Now().UTC()) + + expectedMetadata := []*metadata.Metadata{ + {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated-value1")))}, + {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, + } + integration.AssertMetadataEquals(ttt, expectedMetadata, listMetadataResponse.GetMetadata()) }, - orgId: orgId, - key: "key5", - value: "value5", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + orgResponse := Instance.CreateOrganization( + CTX, + integration.OrganizationName(), + integration.Email(), + ) + if tt.setupFunc != nil { - tt.setupFunc() + tt.setupFunc(tt.ctx, orgResponse.OrganizationId, tt.req) } - got, err := Client.SetOrganizationMetadata(tt.ctx, &org.SetOrganizationMetadataRequest{ - OrganizationId: tt.orgId, - Metadata: []*org.Metadata{ - { - Key: tt.key, - Value: []byte(tt.value), - }, - }, - }) + + testStartTime := time.Now().UTC() + got, err := Client.SetOrganizationMetadata(tt.ctx, tt.req) if tt.err != nil { require.Contains(t, err.Error(), tt.err.Error()) return } require.NoError(t, err) - // check details - gotCD := got.GetSetDate().AsTime() - now := time.Now() - assert.WithinRange(t, gotCD, now.Add(-time.Minute), now.Add(time.Minute)) - retryDuration, tick := integration.WaitForAndTickWithMaxDuration(CTX, 10*time.Minute) require.EventuallyWithT(t, func(ttt *assert.CollectT) { // check metadata listMetadataRes, err := Client.ListOrganizationMetadata(tt.ctx, &org.ListOrganizationMetadataRequest{ - OrganizationId: orgId, + OrganizationId: orgResponse.OrganizationId, }) require.NoError(ttt, err) - foundMetadata := false - foundMetadataKeyCount := 0 - for _, res := range listMetadataRes.Metadata { - if res.Key == tt.key { - foundMetadataKeyCount += 1 - } - if res.Key == tt.key && - string(res.Value) == tt.value { - foundMetadata = true - } - } - require.True(ttt, foundMetadata, "unable to find added metadata") - require.Equal(ttt, 1, foundMetadataKeyCount, "same metadata key found multiple times") + tt.assert(ttt, got, testStartTime, listMetadataRes) }, retryDuration, tick, "timeout waiting for expected organizations being created") }) } @@ -1242,7 +1278,7 @@ func TestServer_DeleteOrganizationMetadata(t *testing.T) { { name: "delete org metadata for org that does not exist", ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner), - orgId: "non existant org id", + orgId: "non existent org id", metadataToDelete: []struct{ key, value string }{ { key: "key4", 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 cc8be1a63..95d82ff95 100644 --- a/internal/api/grpc/user/v2/integration_test/metadata_test.go +++ b/internal/api/grpc/user/v2/integration_test/metadata_test.go @@ -54,7 +54,7 @@ func TestServer_SetUserMetadata(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, { @@ -78,7 +78,7 @@ func TestServer_SetUserMetadata(t *testing.T) { {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, {Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, { @@ -106,7 +106,7 @@ func TestServer_SetUserMetadata(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, { @@ -126,7 +126,7 @@ func TestServer_SetUserMetadata(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, { @@ -164,7 +164,7 @@ func TestServer_SetUserMetadata(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, { @@ -187,7 +187,7 @@ func TestServer_SetUserMetadata(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("updated")))}, } - assertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getResponse.GetMetadata()) }, }, } 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 62467bb2b..c4b566cb4 100644 --- a/internal/api/grpc/user/v2/integration_test/user_test.go +++ b/internal/api/grpc/user/v2/integration_test/user_test.go @@ -3590,7 +3590,7 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, } - assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3627,7 +3627,7 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { expectedMetadata := []*metadata.Metadata{ {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, } - assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3712,7 +3712,7 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { {Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))}, {Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))}, } - assertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, getMetadataResponse.GetMetadata()) }, } }, @@ -3805,19 +3805,6 @@ func TestServer_CreateUser_And_Compare(t *testing.T) { } } -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) @@ -4615,7 +4602,7 @@ func TestServer_UpdateUser_And_Compare(t *testing.T) { {Key: "key2", Value: []byte("value2")}, {Key: "key3", Value: []byte("value3")}, } - assertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) }, } }, @@ -4683,7 +4670,7 @@ func TestServer_UpdateUser_And_Compare(t *testing.T) { {Key: "key1", Value: []byte("updated_value1")}, {Key: "key4", Value: []byte("value4")}, } - assertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) }, } }, @@ -4740,7 +4727,7 @@ func TestServer_UpdateUser_And_Compare(t *testing.T) { {Key: "key2", Value: []byte("value2")}, {Key: "key3", Value: []byte("value3")}, } - assertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) }, } }, @@ -4802,7 +4789,7 @@ func TestServer_UpdateUser_And_Compare(t *testing.T) { {Key: "key1", Value: []byte("updated_value1")}, {Key: "key4", Value: []byte("value4")}, } - assertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) + integration.AssertMetadataEquals(t, expectedMetadata, listMetadataResponse.GetMetadata()) }, } }, diff --git a/internal/command/org_metadata.go b/internal/command/org_metadata.go index 3f9637b76..67a240041 100644 --- a/internal/command/org_metadata.go +++ b/internal/command/org_metadata.go @@ -1,6 +1,7 @@ package command import ( + "bytes" "context" "github.com/zitadel/zitadel/internal/domain" @@ -46,15 +47,42 @@ func (c *Commands) BulkSetOrgMetadata(ctx context.Context, orgID string, permiss return nil, err } - events := make([]eventstore.Command, len(metadatas)) - setMetadata := NewOrgMetadataListWriteModel(orgID) + events := make([]eventstore.Command, 0) + setMetadata, err := c.getOrgMetadataListModelByID(ctx, orgID) + if err != nil { + return nil, err + } orgAgg := OrgAggregateFromWriteModel(&setMetadata.WriteModel) - for i, data := range metadatas { + 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 := org.NewMetadataRemovedEvent(ctx, orgAgg, data.Key) + events = append(events, event) + continue + } + + // if no change to metadata no event has to be pushed + if keyExists && bytes.Equal(existingValue, data.Value) { + continue + } + event, err := c.setOrgMetadata(ctx, orgAgg, data) if err != nil { return nil, err } - events[i] = event + events = append(events, event) + } + + // no changes for the metadata + if len(events) == 0 { + return writeModelToObjectDetails(&setMetadata.WriteModel), nil } pushedEvents, err := c.eventstore.Push(ctx, events...) diff --git a/internal/command/org_metadata_test.go b/internal/command/org_metadata_test.go index c534b5471..369cca332 100644 --- a/internal/command/org_metadata_test.go +++ b/internal/command/org_metadata_test.go @@ -196,32 +196,6 @@ func TestCommandSide_BulkSetOrgMetadata(t *testing.T) { err: zerrors.IsPreconditionFailed, }, }, - { - name: "invalid metadata, pre condition error", - fields: fields{ - eventstore: expectEventstore( - expectFilter( - eventFromEventPusher( - org.NewOrgAddedEvent(context.Background(), - &org.NewAggregate("org1").Aggregate, - "ZITADEL", - ), - ), - ), - ), - }, - args: args{ - ctx: context.Background(), - orgID: "org1", - metadataList: []*domain.Metadata{ - {Key: "key"}, - {Key: "key1"}, - }, - }, - res: res{ - err: zerrors.IsErrorInvalidArgument, - }, - }, { name: "add metadata, ok", fields: fields{ @@ -234,6 +208,7 @@ func TestCommandSide_BulkSetOrgMetadata(t *testing.T) { ), ), ), + expectFilter(), expectPush( org.NewMetadataSetEvent(context.Background(), &org.NewAggregate("org1").Aggregate, @@ -274,6 +249,7 @@ func TestCommandSide_BulkSetOrgMetadata(t *testing.T) { ), ), ), + expectFilter(), expectPush( org.NewMetadataSetEvent(context.Background(), &org.NewAggregate("org1").Aggregate, @@ -303,6 +279,75 @@ func TestCommandSide_BulkSetOrgMetadata(t *testing.T) { }, }, }, + { + name: "update and delete metadata, ok", + fields: fields{ + eventstore: expectEventstore( + expectFilter( + eventFromEventPusher( + org.NewOrgAddedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "ZITADEL", + ), + ), + ), + expectFilter( + eventFromEventPusher( + org.NewMetadataSetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key", + []byte("value"), + ), + ), + eventFromEventPusher( + org.NewMetadataSetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key1", + []byte("value1"), + ), + ), + eventFromEventPusher( + org.NewMetadataSetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key2", + []byte("value2"), + ), + )), + expectPush( + org.NewMetadataSetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key", + []byte("updated_value"), + ), + org.NewMetadataRemovedEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key1", + ), + org.NewMetadataSetEvent(context.Background(), + &org.NewAggregate("org1").Aggregate, + "key3", + []byte("value3"), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + orgID: "org1", + metadataList: []*domain.Metadata{ + {Key: "key", Value: []byte("updated_value")}, // update an existing key + {Key: "key1"}, // delete an existing key + {Key: "key2", Value: []byte("value2")}, // unchanged value + {Key: "key3", Value: []byte("value3")}, // add a new key + {Key: "key4"}, // delete a non-existing key + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "org1", + }, + }, + }, { name: "add metadata (no permission), error", fields: fields{ diff --git a/internal/command/user_metadata.go b/internal/command/user_metadata.go index c08b52ab2..a2c1e150d 100644 --- a/internal/command/user_metadata.go +++ b/internal/command/user_metadata.go @@ -76,6 +76,7 @@ func (c *Commands) BulkSetUserMetadata(ctx context.Context, userID, resourceOwne if err != nil { return nil, err } + // no changes for the metadata if len(events) == 0 { return writeModelToObjectDetails(&setMetadata.WriteModel), nil diff --git a/internal/integration/assert.go b/internal/integration/assert.go index d74187f36..c58b8520e 100644 --- a/internal/integration/assert.go +++ b/internal/integration/assert.go @@ -12,6 +12,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" + metadata "github.com/zitadel/zitadel/pkg/grpc/metadata/v2" resources_object "github.com/zitadel/zitadel/pkg/grpc/resources/object/v3alpha" ) @@ -176,3 +177,17 @@ func diffProto(expected, actual proto.Message) string { } return "\n\nDiff:\n" + diff } + +// AssertMetadataEquals verifies that two slices of proto Metadata are equal, comparing both length and content by key-value mapping. +func AssertMetadataEquals(t assert.TestingT, expected []*metadata.Metadata, actual []*metadata.Metadata) { + assert.Equal(t, len(expected), len(actual), "metadata length mismatch") + assert.Equal(t, getMetadataMap(expected), getMetadataMap(actual), "metadata content mismatch") +} + +func getMetadataMap(metadataEntries []*metadata.Metadata) map[string][]byte { + metadataByKey := make(map[string][]byte, len(metadataEntries)) + for _, md := range metadataEntries { + metadataByKey[md.Key] = md.Value + } + return metadataByKey +} diff --git a/internal/integration/client.go b/internal/integration/client.go index fed90786a..0debd8bd2 100644 --- a/internal/integration/client.go +++ b/internal/integration/client.go @@ -392,6 +392,19 @@ func (i *Instance) CreateOrganization(ctx context.Context, name, adminEmail stri return resp } +func (i *Instance) SetOrganizationMetadata(ctx context.Context, id, key, value string) *org.SetOrganizationMetadataResponse { + resp, err := i.Client.OrgV2.SetOrganizationMetadata(ctx, &org.SetOrganizationMetadataRequest{ + OrganizationId: id, + Metadata: []*org.Metadata{{ + Key: key, + Value: []byte(base64.StdEncoding.EncodeToString([]byte(value))), + }, + }, + }) + logging.OnError(err).Panic("set organization metadata") + return resp +} + func (i *Instance) DeactivateOrganization(ctx context.Context, orgID string) *mgmt.DeactivateOrgResponse { resp, err := i.Client.Mgmt.DeactivateOrg( SetOrgID(ctx, orgID), diff --git a/proto/zitadel/org/v2/org_service.proto b/proto/zitadel/org/v2/org_service.proto index 5de74d79c..389afa84f 100644 --- a/proto/zitadel/org/v2/org_service.proto +++ b/proto/zitadel/org/v2/org_service.proto @@ -263,7 +263,7 @@ service OrganizationService { // Set Organization Metadata // - // Adds or updates a metadata value for the requested key. Make sure the 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 [DeleteOrganizationMetadata](/docs/reference/api/org/zitadel.org.v2.OrganizationService.DeleteOrganizationMetadata). 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: // - `org.write` @@ -914,8 +914,8 @@ message Metadata { // Key is identifier of the metadata entry. string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; - // Value is the values of the metadata entry. - 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 SetOrganizationMetadataRequest{ // Organization ID is the unique identifier of the organization whose metadata is to be set.