feat: command layer implementation for AddZitadelProvider (#12020)

# Which Problems Are Solved

This PR contains the command-layer implementation to add an instance IDP
of the type `ZitadelProvider`

# How the Problems Are Solved
- Implementing `AddZitadelProvider` in AdminService 
- Adding the command-layer to create a `ZitadelProvider` and push
`ZitadelIDPAddedEvent`

# Additional Changes
N/A

# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/11823
- Follow-up for PR https://github.com/zitadel/zitadel/pull/12018

---------

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Gayathri Vijayan
2026-04-13 15:05:36 +00:00
committed by GitHub
co-authored by Copilot
parent 01fe34a526
commit bb3b52dda3
14 changed files with 906 additions and 23 deletions
+11
View File
@@ -466,3 +466,14 @@ func (s *Server) DeleteProvider(ctx context.Context, req *admin_pb.DeleteProvide
Details: object_pb.DomainToChangeDetailsPb(details),
}, nil
}
func (s *Server) AddZitadelProvider(ctx context.Context, req *admin_pb.AddZitadelProviderRequest) (*admin_pb.AddZitadelProviderResponse, error) {
id, details, err := s.command.AddInstanceZitadelProvider(ctx, addZitadelProviderToCommand(req))
if err != nil {
return nil, err
}
return &admin_pb.AddZitadelProviderResponse{
Id: id,
Details: object_pb.DomainToAddDetailsPb(details),
}, nil
}
+22
View File
@@ -1,6 +1,8 @@
package admin
import (
"strings"
"github.com/crewjam/saml"
"github.com/muhlemmer/gu"
dsig "github.com/russellhaering/goxmldsig"
@@ -11,6 +13,7 @@ import (
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore/v1/models"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/internal/zerrors"
admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp"
@@ -545,3 +548,22 @@ func signatureAlgorithmToCommand(signatureAlgorithm idp_pb.SAMLSignatureAlgorith
return ""
}
}
func addZitadelProviderToCommand(req *admin_pb.AddZitadelProviderRequest) command.ZitadelProvider {
instanceRolesInfo := make([]idp.RolesInfo, 0, len(req.InstanceRolesInfo))
for _, info := range req.InstanceRolesInfo {
instanceRolesInfo = append(instanceRolesInfo, idp.RolesInfo{
OrganizationID: strings.TrimSpace(info.OrganizationId),
OrganizationDomain: strings.TrimSpace(info.OrganizationDomain),
})
}
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: instanceRolesInfo,
}
}
+82 -13
View File
@@ -3,11 +3,14 @@ package admin
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/repository/idp"
"github.com/zitadel/zitadel/internal/test"
admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/idp"
idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp"
)
func Test_addOIDCIDPRequestToDomain(t *testing.T) {
@@ -23,13 +26,13 @@ func Test_addOIDCIDPRequestToDomain(t *testing.T) {
args: args{
req: &admin_pb.AddOIDCIDPRequest{
Name: "ZITADEL",
StylingType: idp.IDPStylingType_STYLING_TYPE_GOOGLE,
StylingType: idp_pb.IDPStylingType_STYLING_TYPE_GOOGLE,
ClientId: "test1234",
ClientSecret: "test4321",
Issuer: "zitadel.ch",
Scopes: []string{"email", "profile"},
DisplayNameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
DisplayNameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
AutoRegister: true,
},
},
@@ -70,8 +73,8 @@ func Test_addOIDCIDPRequestToDomainOIDCIDPConfig(t *testing.T) {
ClientSecret: "test4321",
Issuer: "zitadel.ch",
Scopes: []string{"email", "profile"},
DisplayNameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
DisplayNameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
},
},
},
@@ -104,7 +107,7 @@ func Test_updateIDPToDomain(t *testing.T) {
req: &admin_pb.UpdateIDPRequest{
IdpId: "13523",
Name: "new name",
StylingType: idp.IDPStylingType_STYLING_TYPE_GOOGLE,
StylingType: idp_pb.IDPStylingType_STYLING_TYPE_GOOGLE,
AutoRegister: true,
},
},
@@ -141,8 +144,8 @@ func Test_updateOIDCConfigToDomain(t *testing.T) {
ClientId: "ZITEADEL",
ClientSecret: "i'm so secret",
Scopes: []string{"profile"},
DisplayNameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
DisplayNameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_EMAIL,
UsernameMapping: idp_pb.OIDCMappingField_OIDC_MAPPING_FIELD_PREFERRED_USERNAME,
},
},
},
@@ -164,7 +167,7 @@ func Test_signatureAlgorithmToCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
signatureAlgorithm idp.SAMLSignatureAlgorithm
signatureAlgorithm idp_pb.SAMLSignatureAlgorithm
wantSignatureAlgorithm string
}{
{
@@ -174,17 +177,17 @@ func Test_signatureAlgorithmToCommand(t *testing.T) {
},
{
name: "RSA_SHA1",
signatureAlgorithm: idp.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA1,
signatureAlgorithm: idp_pb.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA1,
wantSignatureAlgorithm: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
},
{
name: "RSA_SHA256",
signatureAlgorithm: idp.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA256,
signatureAlgorithm: idp_pb.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA256,
wantSignatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
},
{
name: "RSA_SHA512",
signatureAlgorithm: idp.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA512,
signatureAlgorithm: idp_pb.SAMLSignatureAlgorithm_SAML_SIGNATURE_RSA_SHA512,
wantSignatureAlgorithm: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
},
}
@@ -196,3 +199,69 @@ func Test_signatureAlgorithmToCommand(t *testing.T) {
})
}
}
func Test_addZitadelProviderToCommand(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req *admin_pb.AddZitadelProviderRequest
want command.ZitadelProvider
}{
{
name: "all fields filled",
req: &admin_pb.AddZitadelProviderRequest{
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 := addZitadelProviderToCommand(tt.req)
assert.Equal(t, tt.want, got)
})
}
}
@@ -0,0 +1,198 @@
//go:build integration
package admin_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
admin_pb "github.com/zitadel/zitadel/pkg/grpc/admin"
idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp"
object_pb "github.com/zitadel/zitadel/pkg/grpc/object"
)
func Test_AddZitadelProvider(t *testing.T) {
type args struct {
ctx context.Context
req *admin_pb.AddZitadelProviderRequest
}
tests := []struct {
name string
args args
wantErr error
wantResponse *admin_pb.AddZitadelProviderResponse
}{
{
name: "missing required field: name",
args: args{
ctx: AdminCTX,
req: &admin_pb.AddZitadelProviderRequest{},
},
wantErr: status.Error(codes.InvalidArgument, "invalid AddZitadelProviderRequest.Name: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: issuer",
args: args{
ctx: AdminCTX,
req: &admin_pb.AddZitadelProviderRequest{
Name: "Zitadel Support IdP",
},
},
wantErr: status.Error(codes.InvalidArgument, "invalid AddZitadelProviderRequest.Issuer: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: client_id",
args: args{
ctx: AdminCTX,
req: &admin_pb.AddZitadelProviderRequest{
Name: "Zitadel Support IdP",
Issuer: "zitadel.example.com",
},
},
wantErr: status.Error(codes.InvalidArgument, "invalid AddZitadelProviderRequest.ClientId: value length must be between 1 and 200 runes, inclusive"),
},
{
name: "missing required field: client_secret",
args: args{
ctx: AdminCTX,
req: &admin_pb.AddZitadelProviderRequest{
Name: "Zitadel Support IdP",
Issuer: "zitadel.example.com",
ClientId: "test-client",
},
},
wantErr: status.Error(
codes.InvalidArgument,
"invalid AddZitadelProviderRequest.ClientSecret: value length must be between 1 and 1000 runes, inclusive",
),
},
{
name: "missing org ID in instance roles info",
args: args{
ctx: AdminCTX,
req: &admin_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: "",
},
},
},
},
wantErr: status.Error(
codes.InvalidArgument,
"invalid AddZitadelProviderRequest.InstanceRolesInfo[0]: embedded message failed validation | caused by: invalid InstanceRolesInfo.OrganizationId: value length must be between 1 and 200 runes, inclusive",
),
},
{
name: "missing org domain in instance roles info",
args: args{
ctx: AdminCTX,
req: &admin_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",
},
{
OrganizationId: "org2",
},
},
},
},
wantErr: status.Error(
codes.InvalidArgument,
"invalid AddZitadelProviderRequest.InstanceRolesInfo[1]: embedded message failed validation | caused by: invalid InstanceRolesInfo.OrganizationDomain: value length must be between 1 and 200 runes, inclusive",
),
},
{
name: "valid request without instance roles info",
args: args{
ctx: AdminCTX,
req: &admin_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,
},
},
},
wantResponse: &admin_pb.AddZitadelProviderResponse{
Details: &object_pb.ObjectDetails{
ResourceOwner: Instance.Instance.Id,
},
},
},
{
name: "valid request with instance roles info",
args: args{
ctx: AdminCTX,
req: &admin_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",
},
},
},
},
wantResponse: &admin_pb.AddZitadelProviderResponse{
Details: &object_pb.ObjectDetails{
ResourceOwner: Instance.Instance.Id,
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
before := time.Now()
got, err := Client.AddZitadelProvider(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.NotEmpty(t, got.GetId())
assert.WithinRange(t, got.GetDetails().GetCreationDate().AsTime(), before, after)
assert.Equal(t, tt.wantResponse.GetDetails().GetResourceOwner(), got.GetDetails().GetResourceOwner())
})
}
}