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
+10
View File
@@ -137,6 +137,16 @@ type AppleProvider struct {
IDPOptions idp.Options
}
type ZitadelProvider struct {
Name string
Issuer string
ClientID string
ClientSecret string
Scopes []string
IDPOptions idp.Options
InstanceRolesInfo []idp.RolesInfo
}
// ExistsIDPOnOrgOrInstance query first org level IDPs and then instance level IDPs, no check if the IDP is active
func ExistsIDPOnOrgOrInstance(ctx context.Context, filter preparation.FilterToQueryReducer, instanceID, orgID, id string) (exists bool, err error) {
ctx, span := tracing.NewSpan(ctx)
+43
View File
@@ -2322,3 +2322,46 @@ func (wm *AllIDPWriteModel) ToSAMLProvider(callbackURL string, idpAlg crypto.Enc
}
return wm.samlModel.ToProvider(callbackURL, idpAlg, getRequest, addRequest)
}
type ZitadelIDPWriteModel struct {
eventstore.WriteModel
Name string
ID string
Issuer string
ClientID string
ClientSecret *crypto.CryptoValue
Scopes []string
idp.Options
InstanceRolesInfo []idp.RolesInfo
State domain.IDPState
}
func (wm *ZitadelIDPWriteModel) Reduce() error {
for _, event := range wm.Events {
switch e := event.(type) {
case *idp.ZitadelIDPAddedEvent:
wm.reduceAddedEvent(e)
case *idp.ZitadelIDPChangedEvent:
// todo (@grvijayan): to please the linter for now
wm.reduceChangedEvent(e)
}
}
return wm.WriteModel.Reduce()
}
func (wm *ZitadelIDPWriteModel) reduceAddedEvent(e *idp.ZitadelIDPAddedEvent) {
wm.Name = e.Name
wm.Issuer = e.Issuer
wm.ClientID = e.ClientID
wm.ClientSecret = e.ClientSecret
wm.Scopes = e.Scopes
wm.Options = e.Options
wm.InstanceRolesInfo = e.InstanceRolesInfo
wm.State = domain.IDPStateActive
}
func (wm *ZitadelIDPWriteModel) reduceChangedEvent(e *idp.ZitadelIDPChangedEvent) {
// todo (@grvijayan): will be implemented along with UpdateZitadelProvider changes
}
+86
View File
@@ -1937,3 +1937,89 @@ func (c *Commands) prepareDeleteInstanceProvider(a *instance.Aggregate, id strin
}, nil
}
}
func (c *Commands) AddInstanceZitadelProvider(ctx context.Context, provider ZitadelProvider) (string, *domain.ObjectDetails, error) {
instanceID := authz.GetInstance(ctx).InstanceID()
instanceAgg := instance.NewAggregate(instanceID)
id, err := c.idGenerator.Next()
if err != nil {
return "", nil, err
}
err = c.validateZitadelProvider(ctx, &provider)
if err != nil {
return "", nil, err
}
writeModel := NewInstanceZitadelIDPWriteModel(instanceID, id)
cmds, err := preparation.PrepareCommands(ctx, c.eventstore.Filter, c.prepareAddInstanceZitadelProvider(instanceAgg, 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) prepareAddInstanceZitadelProvider(a *instance.Aggregate, writeModel *InstanceZitadelIDPWriteModel, 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{
instance.NewZitadelIDPAddedEvent(
ctx,
&a.Aggregate,
writeModel.ID,
provider.Name,
provider.Issuer,
provider.ClientID,
secret,
provider.Scopes,
provider.IDPOptions,
provider.InstanceRolesInfo,
),
}, nil
}, nil
}
}
func (c *Commands) validateZitadelProvider(ctx context.Context, provider *ZitadelProvider) error {
if provider.Name = strings.TrimSpace(provider.Name); provider.Name == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-Sgtj5", "Errors.Invalid.Argument")
}
if provider.Issuer = strings.TrimSpace(provider.Issuer); provider.Issuer == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-Hz6zj", "Errors.Invalid.Argument")
}
if provider.ClientID = strings.TrimSpace(provider.ClientID); provider.ClientID == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-fb5jm", "Errors.Invalid.Argument")
}
if provider.ClientSecret = strings.TrimSpace(provider.ClientSecret); provider.ClientSecret == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-Sfdf4", "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, "INST-9uEJaM", "Errors.Invalid.Argument")
}
provider.InstanceRolesInfo[i].OrganizationDomain = strings.TrimSpace(provider.InstanceRolesInfo[i].OrganizationDomain)
if provider.InstanceRolesInfo[i].OrganizationDomain == "" {
return zerrors.ThrowInvalidArgument(nil, "INST-y4Ib4D", "Errors.Invalid.Argument")
}
}
return nil
}
+40
View File
@@ -1034,3 +1034,43 @@ func (wm *InstanceIDPRemoveWriteModel) Query() *eventstore.SearchQueryBuilder {
EventData(map[string]interface{}{"idpConfigId": wm.ID}).
Builder()
}
type InstanceZitadelIDPWriteModel struct {
ZitadelIDPWriteModel
}
func NewInstanceZitadelIDPWriteModel(instanceID, id string) *InstanceZitadelIDPWriteModel {
return &InstanceZitadelIDPWriteModel{
ZitadelIDPWriteModel: ZitadelIDPWriteModel{
WriteModel: eventstore.WriteModel{
AggregateID: instanceID,
ResourceOwner: instanceID,
},
ID: id,
},
}
}
func (wm *InstanceZitadelIDPWriteModel) AppendEvents(events ...eventstore.Event) {
for _, event := range events {
switch e := event.(type) {
case *instance.ZitadelIDPAddedEvent:
wm.ZitadelIDPWriteModel.AppendEvents(&e.ZitadelIDPAddedEvent)
default:
wm.ZitadelIDPWriteModel.AppendEvents(e)
}
}
}
func (wm *InstanceZitadelIDPWriteModel) Query() *eventstore.SearchQueryBuilder {
return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
ResourceOwner(wm.ResourceOwner).
AddQuery().
AggregateTypes(instance.AggregateType).
AggregateIDs(wm.AggregateID).
EventTypes(
instance.ZitadelIDPAddedEventType,
).
EventData(map[string]interface{}{"id": wm.ID}).
Builder()
}
+309
View File
@@ -5937,3 +5937,312 @@ func TestCommandSide_RegenerateInstanceSAMLProviderCertificate(t *testing.T) {
})
}
}
func TestCommandSide_AddInstanceZitadelIDP(t *testing.T) {
pushErr := errors.New("push error")
type fields struct {
eventstore func(*testing.T) *eventstore.Eventstore
idGenerator id.Generator
secretCrypto crypto.EncryptionAlgorithm
}
type args struct {
ctx context.Context
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: authz.WithInstanceID(context.Background(), "instance1"),
provider: ZitadelProvider{},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-Sgtj5", ""))
},
},
},
{
"invalid issuer",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
provider: ZitadelProvider{
Name: "name",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-Hz6zj", ""))
},
},
},
{
"invalid clientID",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-fb5jm", ""))
},
},
},
{
"invalid clientSecret",
fields{
eventstore: expectEventstore(),
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "id1"),
},
args{
ctx: authz.WithInstanceID(context.Background(), "instance1"),
provider: ZitadelProvider{
Name: "name",
Issuer: "issuer",
ClientID: "clientID",
},
},
res{
err: func(err error) bool {
return errors.Is(err, zerrors.ThrowInvalidArgument(nil, "INST-Sfdf4", ""))
},
},
},
{
name: "push error",
fields: fields{
eventstore: expectEventstore(
expectFilter(),
expectPushFailed(
pushErr,
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{},
[]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: authz.WithInstanceID(context.Background(), "instance1"),
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{
err: func(err error) bool {
return errors.Is(err, pushErr)
},
},
},
{
name: "ok",
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{},
[]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: authz.WithInstanceID(context.Background(), "instance1"),
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: "instance1"},
},
},
{
name: "ok all set",
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"),
},
[]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: authz.WithInstanceID(context.Background(), "instance1"),
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: "instance1"},
},
},
}
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,
}
idpId, got, err := c.AddInstanceZitadelProvider(tt.args.ctx, 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, idpId)
assertObjectDetails(t, tt.res.want, got)
}
})
}
}