feat: implement AddZitadelProvider in ManagementService (#12056)

# Which Problems Are Solved

This PR adds implementation to add a Zitadel IdP at the
organization-level.

# How the Problems Are Solved
- Added handling/converters for the `AddZitadelProvider` endpoint in
`ManagementService` in the server layer
- Registered a new `org.idp.zitadel.added` event for org-level Zitadel
providers
- Added `AddOrgZitadelProvider` command to validate the request and push
`org.idp.zitadel.added` event to the eventstore
- Added the `org.idp.zitadel.added` event to the projection reducer
- Added unit and integration tests

# Additional Changes
added more tests for the ZitadelProvider in AdminService

# Additional Context
- Closes https://github.com/zitadel/zitadel/issues/11823
- 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
This commit is contained in:
Gayathri Vijayan
2026-04-15 14:22:45 +02:00
committed by GitHub
parent 55a9acf3bf
commit e19bb7a173
16 changed files with 1090 additions and 21 deletions
+2 -2
View File
@@ -1946,7 +1946,7 @@ func (c *Commands) AddInstanceZitadelProvider(ctx context.Context, provider Zita
return "", nil, err
}
err = c.validateZitadelProvider(ctx, &provider)
err = c.validateInstanceZitadelProvider(&provider)
if err != nil {
return "", nil, err
}
@@ -1996,7 +1996,7 @@ func (c *Commands) prepareAddInstanceZitadelProvider(a *instance.Aggregate, writ
}
}
func (c *Commands) validateZitadelProvider(ctx context.Context, provider *ZitadelProvider) error {
func (c *Commands) validateInstanceZitadelProvider(provider *ZitadelProvider) error {
if provider.Name = strings.TrimSpace(provider.Name); provider.Name == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-Sgtj5", "Errors.Invalid.Argument")
}
+40
View File
@@ -6094,6 +6094,46 @@ func TestCommandSide_AddInstanceZitadelIDP(t *testing.T) {
},
},
},
{
name: "ok, without instance roles info",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectPush(
instance.NewZitadelIDPAddedEvent(context.Background(), &instance.NewAggregate("instance1").Aggregate,
"id1",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
nil,
idp.Options{},
nil,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
ClientSecret: "clientSecret",
},
},
res: res{
id: "id1",
want: &domain.ObjectDetails{ResourceOwner: "instance1"},
},
},
{
name: "ok",
fields: fields{
+84
View File
@@ -1910,3 +1910,87 @@ func (c *Commands) prepareDeleteOrgProvider(a *org.Aggregate, resourceOwner, id
}, nil
}
}
func (c *Commands) AddOrgZitadelProvider(ctx context.Context, resourceOwner string, provider ZitadelProvider) (string, *domain.ObjectDetails, error) {
id, err := c.idGenerator.Next()
if err != nil {
return "", nil, err
}
err = c.validateOrgZitadelProvider(&provider)
if err != nil {
return "", nil, err
}
orgAgg := org.NewAggregate(resourceOwner)
writeModel := NewZitadelOrgIDPWriteModel(resourceOwner, id)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareAddOrgZitadelProvider(orgAgg, writeModel, provider)) //nolint:staticcheck
if err != nil {
return "", nil, err
}
pushedEvents, err := c.eventstore.Push(ctx, cmds...)
if err != nil {
return "", nil, err
}
return id, pushedEventsToObjectDetails(pushedEvents), nil
}
func (c *Commands) prepareAddOrgZitadelProvider(a *org.Aggregate, writeModel *OrgZitadelIDPWriteModel, provider ZitadelProvider) preparation.Validation {
return func() (preparation.CreateCommands, error) {
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
events, err := filter(ctx, writeModel.Query())
if err != nil {
return nil, err
}
writeModel.AppendEvents(events...)
if err = writeModel.Reduce(); err != nil {
return nil, err
}
secret, err := crypto.Encrypt([]byte(provider.ClientSecret), c.idpConfigEncryption)
if err != nil {
return nil, err
}
return []eventstore.Command{
org.NewZitadelIDPAddedEvent(
ctx,
&a.Aggregate,
writeModel.ID,
provider.Name,
provider.Issuer,
provider.ClientID,
secret,
provider.Scopes,
provider.IDPOptions,
provider.InstanceRolesInfo,
),
}, nil
}, nil
}
}
func (c *Commands) validateOrgZitadelProvider(provider *ZitadelProvider) error {
if provider.Name = strings.TrimSpace(provider.Name); provider.Name == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-c3EC3W", "Errors.Invalid.Argument")
}
if provider.Issuer = strings.TrimSpace(provider.Issuer); provider.Issuer == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-TYAbiK", "Errors.Invalid.Argument")
}
if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-feyb3A", "Errors.Invalid.Argument")
}
if provider.ClientSecret = strings.TrimSpace(provider.ClientSecret); provider.ClientSecret == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-DxaSb9", "Errors.Invalid.Argument")
}
for i := range provider.InstanceRolesInfo {
provider.InstanceRolesInfo[i].OrganizationID = strings.TrimSpace(provider.InstanceRolesInfo[i].OrganizationID)
if provider.InstanceRolesInfo[i].OrganizationID == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-M0QmjB", "Errors.Invalid.Argument")
}
provider.InstanceRolesInfo[i].OrganizationDomain = strings.TrimSpace(provider.InstanceRolesInfo[i].OrganizationDomain)
if provider.InstanceRolesInfo[i].OrganizationDomain == "" {
return zerrors.ThrowInvalidArgument(nil, "ORG-HAELEz", "Errors.Invalid.Argument")
}
}
return nil
}
+40
View File
@@ -1046,3 +1046,43 @@ func (wm *OrgIDPRemoveWriteModel) Query() *eventstore.SearchQueryBuilder {
EventData(map[string]interface{}{"idpConfigId": wm.ID}).
Builder()
}
type OrgZitadelIDPWriteModel struct {
ZitadelIDPWriteModel
}
func NewZitadelOrgIDPWriteModel(orgID, id string) *OrgZitadelIDPWriteModel {
return &OrgZitadelIDPWriteModel{
ZitadelIDPWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: orgID,
ResourceOwner: orgID,
},
ID: id,
},
}
}
func (wm *OrgZitadelIDPWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *org.ZitadelIDPAddedEvent:
wm.ZitadelIDPWriteModel.AppendEvents(&e.ZitadelIDPAddedEvent)
default:
wm.ZitadelIDPWriteModel.AppendEvents(e)
}
}
}
func (wm *OrgZitadelIDPWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(org.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
org.ZitadelIDPAddedEventType,
).
EventData(map[string]interface{}{"id": wm.ID}).
Builder()
}
+295
View File
@@ -6026,3 +6026,298 @@ func TestCommandSide_RegenerateOrgSAMLProviderCertificate(t *testing.T) {
})
}
}
func TestCommandSide_AddOrgZitadelIDP(t *testing.T) {
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
idGenerator id.Generator
secretCrypto crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
resourceOwner string
provider ZitadelProvider
}
type res struct {
id string
want *domain.ObjectDetails
err func(error) bool
}
tests := []struct {
name string
fields fields
args args
res res
}{
{
"invalid name",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-c3EC3W", "Errors.Invalid.Argument"))
},
},
},
{
"invalid issuer",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-TYAbiK", "Errors.Invalid.Argument"))
},
},
},
{
"invalid clientID",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-feyb3A", "Errors.Invalid.Argument"))
},
},
},
{
"invalid clientSecret",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "ORG-DxaSb9", "Errors.Invalid.Argument"))
},
},
},
{
name: "ok, without instance roles info",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectPush(
org.NewZitadelIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate,
"id1",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
nil,
idp.Options{},
nil,
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
ClientSecret: "clientSecret",
},
},
res: res{
id: "id1",
want: &domain.ObjectDetails{ResourceOwner: "org1"},
},
},
{
name: "ok",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectPush(
org.NewZitadelIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate,
"id1",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
nil,
idp.Options{},
[]idp.RolesInfo{
{
OrganizationID: "org1",
OrganizationDomain: "example-org1.com",
},
{
OrganizationID: "org2",
OrganizationDomain: "example-org2.com",
},
},
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
ClientSecret: "clientSecret",
InstanceRolesInfo: []idp.RolesInfo{
{
OrganizationID: "org1",
OrganizationDomain: "example-org1.com",
},
{
OrganizationID: "org2",
OrganizationDomain: "example-org2.com",
},
},
},
},
res: res{
id: "id1",
want: &domain.ObjectDetails{ResourceOwner: "org1"},
},
},
{
name: "ok all set",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectPush(
org.NewZitadelIDPAddedEvent(context.Background(), &org.NewAggregate("org1").Aggregate,
"id1",
"name",
"issuer",
"clientID",
&crypto.CryptoValue{
CryptoType: crypto.TypeEncryption,
Algorithm: "enc",
KeyID: "id",
Crypted: []byte("clientSecret"),
},
[]string{openid.ScopeOpenID},
idp.Options{
IsCreationAllowed: true,
IsLinkingAllowed: true,
IsAutoCreation: true,
IsAutoUpdate: true,
},
[]idp.RolesInfo{
{
OrganizationID: "org1",
OrganizationDomain: "example-org1.com",
},
{
OrganizationID: "org2",
OrganizationDomain: "example-org2.com",
},
},
),
),
),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
secretCrypto: crypto.CreateMockEncryptionAlg(gomock.NewController(t)),
},
args: args{
ctx: context.Background(),
resourceOwner: "org1",
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
ClientSecret: "clientSecret",
Scopes: []string{openid.ScopeOpenID},
IDPOptions: idp.Options{
IsCreationAllowed: true,
IsLinkingAllowed: true,
IsAutoCreation: true,
IsAutoUpdate: true,
},
InstanceRolesInfo: []idp.RolesInfo{
{
OrganizationID: "org1",
OrganizationDomain: "example-org1.com",
},
{
OrganizationID: "org2",
OrganizationDomain: "example-org2.com",
},
},
},
},
res: res{
id: "id1",
want: &domain.ObjectDetails{ResourceOwner: "org1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Commands{
eventstore: tt.fields.eventstore(t),
idGenerator: tt.fields.idGenerator,
idpConfigEncryption: tt.fields.secretCrypto,
}
id, got, err := c.AddOrgZitadelProvider(tt.args.ctx, tt.args.resourceOwner, tt.args.provider)
if tt.res.err == nil {
assert.NoError(t, err)
}
if tt.res.err != nil && !tt.res.err(err) {
t.Errorf("got wrong err: %v ", err)
}
if tt.res.err == nil {
assert.Equal(t, tt.res.id, id)
assertObjectDetails(t, tt.res.want, got)
}
})
}
}