feat: implement UpdateZitadelProvider in ManagementService (#12384)

# Which Problems Are Solved

This PR adds support for updating ZITADEL identity provider (IDP)
templates.

# How the Problems Are Solved

- Implements `UpdateZitadelProvider` in the Management gRPC server plus
converter
- Introduces an org-scoped `ZitadelIDPChanged` event and registers its
event mapper.
- Adds command-side support to update org Zitadel providers and appends
the new event to the org write model.
- Extends the IDP template projection reducer to accept org change
events.

# Additional Changes
N/A

# Additional Context
Closes https://github.com/zitadel/zitadel/issues/11922
Follow up for PRs:

- https://github.com/zitadel/zitadel/pull/12018
- https://github.com/zitadel/zitadel/pull/12020
- https://github.com/zitadel/zitadel/pull/12055
- https://github.com/zitadel/zitadel/pull/12056
- https://github.com/zitadel/zitadel/pull/12371
- https://github.com/zitadel/zitadel/pull/12378

---------

Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Livio Spring <[email protected]>
This commit is contained in:
Gayathri Vijayan
2026-07-07 04:32:18 +00:00
committed by GitHub
co-authored by Copilot Autofix powered by AI Livio Spring
parent bc9333aef9
commit 942607e0e9
11 changed files with 1128 additions and 19 deletions
+10
View File
@@ -469,3 +469,13 @@ func (s *Server) AddZitadelProvider(ctx context.Context, req *mgmt_pb.AddZitadel
Details: object_pb.DomainToAddDetailsPb(details),
}, nil
}
func (s *Server) UpdateZitadelProvider(ctx context.Context, req *mgmt_pb.UpdateZitadelProviderRequest) (*mgmt_pb.UpdateZitadelProviderResponse, error) {
details, err := s.command.UpdateOrgZitadelProvider(ctx, req.Id, authz.GetCtxData(ctx).OrgID, updateZitadelProviderToCommand(req))
if err != nil {
return nil, err
}
return &mgmt_pb.UpdateZitadelProviderResponse{
Details: object_pb.DomainToChangeDetailsPb(details),
}, nil
}
+27 -9
View File
@@ -543,14 +543,6 @@ func signatureAlgorithmToCommand(signatureAlgorithm idp_pb.SAMLSignatureAlgorith
}
func addZitadelProviderToCommand(req *mgmt_pb.AddZitadelProviderRequest) command.ZitadelProvider {
instanceRolesInfo := make([]idp.RolesInfo, 0, len(req.InstanceRolesInfo))
for _, info := range req.InstanceRolesInfo {
instanceRolesInfo = append(instanceRolesInfo, idp.RolesInfo{
OrganizationID: info.OrganizationId,
OrganizationDomain: info.OrganizationDomain,
})
}
return command.ZitadelProvider{
Name: req.Name,
Issuer: req.Issuer,
@@ -558,6 +550,32 @@ func addZitadelProviderToCommand(req *mgmt_pb.AddZitadelProviderRequest) command
ClientSecret: req.ClientSecret,
Scopes: req.Scopes,
IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
InstanceRolesInfo: instanceRolesInfo,
InstanceRolesInfo: instanceRolesInfoToCommand(req.InstanceRolesInfo),
}
}
func updateZitadelProviderToCommand(req *mgmt_pb.UpdateZitadelProviderRequest) command.ZitadelProvider {
return command.ZitadelProvider{
Name: req.Name,
Issuer: req.Issuer,
ClientID: req.ClientId,
ClientSecret: req.ClientSecret,
Scopes: req.Scopes,
IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions),
InstanceRolesInfo: instanceRolesInfoToCommand(req.InstanceRolesInfo),
}
}
func instanceRolesInfoToCommand(reqInstanceRolesInfo []*idp_pb.InstanceRolesInfo) []idp.RolesInfo {
if reqInstanceRolesInfo == nil {
return nil
}
instanceRolesInfo := make([]idp.RolesInfo, 0, len(reqInstanceRolesInfo))
for _, info := range reqInstanceRolesInfo {
instanceRolesInfo = append(instanceRolesInfo, idp.RolesInfo{
OrganizationID: info.OrganizationId,
OrganizationDomain: info.OrganizationDomain,
})
}
return instanceRolesInfo
}
@@ -233,7 +233,6 @@ func Test_addZitadelProviderToCommand(t *testing.T) {
IsLinkingAllowed: false,
IsAutoUpdate: true,
},
InstanceRolesInfo: []idp.RolesInfo{},
},
},
{
@@ -294,3 +293,98 @@ func Test_addZitadelProviderToCommand(t *testing.T) {
})
}
}
func Test_updateZitadelProviderToCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req *mgmt_pb.UpdateZitadelProviderRequest
want command.ZitadelProvider
}{
{
name: "without instance roles info",
req: &mgmt_pb.UpdateZitadelProviderRequest{
Name: "Zitadel Support IdP",
ClientId: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile", "urn:zitadel:iam:org:project:roles"},
ProviderOptions: &idp_pb.Options{
IsLinkingAllowed: false,
IsCreationAllowed: true,
IsAutoCreation: false,
IsAutoUpdate: true,
AutoLinking: 0,
},
},
want: command.ZitadelProvider{
Name: "Zitadel Support IdP",
ClientID: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile", "urn:zitadel:iam:org:project:roles"},
IDPOptions: idp.Options{
IsCreationAllowed: true,
IsAutoCreation: false,
IsLinkingAllowed: false,
IsAutoUpdate: true,
},
InstanceRolesInfo: nil,
},
},
{
name: "all fields filled",
req: &mgmt_pb.UpdateZitadelProviderRequest{
Name: "Zitadel Support IdP",
ClientId: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile", "urn:zitadel:iam:org:project:roles"},
ProviderOptions: &idp_pb.Options{
IsLinkingAllowed: false,
IsCreationAllowed: true,
IsAutoCreation: false,
IsAutoUpdate: true,
AutoLinking: 0,
},
InstanceRolesInfo: []*idp_pb.InstanceRolesInfo{
{
OrganizationId: "org1",
OrganizationDomain: "org1.com",
},
{
OrganizationId: "org2",
OrganizationDomain: "org2.com",
},
},
},
want: command.ZitadelProvider{
Name: "Zitadel Support IdP",
ClientID: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile", "urn:zitadel:iam:org:project:roles"},
IDPOptions: idp.Options{
IsCreationAllowed: true,
IsAutoCreation: false,
IsLinkingAllowed: false,
IsAutoUpdate: true,
},
InstanceRolesInfo: []idp.RolesInfo{
{
OrganizationID: "org1",
OrganizationDomain: "org1.com",
},
{
OrganizationID: "org2",
OrganizationDomain: "org2.com",
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := updateZitadelProviderToCommand(tt.req)
assert.Equal(t, tt.want, got)
})
}
}
@@ -231,3 +231,186 @@ func Test_AddZitadelProvider(t *testing.T) {
})
}
}
func Test_UpdateZitadelProvider(t *testing.T) {
existingProvider, err := Instance.Client.Mgmt.AddZitadelProvider(OrgCTX, &mgmt_pb.AddZitadelProviderRequest{
Name: "Zitadel Support IdP",
Issuer: "zitadel.example.com",
ClientId: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile"},
ProviderOptions: &idp_pb.Options{
IsCreationAllowed: true,
},
InstanceRolesInfo: []*idp_pb.InstanceRolesInfo{
{
OrganizationId: "org1",
OrganizationDomain: "org1.com",
},
},
})
require.NoError(t, err)
require.NotEmpty(t, existingProvider.GetId())
type args struct {
ctx context.Context
req *mgmt_pb.UpdateZitadelProviderRequest
}
tests := []struct {
name string
args args
wantErr error
wantResponse *mgmt_pb.UpdateZitadelProviderResponse
}{
{
name: "no permissions, error",
args: args{
ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission),
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Issuer: "zitadel.example.com",
ClientId: "test-client",
ClientSecret: "test-secret",
Scopes: []string{"email", "profile"},
ProviderOptions: &idp_pb.Options{
IsCreationAllowed: true,
},
},
},
wantErr: status.Error(codes.NotFound, "membership not found (AUTHZ-cdgFk)"),
},
{
name: "insufficient permissions, error",
args: args{
ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeLogin),
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Issuer: "acme.example.com",
Scopes: []string{"email", "profile"},
ProviderOptions: &idp_pb.Options{
IsCreationAllowed: true,
IsAutoCreation: false,
},
},
},
wantErr: status.Error(codes.PermissionDenied, "No matching permissions found (AUTH-5mWD2)"),
},
{
name: "missing required field: id",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{},
},
wantErr: status.Error(codes.InvalidArgument, "invalid UpdateZitadelProviderRequest.Id: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: name",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
},
},
wantErr: status.Error(codes.InvalidArgument, "invalid UpdateZitadelProviderRequest.Name: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: issuer",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Name: "Zitadel Support IdP updated",
},
},
wantErr: status.Error(codes.InvalidArgument, "invalid UpdateZitadelProviderRequest.Issuer: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: client_id",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Name: "Zitadel Support IdP updated",
Issuer: "acme.example.com",
},
},
wantErr: status.Error(codes.InvalidArgument, "invalid UpdateZitadelProviderRequest.ClientId: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "update, ok",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Name: "Zitadel Support IdP updated",
Issuer: "acme.example.com",
ClientId: "test-client",
Scopes: []string{"email", "profile", "openid", "offline_access"},
ProviderOptions: &idp_pb.Options{
IsCreationAllowed: true,
IsAutoCreation: false,
},
InstanceRolesInfo: []*idp_pb.InstanceRolesInfo{
{
OrganizationId: "org1",
OrganizationDomain: "org1.com",
},
{
OrganizationId: "org2",
OrganizationDomain: "org2.com",
},
},
},
},
wantResponse: &mgmt_pb.UpdateZitadelProviderResponse{
Details: &object_pb.ObjectDetails{
ResourceOwner: Instance.DefaultOrg.Id,
},
},
},
{
name: "update with scopes unset, ok",
args: args{
ctx: OrgCTX,
req: &mgmt_pb.UpdateZitadelProviderRequest{
Id: existingProvider.GetId(),
Name: "Zitadel Support IdP updated again",
Issuer: "acme.example.com",
ClientId: "test-client",
Scopes: []string{},
ProviderOptions: &idp_pb.Options{
IsCreationAllowed: true,
IsAutoCreation: false,
},
InstanceRolesInfo: nil,
},
},
wantResponse: &mgmt_pb.UpdateZitadelProviderResponse{
Details: &object_pb.ObjectDetails{
ResourceOwner: Instance.DefaultOrg.Id,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
before := time.Now()
got, err := Client.UpdateZitadelProvider(tt.args.ctx, tt.args.req)
after := time.Now()
if tt.wantErr != nil {
require.Error(t, err)
grpcStatus, ok := status.FromError(err)
require.True(t, ok)
assert.Equal(t, status.Code(tt.wantErr), grpcStatus.Code())
assert.Equal(t, status.Convert(tt.wantErr).Message(), grpcStatus.Message())
return
}
require.NoError(t, err)
assert.NotNil(t, got)
assert.WithinRange(t, got.GetDetails().GetChangeDate().AsTime(), before, after)
assert.Equal(t, tt.wantResponse.GetDetails().GetResourceOwner(), got.GetDetails().GetResourceOwner())
// todo: check if the update was actually applied by fetching the provider and checking its fields when the functionality is implemented
})
}
}