diff --git a/internal/api/grpc/admin/idp_converter.go b/internal/api/grpc/admin/idp_converter.go index 7383ba88f..6841cadb5 100644 --- a/internal/api/grpc/admin/idp_converter.go +++ b/internal/api/grpc/admin/idp_converter.go @@ -1,8 +1,6 @@ package admin import ( - "strings" - "github.com/crewjam/saml" "github.com/muhlemmer/gu" dsig "github.com/russellhaering/goxmldsig" @@ -553,10 +551,11 @@ func addZitadelProviderToCommand(req *admin_pb.AddZitadelProviderRequest) comman 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), + OrganizationID: info.OrganizationId, + OrganizationDomain: info.OrganizationDomain, }) } + return command.ZitadelProvider{ Name: req.Name, Issuer: req.Issuer, diff --git a/internal/api/grpc/admin/idp_converter_test.go b/internal/api/grpc/admin/idp_converter_test.go index e5b79b836..b18ee650a 100644 --- a/internal/api/grpc/admin/idp_converter_test.go +++ b/internal/api/grpc/admin/idp_converter_test.go @@ -207,6 +207,35 @@ func Test_addZitadelProviderToCommand(t *testing.T) { req *admin_pb.AddZitadelProviderRequest want command.ZitadelProvider }{ + { + name: "without instance roles info", + 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, + }, + }, + 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{}, + }, + }, { name: "all fields filled", req: &admin_pb.AddZitadelProviderRequest{ diff --git a/internal/api/grpc/admin/integration_test/idp_test.go b/internal/api/grpc/admin/integration_test/idp_test.go index 944b2bdc7..7198e7d0e 100644 --- a/internal/api/grpc/admin/integration_test/idp_test.go +++ b/internal/api/grpc/admin/integration_test/idp_test.go @@ -12,6 +12,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/zitadel/zitadel/internal/integration" 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" @@ -28,6 +29,34 @@ func Test_AddZitadelProvider(t *testing.T) { wantErr error wantResponse *admin_pb.AddZitadelProviderResponse }{ + { + name: "no permissions, error", + args: args{ + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + req: &admin_pb.AddZitadelProviderRequest{ + Name: "Zitadel Support IdP", + Issuer: "zitadel.example.com", + ClientId: "test-client", + ClientSecret: "test-secret", + Scopes: []string{"email", "profile"}, + }, + }, + wantErr: status.Error(codes.NotFound, "membership not found (AUTHZ-cdgFk)"), + }, + { + name: "insufficient permissions, error", + args: args{ + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeLogin), + req: &admin_pb.AddZitadelProviderRequest{ + Name: "Zitadel Support IdP", + Issuer: "zitadel.example.com", + ClientId: "test-client", + ClientSecret: "test-secret", + Scopes: []string{"email", "profile"}, + }, + }, + wantErr: status.Error(codes.PermissionDenied, "No matching permissions found (AUTH-5mWD2)"), + }, { name: "missing required field: name", args: args{ diff --git a/internal/api/grpc/management/idp.go b/internal/api/grpc/management/idp.go index 9ef7b833a..3424a210b 100644 --- a/internal/api/grpc/management/idp.go +++ b/internal/api/grpc/management/idp.go @@ -458,3 +458,14 @@ func (s *Server) DeleteProvider(ctx context.Context, req *mgmt_pb.DeleteProvider Details: object_pb.DomainToChangeDetailsPb(details), }, nil } + +func (s *Server) AddZitadelProvider(ctx context.Context, req *mgmt_pb.AddZitadelProviderRequest) (*mgmt_pb.AddZitadelProviderResponse, error) { + id, details, err := s.command.AddOrgZitadelProvider(ctx, authz.GetCtxData(ctx).OrgID, addZitadelProviderToCommand(req)) + if err != nil { + return nil, err + } + return &mgmt_pb.AddZitadelProviderResponse{ + Id: id, + Details: object_pb.DomainToAddDetailsPb(details), + }, nil +} diff --git a/internal/api/grpc/management/idp_converter.go b/internal/api/grpc/management/idp_converter.go index 6fa49d081..a2033f5af 100644 --- a/internal/api/grpc/management/idp_converter.go +++ b/internal/api/grpc/management/idp_converter.go @@ -14,6 +14,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" idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt_pb "github.com/zitadel/zitadel/pkg/grpc/management" @@ -538,3 +539,23 @@ func signatureAlgorithmToCommand(signatureAlgorithm idp_pb.SAMLSignatureAlgorith return "" } } + +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, + ClientID: req.ClientId, + ClientSecret: req.ClientSecret, + Scopes: req.Scopes, + IDPOptions: idp_grpc.OptionsToCommand(req.ProviderOptions), + InstanceRolesInfo: instanceRolesInfo, + } +} diff --git a/internal/api/grpc/management/idp_converter_test.go b/internal/api/grpc/management/idp_converter_test.go index 78640aa95..dc4a38ce8 100644 --- a/internal/api/grpc/management/idp_converter_test.go +++ b/internal/api/grpc/management/idp_converter_test.go @@ -3,10 +3,13 @@ package management 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" - "github.com/zitadel/zitadel/pkg/grpc/idp" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp" mgmt_pb "github.com/zitadel/zitadel/pkg/grpc/management" ) @@ -23,13 +26,13 @@ func Test_addOIDCIDPRequestToDomain(t *testing.T) { args: args{ req: &mgmt_pb.AddOrgOIDCIDPRequest{ 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: &mgmt_pb.UpdateOrgIDPRequest{ 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,98 @@ func Test_signatureAlgorithmToCommand(t *testing.T) { }) } } + +func Test_addZitadelProviderToCommand(t *testing.T) { + t.Parallel() + tests := []struct { + name string + req *mgmt_pb.AddZitadelProviderRequest + want command.ZitadelProvider + }{ + { + name: "without instance roles info", + req: &mgmt_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, + }, + }, + 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{}, + }, + }, + { + name: "all fields filled", + req: &mgmt_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) + }) + } +} diff --git a/internal/api/grpc/management/integration_test/idp_test.go b/internal/api/grpc/management/integration_test/idp_test.go new file mode 100644 index 000000000..4a066a2d4 --- /dev/null +++ b/internal/api/grpc/management/integration_test/idp_test.go @@ -0,0 +1,233 @@ +//go:build integration + +package management_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" + + "github.com/zitadel/zitadel/internal/integration" + idp_pb "github.com/zitadel/zitadel/pkg/grpc/idp" + mgmt_pb "github.com/zitadel/zitadel/pkg/grpc/management" + object_pb "github.com/zitadel/zitadel/pkg/grpc/object" +) + +func Test_AddZitadelProvider(t *testing.T) { + type args struct { + ctx context.Context + req *mgmt_pb.AddZitadelProviderRequest + } + tests := []struct { + name string + args args + wantErr error + wantResponse *mgmt_pb.AddZitadelProviderResponse + }{ + { + name: "no permissions, error", + args: args{ + ctx: Instance.WithAuthorizationToken(CTX, integration.UserTypeNoPermission), + req: &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, + }, + }, + }, + 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.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, + }, + }, + }, + wantErr: status.Error(codes.PermissionDenied, "No matching permissions found (AUTH-5mWD2)"), + }, + { + name: "missing required field: name", + args: args{ + ctx: OrgCTX, + req: &mgmt_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: OrgCTX, + req: &mgmt_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: OrgCTX, + req: &mgmt_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: OrgCTX, + req: &mgmt_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: OrgCTX, + req: &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: "", + }, + }, + }, + }, + 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: OrgCTX, + req: &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", + }, + { + 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: OrgCTX, + req: &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, + }, + }, + }, + wantResponse: &mgmt_pb.AddZitadelProviderResponse{ + Details: &object_pb.ObjectDetails{ + ResourceOwner: Instance.DefaultOrg.Id, + }, + }, + }, + { + name: "valid request with instance roles info", + args: args{ + ctx: OrgCTX, + req: &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", + }, + }, + }, + }, + wantResponse: &mgmt_pb.AddZitadelProviderResponse{ + 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.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()) + }) + } +} diff --git a/internal/command/instance_idp.go b/internal/command/instance_idp.go index a7a805e68..eb1c05621 100644 --- a/internal/command/instance_idp.go +++ b/internal/command/instance_idp.go @@ -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") } diff --git a/internal/command/instance_idp_test.go b/internal/command/instance_idp_test.go index 517d62187..f8eb31d11 100644 --- a/internal/command/instance_idp_test.go +++ b/internal/command/instance_idp_test.go @@ -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{ diff --git a/internal/command/org_idp.go b/internal/command/org_idp.go index 214cccb47..912f321f8 100644 --- a/internal/command/org_idp.go +++ b/internal/command/org_idp.go @@ -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 +} diff --git a/internal/command/org_idp_model.go b/internal/command/org_idp_model.go index 9a7fea93c..c0adb63bf 100644 --- a/internal/command/org_idp_model.go +++ b/internal/command/org_idp_model.go @@ -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() +} diff --git a/internal/command/org_idp_test.go b/internal/command/org_idp_test.go index 45efa3a52..5b23b052c 100644 --- a/internal/command/org_idp_test.go +++ b/internal/command/org_idp_test.go @@ -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) + } + }) + } +} diff --git a/internal/query/projection/idp_template.go b/internal/query/projection/idp_template.go index c6bee0ff6..b14fc5ef3 100644 --- a/internal/query/projection/idp_template.go +++ b/internal/query/projection/idp_template.go @@ -694,6 +694,10 @@ func (p *idpTemplateProjection) Reducers() []handler.AggregateReducer { Event: org.SAMLIDPChangedEventType, Reduce: p.reduceSAMLIDPChanged, }, + { + Event: org.ZitadelIDPAddedEventType, + Reduce: p.reduceZitadelIDPAdded, + }, { Event: org.IDPConfigRemovedEventType, Reduce: p.reduceIDPConfigRemoved, @@ -2574,9 +2578,11 @@ func (p *idpTemplateProjection) reduceZitadelIDPAdded(event eventstore.Event) (* case *instance.ZitadelIDPAddedEvent: idpEvent = e.ZitadelIDPAddedEvent idpOwnerType = domain.IdentityProviderTypeSystem - // todo (@grvijayan): org-level ZitadelIDPAddedEvent in the next PRs + case *org.ZitadelIDPAddedEvent: + idpEvent = e.ZitadelIDPAddedEvent + idpOwnerType = domain.IdentityProviderTypeOrg default: - return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ZCpH41", "reduce.wrong.event.type %v", []eventstore.EventType{instance.ZitadelIDPAddedEventType}) + return nil, zerrors.ThrowInvalidArgumentf(nil, "HANDL-ZCpH41", "reduce.wrong.event.type %v", []eventstore.EventType{instance.ZitadelIDPAddedEventType, org.ZitadelIDPAddedEventType}) } zitadelIDPCols := []handler.Column{ diff --git a/internal/query/projection/idp_template_test.go b/internal/query/projection/idp_template_test.go index 2c20e4792..03a634ff4 100644 --- a/internal/query/projection/idp_template_test.go +++ b/internal/query/projection/idp_template_test.go @@ -4659,6 +4659,149 @@ func TestIDPTemplateProjection_reducesZitadel(t *testing.T) { }, }, }, + { + name: "org reduceZitadelIDPAdded without instance roles info", + args: args{ + event: getEvent( + testEvent( + org.ZitadelIDPAddedEventType, + org.AggregateType, + []byte(`{ + "id": "idp-id", + "name": "idp-name", + "issuer": "issuer", + "clientId": "client_id", + "clientSecret": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "scopes": ["profile"], + "isCreationAllowed": true, + "isLinkingAllowed": true, + "isAutoCreation": true, + "isAutoUpdate": true, + "autoLinkingOption": 1 +}`), + ), eventstore.GenericEventMapper[org.ZitadelIDPAddedEvent]), + }, + reduce: (&idpTemplateProjection{}).reduceZitadelIDPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateInsertStmt, + expectedArgs: []interface{}{ + "idp-id", + anyArg{}, + anyArg{}, + uint64(15), + "ro-id", + "instance-id", + domain.IDPStateActive, + "idp-name", + domain.IdentityProviderTypeOrg, + domain.IDPTypeZitadel, + true, + true, + true, + true, + domain.AutoLinkingOptionUsername, + }, + }, + { + expectedStmt: "INSERT INTO projections.idp_templates6_zitadel (idp_id, instance_id, issuer, client_id, client_secret, scopes) VALUES ($1, $2, $3, $4, $5, $6)", + expectedArgs: []interface{}{ + "idp-id", + "instance-id", + "issuer", + "client_id", + anyArg{}, + database.TextArray[string]{"profile"}, + }, + }, + }, + }, + }, + }, + { + name: "org reduceZitadelIDPAdded with instance roles info", + args: args{ + event: getEvent( + testEvent( + org.ZitadelIDPAddedEventType, + org.AggregateType, + []byte(`{ + "id": "idp-id", + "name": "idp-name", + "issuer": "issuer", + "clientId": "client_id", + "clientSecret": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "scopes": ["profile"], + "isCreationAllowed": true, + "isLinkingAllowed": true, + "isAutoCreation": true, + "isAutoUpdate": true, + "autoLinkingOption": 1, + "instanceRolesInfo": [{ + "organizationId": "org1", + "organizationDomain": "org1.com" + }, + { + "organizationId": "org2", + "organizationDomain": "org2.com" + }] +}`), + ), eventstore.GenericEventMapper[org.ZitadelIDPAddedEvent]), + }, + reduce: (&idpTemplateProjection{}).reduceZitadelIDPAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("org"), + sequence: 15, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: idpTemplateInsertStmt, + expectedArgs: []interface{}{ + "idp-id", + anyArg{}, + anyArg{}, + uint64(15), + "ro-id", + "instance-id", + domain.IDPStateActive, + "idp-name", + domain.IdentityProviderTypeOrg, + domain.IDPTypeZitadel, + true, + true, + true, + true, + domain.AutoLinkingOptionUsername, + }, + }, + { + expectedStmt: "INSERT INTO projections.idp_templates6_zitadel (idp_id, instance_id, issuer, client_id, client_secret, scopes, instance_roles_info) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedArgs: []interface{}{ + "idp-id", + "instance-id", + "issuer", + "client_id", + anyArg{}, + database.TextArray[string]{"profile"}, + []byte(`[{"organizationId":"org1","organizationDomain":"org1.com"},{"organizationId":"org2","organizationDomain":"org2.com"}]`), + }, + }, + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/repository/org/eventstore.go b/internal/repository/org/eventstore.go index 74052acc4..83bd48a83 100644 --- a/internal/repository/org/eventstore.go +++ b/internal/repository/org/eventstore.go @@ -103,6 +103,7 @@ func init() { eventstore.RegisterFilterEventMapper(AggregateType, AppleIDPChangedEventType, AppleIDPChangedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SAMLIDPAddedEventType, SAMLIDPAddedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, SAMLIDPChangedEventType, SAMLIDPChangedEventMapper) + eventstore.RegisterFilterEventMapper(AggregateType, ZitadelIDPAddedEventType, eventstore.GenericEventMapper[ZitadelIDPAddedEvent]) eventstore.RegisterFilterEventMapper(AggregateType, IDPRemovedEventType, IDPRemovedEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, TriggerActionsSetEventType, TriggerActionsSetEventMapper) eventstore.RegisterFilterEventMapper(AggregateType, TriggerActionsCascadeRemovedEventType, TriggerActionsCascadeRemovedEventMapper) diff --git a/internal/repository/org/idp.go b/internal/repository/org/idp.go index dc402e36e..70faed4ce 100644 --- a/internal/repository/org/idp.go +++ b/internal/repository/org/idp.go @@ -38,6 +38,7 @@ const ( SAMLIDPAddedEventType eventstore.EventType = "org.idp.saml.added" SAMLIDPChangedEventType eventstore.EventType = "org.idp.saml.changed" IDPRemovedEventType eventstore.EventType = "org.idp.removed" + ZitadelIDPAddedEventType eventstore.EventType = "org.idp.zitadel.added" ) type OAuthIDPAddedEvent struct { @@ -1129,3 +1130,42 @@ func IDPRemovedEventMapper(event eventstore.Event) (eventstore.Event, error) { return &IDPRemovedEvent{RemovedEvent: *e.(*idp.RemovedEvent)}, nil } + +type ZitadelIDPAddedEvent struct { + idp.ZitadelIDPAddedEvent +} + +func NewZitadelIDPAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + name, + issuer, + clientID string, + clientSecret *crypto.CryptoValue, + scopes []string, + options idp.Options, + instanceRolesInfo []idp.RolesInfo, +) *ZitadelIDPAddedEvent { + return &ZitadelIDPAddedEvent{ + ZitadelIDPAddedEvent: *idp.NewZitadelIDPAddedEvent( + eventstore.NewBaseEventForPush( + ctx, + aggregate, + ZitadelIDPAddedEventType, + ), + id, + name, + issuer, + clientID, + clientSecret, + scopes, + options, + instanceRolesInfo, + ), + } +} + +func (e *ZitadelIDPAddedEvent) SetBaseEvent(event *eventstore.BaseEvent) { + e.BaseEvent = *event +}