diff --git a/cmd/admin/start/start.go b/cmd/admin/start/start.go index fb06b80234..72737e6080 100644 --- a/cmd/admin/start/start.go +++ b/cmd/admin/start/start.go @@ -143,7 +143,14 @@ func startZitadel(config *startConfig) error { return fmt.Errorf("cannot start eventstore for queries: %w", err) } smtpPasswordCrypto, err := crypto.NewAESCrypto(config.SystemDefaults.SMTPPasswordVerificationKey) - logging.Log("MAIN-en9ew").OnError(err).Fatal("cannot create smtp crypto") + if err != nil { + return fmt.Errorf("cannot create smtp crypto: %w", err) + } + + smsCrypto, err := crypto.NewAESCrypto(config.SystemDefaults.SMSVerificationKey) + if err != nil { + return fmt.Errorf("cannot create smtp crypto: %w", err) + } queries, err := query.StartQueries(ctx, eventstoreClient, dbClient, config.Projections.Config, config.SystemDefaults, config.Projections.KeyConfig, keyChan, config.InternalAuthZ.RolePermissionMappings) if err != nil { @@ -159,7 +166,7 @@ func startZitadel(config *startConfig) error { Origin: http_util.BuildHTTP(config.ExternalDomain, config.ExternalPort, config.ExternalSecure), DisplayName: "ZITADEL", } - commands, err := command.StartCommands(eventstoreClient, config.SystemDefaults, config.InternalAuthZ, storage, authZRepo, config.OIDC.KeyConfig, smtpPasswordCrypto, webAuthNConfig) + commands, err := command.StartCommands(eventstoreClient, config.SystemDefaults, config.InternalAuthZ, storage, authZRepo, config.OIDC.KeyConfig, webAuthNConfig, smtpPasswordCrypto, smsCrypto) if err != nil { return fmt.Errorf("cannot start commands: %w", err) } diff --git a/cmd/defaults.yaml b/cmd/defaults.yaml index be85496a74..62870f3da7 100644 --- a/cmd/defaults.yaml +++ b/cmd/defaults.yaml @@ -143,6 +143,8 @@ SystemDefaults: EncryptionKeyID: $ZITADEL_IDP_CONFIG_VERIFICATION_KEY SMTPPasswordVerificationKey: EncryptionKeyID: $ZITADEL_SMTP_PASSWORD_VERIFICATION_KEY + SMSVerificationKey: + EncryptionKeyID: $ZITADEL_SMS_VERIFICATION_KEY SecretGenerators: PasswordSaltCost: 14 ClientSecretGenerator: diff --git a/docs/docs/apis/proto/admin.md b/docs/docs/apis/proto/admin.md index 1b1c1e12cb..b6bd61d5f9 100644 --- a/docs/docs/apis/proto/admin.md +++ b/docs/docs/apis/proto/admin.md @@ -128,6 +128,66 @@ Update system smtp configuration password for host PUT: /smtp/password +### ListSMSProviders + +> **rpc** ListSMSProviders([ListSMSProvidersRequest](#listsmsprovidersrequest)) +[ListSMSProvidersResponse](#listsmsprovidersresponse) + +list sms provider configurations + + + + POST: /sms/_search + + +### GetSMSProvider + +> **rpc** GetSMSProvider([GetSMSProviderRequest](#getsmsproviderrequest)) +[GetSMSProviderResponse](#getsmsproviderresponse) + +Get sms provider + + + + GET: /sms/{id} + + +### AddSMSProviderTwilio + +> **rpc** AddSMSProviderTwilio([AddSMSProviderTwilioRequest](#addsmsprovidertwiliorequest)) +[AddSMSProviderTwilioResponse](#addsmsprovidertwilioresponse) + +Add twilio sms provider + + + + POST: /sms/twilio + + +### UpdateSMSProviderTwilio + +> **rpc** UpdateSMSProviderTwilio([UpdateSMSProviderTwilioRequest](#updatesmsprovidertwiliorequest)) +[UpdateSMSProviderTwilioResponse](#updatesmsprovidertwilioresponse) + +Update twilio sms provider + + + + PUT: /sms/twilio/{id} + + +### UpdateSMSProviderTwilioToken + +> **rpc** UpdateSMSProviderTwilioToken([UpdateSMSProviderTwilioTokenRequest](#updatesmsprovidertwiliotokenrequest)) +[UpdateSMSProviderTwilioTokenResponse](#updatesmsprovidertwiliotokenresponse) + +Update twilio sms provider token + + + + PUT: /sms/twilio/{id}/token + + ### GetOrgByID > **rpc** GetOrgByID([GetOrgByIDRequest](#getorgbyidrequest)) @@ -1446,6 +1506,31 @@ This is an empty request +### AddSMSProviderTwilioRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| sid | string | - | string.min_len: 1
string.max_len: 200
| +| token | string | - | string.min_len: 1
string.max_len: 200
| +| from | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### AddSMSProviderTwilioResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ObjectDetails | - | | +| id | string | - | | + + + + ### AddSecondFactorToLoginPolicyRequest @@ -2090,6 +2175,28 @@ This is an empty request +### GetSMSProviderRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | string.min_len: 1
string.max_len: 100
| + + + + +### GetSMSProviderResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| config | zitadel.settings.v1.SMSProvider | - | | + + + + ### GetSMTPConfigRequest This is an empty request @@ -2363,6 +2470,29 @@ This is an empty request +### ListSMSProvidersRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| query | zitadel.v1.ListQuery | list limitations and ordering | | + + + + +### ListSMSProvidersResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ListDetails | - | | +| result | repeated zitadel.settings.v1.SMSProvider | - | | + + + + ### ListSecretGeneratorsRequest @@ -3572,6 +3702,53 @@ This is an empty request +### UpdateSMSProviderTwilioRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | string.min_len: 1
string.max_len: 200
| +| sid | string | - | string.min_len: 1
string.max_len: 200
| +| from | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### UpdateSMSProviderTwilioResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ObjectDetails | - | | + + + + +### UpdateSMSProviderTwilioTokenRequest + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| id | string | - | string.min_len: 1
string.max_len: 200
| +| token | string | - | string.min_len: 1
string.max_len: 200
| + + + + +### UpdateSMSProviderTwilioTokenResponse + + + +| Field | Type | Description | Validation | +| ----- | ---- | ----------- | ----------- | +| details | zitadel.v1.ObjectDetails | - | | + + + + ### UpdateSMTPConfigPasswordRequest diff --git a/internal/api/grpc/admin/sms.go b/internal/api/grpc/admin/sms.go new file mode 100644 index 0000000000..1844cce532 --- /dev/null +++ b/internal/api/grpc/admin/sms.go @@ -0,0 +1,74 @@ +package admin + +import ( + "context" + + "github.com/caos/zitadel/internal/api/grpc/object" + admin_pb "github.com/caos/zitadel/pkg/grpc/admin" + settings_pb "github.com/caos/zitadel/pkg/grpc/settings" +) + +func (s *Server) ListSMSProviders(ctx context.Context, req *admin_pb.ListSMSProvidersRequest) (*admin_pb.ListSMSProvidersResponse, error) { + queries, err := listSMSConfigsToModel(req) + if err != nil { + return nil, err + } + result, err := s.query.SearchSMSConfigs(ctx, queries) + if err != nil { + return nil, err + + } + return &admin_pb.ListSMSProvidersResponse{ + Details: object.ToListDetails(result.Count, result.Sequence, result.Timestamp), + }, nil +} + +func (s *Server) GetSMSProvider(ctx context.Context, req *admin_pb.GetSMSProviderRequest) (*admin_pb.GetSMSProviderResponse, error) { + result, err := s.query.SMSProviderConfigByID(ctx, req.Id) + if err != nil { + return nil, err + + } + return &admin_pb.GetSMSProviderResponse{ + Config: &settings_pb.SMSProvider{ + Details: object.ToViewDetailsPb(result.Sequence, result.CreationDate, result.ChangeDate, result.ResourceOwner), + Id: result.ID, + State: smsStateToPb(result.State), + Config: SMSConfigToPb(result), + }, + }, nil +} + +func (s *Server) AddSMSProviderTwilio(ctx context.Context, req *admin_pb.AddSMSProviderTwilioRequest) (*admin_pb.AddSMSProviderTwilioResponse, error) { + id, result, err := s.command.AddSMSConfigTwilio(ctx, AddSMSConfigTwilioToConfig(req)) + if err != nil { + return nil, err + + } + return &admin_pb.AddSMSProviderTwilioResponse{ + Details: object.DomainToAddDetailsPb(result), + Id: id, + }, nil +} + +func (s *Server) UpdateSMSProviderTwilio(ctx context.Context, req *admin_pb.UpdateSMSProviderTwilioRequest) (*admin_pb.UpdateSMSProviderTwilioResponse, error) { + result, err := s.command.ChangeSMSConfigTwilio(ctx, req.Id, UpdateSMSConfigTwilioToConfig(req)) + if err != nil { + return nil, err + + } + return &admin_pb.UpdateSMSProviderTwilioResponse{ + Details: object.DomainToChangeDetailsPb(result), + }, nil +} + +func (s *Server) UpdateSMSProviderTwilioToken(ctx context.Context, req *admin_pb.UpdateSMSProviderTwilioTokenRequest) (*admin_pb.UpdateSMSProviderTwilioTokenResponse, error) { + result, err := s.command.ChangeSMSConfigTwilioToken(ctx, req.Id, req.Token) + if err != nil { + return nil, err + + } + return &admin_pb.UpdateSMSProviderTwilioTokenResponse{ + Details: object.DomainToChangeDetailsPb(result), + }, nil +} diff --git a/internal/api/grpc/admin/sms_converter.go b/internal/api/grpc/admin/sms_converter.go new file mode 100644 index 0000000000..c47ea201b7 --- /dev/null +++ b/internal/api/grpc/admin/sms_converter.go @@ -0,0 +1,63 @@ +package admin + +import ( + "github.com/caos/zitadel/internal/api/grpc/object" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/notification/channels/twilio" + "github.com/caos/zitadel/internal/query" + admin_pb "github.com/caos/zitadel/pkg/grpc/admin" + settings_pb "github.com/caos/zitadel/pkg/grpc/settings" +) + +func listSMSConfigsToModel(req *admin_pb.ListSMSProvidersRequest) (*query.SMSConfigsSearchQueries, error) { + offset, limit, asc := object.ListQueryToModel(req.Query) + return &query.SMSConfigsSearchQueries{ + SearchRequest: query.SearchRequest{ + Offset: offset, + Limit: limit, + Asc: asc, + }, + }, nil +} + +func SMSConfigToPb(app *query.SMSConfig) settings_pb.SMSConfig { + if app.TwilioConfig != nil { + return TwilioConfigToPb(app.TwilioConfig) + } + return nil +} + +func TwilioConfigToPb(twilio *query.Twilio) *settings_pb.SMSProvider_Twilio { + return &settings_pb.SMSProvider_Twilio{ + Twilio: &settings_pb.TwilioConfig{ + Sid: twilio.SID, + SenderNumber: twilio.SenderNumber, + }, + } +} + +func smsStateToPb(state domain.SMSConfigState) settings_pb.SMSProviderConfigState { + switch state { + case domain.SMSConfigStateInactive: + return settings_pb.SMSProviderConfigState_SMS_PROVIDER_CONFIG_INACTIVE + case domain.SMSConfigStateActive: + return settings_pb.SMSProviderConfigState_SMS_PROVIDER_CONFIG_ACTIVE + default: + return settings_pb.SMSProviderConfigState_SMS_PROVIDER_CONFIG_INACTIVE + } +} + +func AddSMSConfigTwilioToConfig(req *admin_pb.AddSMSProviderTwilioRequest) *twilio.TwilioConfig { + return &twilio.TwilioConfig{ + SID: req.Sid, + SenderNumber: req.SenderNumber, + Token: req.Token, + } +} + +func UpdateSMSConfigTwilioToConfig(req *admin_pb.UpdateSMSProviderTwilioRequest) *twilio.TwilioConfig { + return &twilio.TwilioConfig{ + SID: req.Sid, + SenderNumber: req.SenderNumber, + } +} diff --git a/internal/command/command.go b/internal/command/command.go index e8f222e989..f2d27dadf8 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -32,6 +32,7 @@ type Commands struct { idpConfigSecretCrypto crypto.EncryptionAlgorithm smtpPasswordCrypto crypto.EncryptionAlgorithm + smsCrypto crypto.EncryptionAlgorithm userPasswordAlg crypto.HashAlgorithm machineKeySize int @@ -60,8 +61,9 @@ func StartCommands( staticStore static.Storage, authZRepo authz_repo.Repository, keyConfig *crypto.KeyConfig, - smtpPasswordEncAlg crypto.EncryptionAlgorithm, webAuthN webauthn_helper.Config, + smtpPasswordEncAlg crypto.EncryptionAlgorithm, + smsHashAlg crypto.EncryptionAlgorithm, ) (repo *Commands, err error) { repo = &Commands{ eventstore: es, @@ -73,6 +75,7 @@ func StartCommands( privateKeyLifetime: defaults.KeyConfig.PrivateKeyLifetime, publicKeyLifetime: defaults.KeyConfig.PublicKeyLifetime, smtpPasswordCrypto: smtpPasswordEncAlg, + smsCrypto: smsHashAlg, } iam_repo.RegisterEventMappers(repo.eventstore) org.RegisterEventMappers(repo.eventstore) diff --git a/internal/command/sms_config.go b/internal/command/sms_config.go new file mode 100644 index 0000000000..e3af258820 --- /dev/null +++ b/internal/command/sms_config.go @@ -0,0 +1,207 @@ +package command + +import ( + "context" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/notification/channels/twilio" + "github.com/caos/zitadel/internal/repository/iam" +) + +func (c *Commands) AddSMSConfigTwilio(ctx context.Context, config *twilio.TwilioConfig) (string, *domain.ObjectDetails, error) { + id, err := c.idGenerator.Next() + if err != nil { + return "", nil, err + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return "", nil, err + } + + var token *crypto.CryptoValue + if config.Token != "" { + token, err = crypto.Encrypt([]byte(config.Token), c.smsCrypto) + if err != nil { + return "", nil, err + } + } + + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + pushedEvents, err := c.eventstore.Push(ctx, iam.NewSMSConfigTwilioAddedEvent( + ctx, + iamAgg, + id, + config.SID, + config.SenderNumber, + token)) + if err != nil { + return "", nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return "", nil, err + } + return id, writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} + +func (c *Commands) ChangeSMSConfigTwilio(ctx context.Context, id string, config *twilio.TwilioConfig) (*domain.ObjectDetails, error) { + if id == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "SMS-e9jwf", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return nil, err + } + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-2m9fw", "Errors.SMSConfig.NotFound") + } + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + + changedEvent, hasChanged, err := smsConfigWriteModel.NewChangedEvent( + ctx, + iamAgg, + id, + config.SID, + config.SenderNumber) + if err != nil { + return nil, err + } + if !hasChanged { + return nil, caos_errs.ThrowPreconditionFailed(nil, "COMMAND-jf9wk", "Errors.NoChangesFound") + } + pushedEvents, err := c.eventstore.Push(ctx, changedEvent) + if err != nil { + return nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} + +func (c *Commands) ChangeSMSConfigTwilioToken(ctx context.Context, id, token string) (*domain.ObjectDetails, error) { + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return nil, err + } + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-fj9wf", "Errors.SMSConfig.NotFound") + } + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + newtoken, err := crypto.Encrypt([]byte(token), c.smsCrypto) + if err != nil { + return nil, err + } + pushedEvents, err := c.eventstore.Push(ctx, iam.NewSMSConfigTokenChangedEvent( + ctx, + iamAgg, + id, + newtoken)) + if err != nil { + return nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} + +func (c *Commands) ActivateSMSConfigTwilio(ctx context.Context, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "SMS-dn93n", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return nil, err + } + + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.NotFound") + } + if smsConfigWriteModel.State == domain.SMSConfigStateActive { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.AlreadyActive") + } + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + pushedEvents, err := c.eventstore.Push(ctx, iam.NewSMSConfigTwilioActivatedEvent( + ctx, + iamAgg, + id)) + if err != nil { + return nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} + +func (c *Commands) DeactivateSMSConfigTwilio(ctx context.Context, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "SMS-frkwf", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return nil, err + } + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-s39Kg", "Errors.SMSConfig.NotFound") + } + if smsConfigWriteModel.State == domain.SMSConfigStateInactive { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-dm9e3", "Errors.SMSConfig.AlreadyDeactivated") + } + + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + pushedEvents, err := c.eventstore.Push(ctx, iam.NewSMSConfigDeactivatedEvent( + ctx, + iamAgg, + id)) + if err != nil { + return nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} + +func (c *Commands) RemoveSMSConfigTwilio(ctx context.Context, id string) (*domain.ObjectDetails, error) { + if id == "" { + return nil, caos_errs.ThrowInvalidArgument(nil, "SMS-3j9fs", "Errors.IDMissing") + } + smsConfigWriteModel, err := c.getSMSConfig(ctx, id) + if err != nil { + return nil, err + } + if !smsConfigWriteModel.State.Exists() || smsConfigWriteModel.Twilio == nil { + return nil, caos_errs.ThrowNotFound(nil, "COMMAND-sn9we", "Errors.SMSConfig.NotFound") + } + + iamAgg := IAMAggregateFromWriteModel(&smsConfigWriteModel.WriteModel) + pushedEvents, err := c.eventstore.Push(ctx, iam.NewSMSConfigRemovedEvent( + ctx, + iamAgg, + id)) + if err != nil { + return nil, err + } + err = AppendAndReduce(smsConfigWriteModel, pushedEvents...) + if err != nil { + return nil, err + } + return writeModelToObjectDetails(&smsConfigWriteModel.WriteModel), nil +} +func (c *Commands) getSMSConfig(ctx context.Context, id string) (_ *IAMSMSConfigWriteModel, err error) { + writeModel := NewIAMSMSConfigWriteModel(id) + err = c.eventstore.FilterToQueryReducer(ctx, writeModel) + if err != nil { + return nil, err + } + + return writeModel, nil +} diff --git a/internal/command/sms_config_model.go b/internal/command/sms_config_model.go new file mode 100644 index 0000000000..93f84998ef --- /dev/null +++ b/internal/command/sms_config_model.go @@ -0,0 +1,119 @@ +package command + +import ( + "context" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/repository/iam" +) + +type IAMSMSConfigWriteModel struct { + eventstore.WriteModel + + ID string + Twilio *TwilioConfig + State domain.SMSConfigState +} + +type TwilioConfig struct { + SID string + Token *crypto.CryptoValue + SenderNumber string +} + +func NewIAMSMSConfigWriteModel(id string) *IAMSMSConfigWriteModel { + return &IAMSMSConfigWriteModel{ + WriteModel: eventstore.WriteModel{ + AggregateID: domain.IAMID, + ResourceOwner: domain.IAMID, + }, + ID: id, + } +} + +func (wm *IAMSMSConfigWriteModel) Reduce() error { + for _, event := range wm.Events { + switch e := event.(type) { + case *iam.SMSConfigTwilioAddedEvent: + if wm.ID != e.ID { + continue + } + wm.Twilio = &TwilioConfig{ + SID: e.SID, + Token: e.Token, + SenderNumber: e.SenderNumber, + } + wm.State = domain.SMSConfigStateInactive + case *iam.SMSConfigTwilioChangedEvent: + if wm.ID != e.ID { + continue + } + if e.SID != nil { + wm.Twilio.SID = *e.SID + } + if e.SenderNumber != nil { + wm.Twilio.SenderNumber = *e.SenderNumber + } + case *iam.SMSConfigTwilioTokenChangedEvent: + if wm.ID != e.ID { + continue + } + wm.Twilio.Token = e.Token + case *iam.SMSConfigActivatedEvent: + if wm.ID != e.ID { + continue + } + wm.State = domain.SMSConfigStateActive + case *iam.SMSConfigDeactivatedEvent: + if wm.ID != e.ID { + continue + } + wm.State = domain.SMSConfigStateInactive + case *iam.SMSConfigRemovedEvent: + if wm.ID != e.ID { + continue + } + wm.Twilio = nil + wm.State = domain.SMSConfigStateRemoved + } + } + return wm.WriteModel.Reduce() +} +func (wm *IAMSMSConfigWriteModel) Query() *eventstore.SearchQueryBuilder { + return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent). + ResourceOwner(wm.ResourceOwner). + AddQuery(). + AggregateTypes(iam.AggregateType). + AggregateIDs(wm.AggregateID). + EventTypes( + iam.SMSConfigTwilioAddedEventType, + iam.SMSConfigTwilioChangedEventType, + iam.SMSConfigTwilioTokenChangedEventType, + iam.SMSConfigActivatedEventType, + iam.SMSConfigDeactivatedEventType, + iam.SMSConfigRemovedEventType). + Builder() +} + +func (wm *IAMSMSConfigWriteModel) NewChangedEvent(ctx context.Context, aggregate *eventstore.Aggregate, id, sid, senderNumber string) (*iam.SMSConfigTwilioChangedEvent, bool, error) { + changes := make([]iam.SMSConfigTwilioChanges, 0) + var err error + + if wm.Twilio.SID != sid { + changes = append(changes, iam.ChangeSMSConfigTwilioSID(sid)) + } + if wm.Twilio.SenderNumber != senderNumber { + changes = append(changes, iam.ChangeSMSConfigTwilioSenderNumber(senderNumber)) + } + + if len(changes) == 0 { + return nil, false, nil + } + changeEvent, err := iam.NewSMSConfigTwilioChangedEvent(ctx, aggregate, id, changes) + if err != nil { + return nil, false, err + } + return changeEvent, true, nil +} diff --git a/internal/command/sms_config_test.go b/internal/command/sms_config_test.go new file mode 100644 index 0000000000..f085f0c532 --- /dev/null +++ b/internal/command/sms_config_test.go @@ -0,0 +1,605 @@ +package command + +import ( + "context" + "testing" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + caos_errs "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/id" + id_mock "github.com/caos/zitadel/internal/id/mock" + "github.com/caos/zitadel/internal/notification/channels/twilio" + "github.com/caos/zitadel/internal/repository/iam" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" +) + +func TestCommandSide_AddSMSConfigTwilio(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + idGenerator id.Generator + alg crypto.EncryptionAlgorithm + } + type args struct { + ctx context.Context + sms *twilio.TwilioConfig + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "add sms config twilio, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + expectPush( + []*repository.Event{ + eventFromEventPusher(iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "senderName", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("token"), + }, + ), + ), + }, + ), + ), + idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "providerid"), + alg: crypto.CreateMockEncryptionAlg(gomock.NewController(t)), + }, + args: args{ + ctx: context.Background(), + sms: &twilio.TwilioConfig{ + SID: "sid", + Token: "token", + SenderNumber: "senderName", + }, + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "IAM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + idGenerator: tt.fields.idGenerator, + smsCrypto: tt.fields.alg, + } + _, got, err := r.AddSMSConfigTwilio(tt.args.ctx, tt.args.sms) + 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.want, got) + } + }) + } +} + +func TestCommandSide_ChangeSMSConfigTwilio(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + sms *twilio.TwilioConfig + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "id empty, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + sms: &twilio.TwilioConfig{}, + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "sms not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + sms: &twilio.TwilioConfig{}, + id: "id", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "no changes, precondition error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "senderName", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("token"), + }, + ), + ), + ), + ), + }, + args: args{ + ctx: context.Background(), + sms: &twilio.TwilioConfig{ + SID: "sid", + Token: "token", + SenderNumber: "senderName", + }, + id: "providerid", + }, + res: res{ + err: caos_errs.IsPreconditionFailed, + }, + }, + { + name: "sms config twilio change, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "token", + &crypto.CryptoValue{ + CryptoType: crypto.TypeEncryption, + Algorithm: "enc", + KeyID: "id", + Crypted: []byte("token"), + }, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + newSMSConfigTwilioChangedEvent( + context.Background(), + "providerid", + "sid2", + "senderName2", + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + sms: &twilio.TwilioConfig{ + SID: "sid2", + Token: "token2", + SenderNumber: "senderName2", + }, + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "IAM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := r.ChangeSMSConfigTwilio(tt.args.ctx, tt.args.id, tt.args.sms) + 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.want, got) + } + }) + } +} + +func TestCommandSide_ActivateSMSConfigTwilio(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "id empty, invalid error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "sms not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + id: "id", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "sms config twilio activate, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "sender-name", + &crypto.CryptoValue{}, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + iam.NewSMSConfigTwilioActivatedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "IAM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := r.ActivateSMSConfigTwilio(tt.args.ctx, tt.args.id) + 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.want, got) + } + }) + } +} + +func TestCommandSide_DeactivateSMSConfigTwilio(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "id empty, invalid error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "sms not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + id: "id", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "sms config twilio deactivate, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "sender-name", + &crypto.CryptoValue{}, + ), + ), + eventFromEventPusher( + iam.NewSMSConfigTwilioActivatedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + iam.NewSMSConfigDeactivatedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "IAM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := r.DeactivateSMSConfigTwilio(tt.args.ctx, tt.args.id) + 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.want, got) + } + }) + } +} + +func TestCommandSide_RemoveSMSConfigTwilio(t *testing.T) { + type fields struct { + eventstore *eventstore.Eventstore + } + type args struct { + ctx context.Context + id string + } + type res struct { + want *domain.ObjectDetails + err func(error) bool + } + tests := []struct { + name string + fields fields + args args + res res + }{ + { + name: "id empty, invalid error", + fields: fields{ + eventstore: eventstoreExpect( + t, + ), + }, + args: args{ + ctx: context.Background(), + }, + res: res{ + err: caos_errs.IsErrorInvalidArgument, + }, + }, + { + name: "sms not existing, not found error", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter(), + ), + }, + args: args{ + ctx: context.Background(), + id: "id", + }, + res: res{ + err: caos_errs.IsNotFound, + }, + }, + { + name: "sms config twilio remove, ok", + fields: fields{ + eventstore: eventstoreExpect( + t, + expectFilter( + eventFromEventPusher( + iam.NewSMSConfigTwilioAddedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + "sid", + "sender-name", + &crypto.CryptoValue{}, + ), + ), + ), + expectPush( + []*repository.Event{ + eventFromEventPusher( + iam.NewSMSConfigRemovedEvent( + context.Background(), + &iam.NewAggregate().Aggregate, + "providerid", + ), + ), + }, + ), + ), + }, + args: args{ + ctx: context.Background(), + id: "providerid", + }, + res: res{ + want: &domain.ObjectDetails{ + ResourceOwner: "IAM", + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := &Commands{ + eventstore: tt.fields.eventstore, + } + got, err := r.RemoveSMSConfigTwilio(tt.args.ctx, tt.args.id) + 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.want, got) + } + }) + } +} + +func newSMSConfigTwilioChangedEvent(ctx context.Context, id, sid, senderName string) *iam.SMSConfigTwilioChangedEvent { + changes := []iam.SMSConfigTwilioChanges{ + iam.ChangeSMSConfigTwilioSID(sid), + iam.ChangeSMSConfigTwilioSenderNumber(senderName), + } + event, _ := iam.NewSMSConfigTwilioChangedEvent(ctx, + &iam.NewAggregate().Aggregate, + id, + changes, + ) + return event +} diff --git a/internal/config/systemdefaults/system_defaults.go b/internal/config/systemdefaults/system_defaults.go index 6f2f1ba535..d6ba150f07 100644 --- a/internal/config/systemdefaults/system_defaults.go +++ b/internal/config/systemdefaults/system_defaults.go @@ -21,6 +21,7 @@ type SystemDefaults struct { UserVerificationKey *crypto.KeyConfig IDPConfigVerificationKey *crypto.KeyConfig SMTPPasswordVerificationKey *crypto.KeyConfig + SMSVerificationKey *crypto.KeyConfig Multifactors MultifactorConfig VerificationLifetimes VerificationLifetimes DomainVerification DomainVerification diff --git a/internal/domain/sms.go b/internal/domain/sms.go new file mode 100644 index 0000000000..f010c5e0d8 --- /dev/null +++ b/internal/domain/sms.go @@ -0,0 +1,14 @@ +package domain + +type SMSConfigState int32 + +const ( + SMSConfigStateUnspecified SMSConfigState = iota + SMSConfigStateActive + SMSConfigStateInactive + SMSConfigStateRemoved +) + +func (s SMSConfigState) Exists() bool { + return s != SMSConfigStateUnspecified && s != SMSConfigStateRemoved +} diff --git a/internal/notification/channels/twilio/config.go b/internal/notification/channels/twilio/config.go index ca389da383..d3f462d1b3 100644 --- a/internal/notification/channels/twilio/config.go +++ b/internal/notification/channels/twilio/config.go @@ -1,7 +1,11 @@ package twilio type TwilioConfig struct { - SID string - Token string - From string + SID string + Token string + SenderNumber string +} + +func (t *TwilioConfig) IsValid() bool { + return t.SID != "" && t.Token != "" && t.SenderNumber != "" } diff --git a/internal/notification/types/user_phone.go b/internal/notification/types/user_phone.go index 1d168f0345..55fdb7e5e4 100644 --- a/internal/notification/types/user_phone.go +++ b/internal/notification/types/user_phone.go @@ -9,7 +9,7 @@ import ( func generateSms(user *view_model.NotifyUser, content string, config systemdefaults.Notifications, lastPhone bool) error { message := &messages.SMS{ - SenderPhoneNumber: config.Providers.Twilio.From, + SenderPhoneNumber: config.Providers.Twilio.SenderNumber, RecipientPhoneNumber: user.VerifiedPhone, Content: content, } diff --git a/internal/query/projection/projection.go b/internal/query/projection/projection.go index 6004f7411a..ce41288725 100644 --- a/internal/query/projection/projection.go +++ b/internal/query/projection/projection.go @@ -71,6 +71,7 @@ func Start(ctx context.Context, sqlClient *sql.DB, es *eventstore.Eventstore, co NewIAMProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["iam"])) NewSecretGeneratorProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["secret_generators"])) NewSMTPConfigProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["smtp_configs"])) + NewSMSConfigProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["sms_config"])) _, err := NewKeyProjection(ctx, applyCustomConfig(projectionConfig, config.Customizations["keys"]), keyConfig, keyChan) return err diff --git a/internal/query/projection/sms.go b/internal/query/projection/sms.go new file mode 100644 index 0000000000..26ec120479 --- /dev/null +++ b/internal/query/projection/sms.go @@ -0,0 +1,195 @@ +package projection + +import ( + "context" + + "github.com/caos/logging" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/handler/crdb" + "github.com/caos/zitadel/internal/repository/iam" +) + +type SMSConfigProjection struct { + crdb.StatementHandler +} + +const ( + SMSConfigProjectionTable = "zitadel.projections.sms_configs" + SMSTwilioTable = SMSConfigProjectionTable + "_" + smsTwilioTableSuffix +) + +func NewSMSConfigProjection(ctx context.Context, config crdb.StatementHandlerConfig) *SMSConfigProjection { + p := &SMSConfigProjection{} + config.ProjectionName = SMSConfigProjectionTable + config.Reducers = p.reducers() + p.StatementHandler = crdb.NewStatementHandler(ctx, config) + return p +} + +func (p *SMSConfigProjection) reducers() []handler.AggregateReducer { + return []handler.AggregateReducer{ + { + Aggregate: iam.AggregateType, + EventRedusers: []handler.EventReducer{ + { + Event: iam.SMSConfigTwilioAddedEventType, + Reduce: p.reduceSMSConfigTwilioAdded, + }, + { + Event: iam.SMSConfigTwilioChangedEventType, + Reduce: p.reduceSMSConfigTwilioChanged, + }, + { + Event: iam.SMSConfigActivatedEventType, + Reduce: p.reduceSMSConfigActivated, + }, + { + Event: iam.SMSConfigDeactivatedEventType, + Reduce: p.reduceSMSConfigDeactivated, + }, + { + Event: iam.SMSConfigRemovedEventType, + Reduce: p.reduceSMSConfigRemoved, + }, + }, + }, + } +} + +const ( + SMSColumnID = "id" + SMSColumnAggregateID = "aggregate_id" + SMSColumnCreationDate = "creation_date" + SMSColumnChangeDate = "change_date" + SMSColumnResourceOwner = "resource_owner" + SMSColumnState = "state" + SMSColumnSequence = "sequence" + + smsTwilioTableSuffix = "twilio" + SMSTwilioConfigColumnSMSID = "sms_id" + SMSTwilioConfigColumnSID = "sid" + SMSTwilioConfigColumnToken = "token" + SMSTwilioConfigColumnSenderNumber = "sender_number" +) + +func (p *SMSConfigProjection) reduceSMSConfigTwilioAdded(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*iam.SMSConfigTwilioAddedEvent) + if !ok { + logging.LogWithFields("HANDL-9jiWf", "seq", event.Sequence(), "expectedType", iam.SMSConfigTwilioAddedEventType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-s8efs", "reduce.wrong.event.type") + } + + return crdb.NewMultiStatement( + e, + crdb.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMSColumnID, e.ID), + handler.NewCol(SMSColumnAggregateID, e.Aggregate().ID), + handler.NewCol(SMSColumnCreationDate, e.CreationDate()), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnResourceOwner, e.Aggregate().ResourceOwner), + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + ), + crdb.AddCreateStatement( + []handler.Column{ + handler.NewCol(SMSTwilioConfigColumnSMSID, e.ID), + handler.NewCol(SMSTwilioConfigColumnSID, e.SID), + handler.NewCol(SMSTwilioConfigColumnToken, e.Token), + handler.NewCol(SMSTwilioConfigColumnSenderNumber, e.SenderNumber), + }, + crdb.WithTableSuffix(smsTwilioTableSuffix), + ), + ), nil +} + +func (p *SMSConfigProjection) reduceSMSConfigTwilioChanged(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*iam.SMSConfigTwilioChangedEvent) + if !ok { + logging.LogWithFields("HANDL-fm9el", "seq", event.Sequence(), "expectedType", iam.SMSConfigTwilioChangedEventType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-fi99F", "reduce.wrong.event.type") + } + columns := make([]handler.Column, 0) + if e.SID != nil { + columns = append(columns, handler.NewCol(SMSTwilioConfigColumnSID, e.SID)) + } + if e.SenderNumber != nil { + columns = append(columns, handler.NewCol(SMSTwilioConfigColumnSenderNumber, e.SenderNumber)) + } + + return crdb.NewMultiStatement( + e, + crdb.AddUpdateStatement( + columns, + []handler.Condition{ + handler.NewCond(SMSTwilioConfigColumnSMSID, e.ID), + }, + crdb.WithTableSuffix(smsTwilioTableSuffix), + ), + crdb.AddUpdateStatement( + []handler.Column{ + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + }, + ), + ), nil +} + +func (p *SMSConfigProjection) reduceSMSConfigActivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*iam.SMSConfigActivatedEvent) + if !ok { + logging.LogWithFields("HANDL-fm03F", "seq", event.Sequence(), "expectedType", iam.SMSConfigActivatedEventType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-fj9Ef", "reduce.wrong.event.type") + } + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateActive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + }, + ), nil +} + +func (p *SMSConfigProjection) reduceSMSConfigDeactivated(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*iam.SMSConfigDeactivatedEvent) + if !ok { + logging.LogWithFields("HANDL-9fnHS", "seq", event.Sequence(), "expectedType", iam.SMSConfigDeactivatedEventType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-dj9Js", "reduce.wrong.event.type") + } + return crdb.NewUpdateStatement( + e, + []handler.Column{ + handler.NewCol(SMSColumnState, domain.SMSConfigStateInactive), + handler.NewCol(SMSColumnChangeDate, e.CreationDate()), + handler.NewCol(SMSColumnSequence, e.Sequence()), + }, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + }, + ), nil +} + +func (p *SMSConfigProjection) reduceSMSConfigRemoved(event eventstore.Event) (*handler.Statement, error) { + e, ok := event.(*iam.SMSConfigRemovedEvent) + if !ok { + logging.LogWithFields("HANDL-0Opew", "seq", event.Sequence(), "expectedType", iam.SMSConfigRemovedEventType).Error("wrong event type") + return nil, errors.ThrowInvalidArgument(nil, "HANDL-s9JJf", "reduce.wrong.event.type") + } + return crdb.NewDeleteStatement( + e, + []handler.Condition{ + handler.NewCond(SMSColumnID, e.ID), + }, + ), nil +} diff --git a/internal/query/projection/sms_test.go b/internal/query/projection/sms_test.go new file mode 100644 index 0000000000..e02080888e --- /dev/null +++ b/internal/query/projection/sms_test.go @@ -0,0 +1,229 @@ +package projection + +import ( + "testing" + + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/handler" + "github.com/caos/zitadel/internal/eventstore/repository" + "github.com/caos/zitadel/internal/repository/iam" +) + +var ( + sid = "sid" + token = "token" + senderNumber = "sender-number" +) + +func TestSMSProjection_reduces(t *testing.T) { + type args struct { + event func(t *testing.T) eventstore.Event + } + tests := []struct { + name string + args args + reduce func(event eventstore.Event) (*handler.Statement, error) + want wantReduce + }{ + { + name: "iam.reduceSMSTwilioAdded", + args: args{ + event: getEvent(testEvent( + repository.EventType(iam.SMSConfigTwilioAddedEventType), + iam.AggregateType, + []byte(`{ + "id": "id", + "sid": "sid", + "token": { + "cryptoType": 0, + "algorithm": "RSA-265", + "keyId": "key-id" + }, + "senderNumber": "sender-number" + }`), + ), iam.SMSConfigTwilioAddedEventMapper), + }, + reduce: (&SMSConfigProjection{}).reduceSMSConfigTwilioAdded, + want: wantReduce{ + aggregateType: eventstore.AggregateType("iam"), + sequence: 15, + previousSequence: 10, + projection: SMSConfigProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "INSERT INTO zitadel.projections.sms_configs (id, aggregate_id, creation_date, change_date, resource_owner, state, sequence) VALUES ($1, $2, $3, $4, $5, $6, $7)", + expectedArgs: []interface{}{ + "id", + "agg-id", + anyArg{}, + anyArg{}, + "ro-id", + domain.SMSConfigStateInactive, + uint64(15), + }, + }, + { + expectedStmt: "INSERT INTO zitadel.projections.sms_configs_twilio (sms_id, sid, token, sender_number) VALUES ($1, $2, $3, $4)", + expectedArgs: []interface{}{ + "id", + "sid", + anyArg{}, + "sender-number", + }, + }, + }, + }, + }, + }, + { + name: "iam.reduceSMSConfigTwilioChanged", + args: args{ + event: getEvent(testEvent( + repository.EventType(iam.SMSConfigTwilioChangedEventType), + iam.AggregateType, + []byte(`{ + "id": "id", + "sid": "sid", + "senderNumber": "sender-number" + }`), + ), iam.SMSConfigTwilioChangedEventMapper), + }, + reduce: (&SMSConfigProjection{}).reduceSMSConfigTwilioChanged, + want: wantReduce{ + aggregateType: eventstore.AggregateType("iam"), + sequence: 15, + previousSequence: 10, + projection: SMSConfigProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.sms_configs_twilio SET (sid, sender_number) = ($1, $2) WHERE (sms_id = $3)", + expectedArgs: []interface{}{ + &sid, + &senderNumber, + "id", + }, + }, + { + expectedStmt: "UPDATE zitadel.projections.sms_configs SET (change_date, sequence) = ($1, $2) WHERE (id = $3)", + expectedArgs: []interface{}{ + anyArg{}, + uint64(15), + "id", + }, + }, + }, + }, + }, + }, + { + name: "iam.reduceSMSConfigActivated", + args: args{ + event: getEvent(testEvent( + repository.EventType(iam.SMSConfigActivatedEventType), + iam.AggregateType, + []byte(`{ + "id": "id" + }`), + ), iam.SMSConfigActivatedEventMapper), + }, + reduce: (&SMSConfigProjection{}).reduceSMSConfigActivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("iam"), + sequence: 15, + previousSequence: 10, + projection: SMSConfigProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.sms_configs SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + domain.SMSConfigStateActive, + anyArg{}, + uint64(15), + "id", + }, + }, + }, + }, + }, + }, + { + name: "iam.reduceSMSConfigDeactivated", + args: args{ + event: getEvent(testEvent( + repository.EventType(iam.SMSConfigDeactivatedEventType), + iam.AggregateType, + []byte(`{ + "id": "id" + }`), + ), iam.SMSConfigDeactivatedEventMapper), + }, + reduce: (&SMSConfigProjection{}).reduceSMSConfigDeactivated, + want: wantReduce{ + aggregateType: eventstore.AggregateType("iam"), + sequence: 15, + previousSequence: 10, + projection: SMSConfigProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "UPDATE zitadel.projections.sms_configs SET (state, change_date, sequence) = ($1, $2, $3) WHERE (id = $4)", + expectedArgs: []interface{}{ + domain.SMSConfigStateInactive, + anyArg{}, + uint64(15), + "id", + }, + }, + }, + }, + }, + }, + { + name: "iam.reduceSMSConfigRemoved", + args: args{ + event: getEvent(testEvent( + repository.EventType(iam.SMSConfigRemovedEventType), + iam.AggregateType, + []byte(`{ + "id": "id" + }`), + ), iam.SMSConfigRemovedEventMapper), + }, + reduce: (&SMSConfigProjection{}).reduceSMSConfigRemoved, + want: wantReduce{ + aggregateType: eventstore.AggregateType("iam"), + sequence: 15, + previousSequence: 10, + projection: SMSConfigProjectionTable, + executer: &testExecuter{ + executions: []execution{ + { + expectedStmt: "DELETE FROM zitadel.projections.sms_configs WHERE (id = $1)", + expectedArgs: []interface{}{ + "id", + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + event := baseEvent(t) + got, err := tt.reduce(event) + if _, ok := err.(errors.InvalidArgument); !ok { + t.Errorf("no wrong event mapping: %v, got: %v", err, got) + } + + event = tt.args.event(t) + got, err = tt.reduce(event) + assertReduce(t, got, err, tt.want) + }) + } +} diff --git a/internal/query/sms.go b/internal/query/sms.go new file mode 100644 index 0000000000..ab7a1ae163 --- /dev/null +++ b/internal/query/sms.go @@ -0,0 +1,264 @@ +package query + +import ( + "context" + "database/sql" + errs "errors" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/query/projection" +) + +type SMSConfigs struct { + SearchResponse + Configs []*SMSConfig +} + +type SMSConfig struct { + AggregateID string + ID string + CreationDate time.Time + ChangeDate time.Time + ResourceOwner string + State domain.SMSConfigState + Sequence uint64 + + TwilioConfig *Twilio +} + +type Twilio struct { + SID string + Token *crypto.CryptoValue + SenderNumber string +} + +type SMSConfigsSearchQueries struct { + SearchRequest + Queries []SearchQuery +} + +func (q *SMSConfigsSearchQueries) toQuery(query sq.SelectBuilder) sq.SelectBuilder { + query = q.SearchRequest.toQuery(query) + for _, q := range q.Queries { + query = q.toQuery(query) + } + return query +} + +var ( + smsConfigsTable = table{ + name: projection.SMSConfigProjectionTable, + } + SMSConfigColumnID = Column{ + name: projection.SMSColumnID, + table: smsConfigsTable, + } + SMSConfigColumnAggregateID = Column{ + name: projection.SMSColumnAggregateID, + table: smsConfigsTable, + } + SMSConfigColumnCreationDate = Column{ + name: projection.SMSColumnCreationDate, + table: smsConfigsTable, + } + SMSConfigColumnChangeDate = Column{ + name: projection.SMSColumnChangeDate, + table: smsConfigsTable, + } + SMSConfigColumnResourceOwner = Column{ + name: projection.SMSColumnResourceOwner, + table: smsConfigsTable, + } + SMSConfigColumnState = Column{ + name: projection.SMSColumnState, + table: smsConfigsTable, + } + SMSConfigColumnSequence = Column{ + name: projection.SMSColumnSequence, + table: smsConfigsTable, + } +) + +var ( + smsTwilioConfigsTable = table{ + name: projection.SMSTwilioTable, + } + SMSTwilioConfigColumnSMSID = Column{ + name: projection.SMSTwilioConfigColumnSMSID, + table: smsTwilioConfigsTable, + } + SMSTwilioConfigColumnSID = Column{ + name: projection.SMSTwilioConfigColumnSID, + table: smsTwilioConfigsTable, + } + SMSTwilioConfigColumnToken = Column{ + name: projection.SMSTwilioConfigColumnToken, + table: smsTwilioConfigsTable, + } + SMSTwilioConfigColumnSenderNumber = Column{ + name: projection.SMSTwilioConfigColumnSenderNumber, + table: smsTwilioConfigsTable, + } +) + +func (q *Queries) SMSProviderConfigByID(ctx context.Context, id string) (*SMSConfig, error) { + stmt, scan := prepareSMSConfigQuery() + query, args, err := stmt.Where( + sq.Eq{ + SMSConfigColumnID.identifier(): id, + }, + ).ToSql() + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-dn9JW", "Errors.Query.SQLStatement") + } + + row := q.client.QueryRowContext(ctx, query, args...) + return scan(row) +} + +func (q *Queries) SearchSMSConfigs(ctx context.Context, queries *SMSConfigsSearchQueries) (*SMSConfigs, error) { + query, scan := prepareSMSConfigsQuery() + stmt, args, err := queries.toQuery(query).ToSql() + if err != nil { + return nil, errors.ThrowInvalidArgument(err, "QUERY-sn9Jf", "Errors.Query.InvalidRequest") + } + + rows, err := q.client.QueryContext(ctx, stmt, args...) + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-aJnZL", "Errors.Internal") + } + apps, err := scan(rows) + if err != nil { + return nil, err + } + apps.LatestSequence, err = q.latestSequence(ctx, smsConfigsTable) + return apps, err +} + +func prepareSMSConfigQuery() (sq.SelectBuilder, func(*sql.Row) (*SMSConfig, error)) { + return sq.Select( + SMSConfigColumnID.identifier(), + SMSConfigColumnAggregateID.identifier(), + SMSConfigColumnCreationDate.identifier(), + SMSConfigColumnChangeDate.identifier(), + SMSConfigColumnResourceOwner.identifier(), + SMSConfigColumnState.identifier(), + SMSConfigColumnSequence.identifier(), + + SMSTwilioConfigColumnSMSID.identifier(), + SMSTwilioConfigColumnSID.identifier(), + SMSTwilioConfigColumnToken.identifier(), + SMSTwilioConfigColumnSenderNumber.identifier(), + ).From(smsConfigsTable.identifier()). + LeftJoin(join(SMSTwilioConfigColumnSMSID, SMSConfigColumnID)). + PlaceholderFormat(sq.Dollar), func(row *sql.Row) (*SMSConfig, error) { + config := new(SMSConfig) + + var ( + twilioConfig = sqlTwilioConfig{} + ) + + err := row.Scan( + &config.ID, + &config.AggregateID, + &config.CreationDate, + &config.ChangeDate, + &config.ResourceOwner, + &config.State, + &config.Sequence, + + &twilioConfig.smsID, + &twilioConfig.sid, + &twilioConfig.token, + &twilioConfig.senderNumber, + ) + + if err != nil { + if errs.Is(err, sql.ErrNoRows) { + return nil, errors.ThrowNotFound(err, "QUERY-fn99w", "Errors.SMSConfig.NotExisting") + } + return nil, errors.ThrowInternal(err, "QUERY-3n9Js", "Errors.Internal") + } + + twilioConfig.set(config) + + return config, nil + } +} + +func prepareSMSConfigsQuery() (sq.SelectBuilder, func(*sql.Rows) (*SMSConfigs, error)) { + return sq.Select( + SMSConfigColumnID.identifier(), + SMSConfigColumnAggregateID.identifier(), + SMSConfigColumnCreationDate.identifier(), + SMSConfigColumnChangeDate.identifier(), + SMSConfigColumnResourceOwner.identifier(), + SMSConfigColumnState.identifier(), + SMSConfigColumnSequence.identifier(), + + SMSTwilioConfigColumnSMSID.identifier(), + SMSTwilioConfigColumnSID.identifier(), + SMSTwilioConfigColumnToken.identifier(), + SMSTwilioConfigColumnSenderNumber.identifier(), + countColumn.identifier(), + ).From(smsConfigsTable.identifier()). + LeftJoin(join(SMSTwilioConfigColumnSMSID, SMSConfigColumnID)). + PlaceholderFormat(sq.Dollar), func(row *sql.Rows) (*SMSConfigs, error) { + configs := &SMSConfigs{Configs: []*SMSConfig{}} + + for row.Next() { + config := new(SMSConfig) + var ( + twilioConfig = sqlTwilioConfig{} + ) + + err := row.Scan( + &config.ID, + &config.AggregateID, + &config.CreationDate, + &config.ChangeDate, + &config.ResourceOwner, + &config.State, + &config.Sequence, + + &twilioConfig.smsID, + &twilioConfig.sid, + &twilioConfig.token, + &twilioConfig.senderNumber, + &configs.Count, + ) + + if err != nil { + return nil, errors.ThrowInternal(err, "QUERY-d9jJd", "Errors.Internal") + } + + twilioConfig.set(config) + + configs.Configs = append(configs.Configs, config) + } + + return configs, nil + } +} + +type sqlTwilioConfig struct { + smsID sql.NullString + sid sql.NullString + token *crypto.CryptoValue + senderNumber sql.NullString +} + +func (c sqlTwilioConfig) set(smsConfig *SMSConfig) { + if !c.smsID.Valid { + return + } + smsConfig.TwilioConfig = &Twilio{ + SID: c.sid.String, + Token: c.token, + SenderNumber: c.senderNumber.String, + } +} diff --git a/internal/query/sms_test.go b/internal/query/sms_test.go new file mode 100644 index 0000000000..8e155d9cdb --- /dev/null +++ b/internal/query/sms_test.go @@ -0,0 +1,326 @@ +package query + +import ( + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "regexp" + "testing" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/domain" + errs "github.com/caos/zitadel/internal/errors" +) + +var ( + expectedSMSConfigQuery = regexp.QuoteMeta(`SELECT zitadel.projections.sms_configs.id,` + + ` zitadel.projections.sms_configs.aggregate_id,` + + ` zitadel.projections.sms_configs.creation_date,` + + ` zitadel.projections.sms_configs.change_date,` + + ` zitadel.projections.sms_configs.resource_owner,` + + ` zitadel.projections.sms_configs.state,` + + ` zitadel.projections.sms_configs.sequence,` + + + // twilio config + ` zitadel.projections.sms_configs_twilio.sms_id,` + + ` zitadel.projections.sms_configs_twilio.sid,` + + ` zitadel.projections.sms_configs_twilio.token,` + + ` zitadel.projections.sms_configs_twilio.sender_number` + + ` FROM zitadel.projections.sms_configs` + + ` LEFT JOIN zitadel.projections.sms_configs_twilio ON zitadel.projections.sms_configs.id = zitadel.projections.sms_configs_twilio.sms_id`) + expectedSMSConfigsQuery = regexp.QuoteMeta(`SELECT zitadel.projections.sms_configs.id,` + + ` zitadel.projections.sms_configs.aggregate_id,` + + ` zitadel.projections.sms_configs.creation_date,` + + ` zitadel.projections.sms_configs.change_date,` + + ` zitadel.projections.sms_configs.resource_owner,` + + ` zitadel.projections.sms_configs.state,` + + ` zitadel.projections.sms_configs.sequence,` + + + // twilio config + ` zitadel.projections.sms_configs_twilio.sms_id,` + + ` zitadel.projections.sms_configs_twilio.sid,` + + ` zitadel.projections.sms_configs_twilio.token,` + + ` zitadel.projections.sms_configs_twilio.sender_number,` + + ` COUNT(*) OVER ()` + + ` FROM zitadel.projections.sms_configs` + + ` LEFT JOIN zitadel.projections.sms_configs_twilio ON zitadel.projections.sms_configs.id = zitadel.projections.sms_configs_twilio.sms_id`) + + smsConfigCols = []string{ + "id", + "aggregate_id", + "creation_date", + "change_date", + "resource_owner", + "state", + "sequence", + // twilio config + "sms_id", + "sid", + "token", + "sender-number", + } + smsConfigsCols = append(smsConfigCols, "count") +) + +func Test_SMSConfigssPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSMSConfigsQuery no result", + prepare: prepareSMSConfigsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSMSConfigsQuery, + nil, + nil, + ), + }, + object: &SMSConfigs{Configs: []*SMSConfig{}}, + }, + { + name: "prepareSMSQuery twilio config", + prepare: prepareSMSConfigsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSMSConfigsQuery, + smsConfigsCols, + [][]driver.Value{ + { + "sms-id", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + // twilio config + "sms-id", + "sid", + &crypto.CryptoValue{}, + "sender-number", + }, + }, + ), + }, + object: &SMSConfigs{ + SearchResponse: SearchResponse{ + Count: 1, + }, + Configs: []*SMSConfig{ + { + ID: "sms-id", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + TwilioConfig: &Twilio{ + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + }, + }, + }, + }, + }, + { + name: "prepareSMSConfigsQuery multiple result", + prepare: prepareSMSConfigsQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSMSConfigsQuery, + smsConfigsCols, + [][]driver.Value{ + { + "sms-id", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + // twilio config + "sms-id", + "sid", + &crypto.CryptoValue{}, + "sender-number", + }, + { + "sms-id2", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + // twilio config + "sms-id2", + "sid2", + &crypto.CryptoValue{}, + "sender-number2", + }, + }, + ), + }, + object: &SMSConfigs{ + SearchResponse: SearchResponse{ + Count: 2, + }, + Configs: []*SMSConfig{ + { + ID: "sms-id", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + TwilioConfig: &Twilio{ + SID: "sid", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number", + }, + }, + { + ID: "sms-id2", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + TwilioConfig: &Twilio{ + SID: "sid2", + Token: &crypto.CryptoValue{}, + SenderNumber: "sender-number2", + }, + }, + }, + }, + }, + { + name: "prepareSMSConfigsQuery sql err", + prepare: prepareSMSConfigsQuery, + want: want{ + sqlExpectations: mockQueryErr( + expectedSMSConfigsQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) + }) + } +} + +func Test_SMSConfigPrepare(t *testing.T) { + type want struct { + sqlExpectations sqlExpectation + err checkErr + } + tests := []struct { + name string + prepare interface{} + want want + object interface{} + }{ + { + name: "prepareSMSConfigQuery no result", + prepare: prepareSMSConfigQuery, + want: want{ + sqlExpectations: mockQueries( + expectedSMSConfigQuery, + nil, + nil, + ), + err: func(err error) (error, bool) { + if !errs.IsNotFound(err) { + return fmt.Errorf("err should be zitadel.NotFoundError got: %w", err), false + } + return nil, true + }, + }, + object: (*SMSConfig)(nil), + }, + { + name: "prepareSMSConfigQuery found", + prepare: prepareSMSConfigQuery, + want: want{ + sqlExpectations: mockQuery( + expectedSMSConfigQuery, + smsConfigCols, + []driver.Value{ + "sms-id", + "agg-id", + testNow, + testNow, + "ro", + domain.SMSConfigStateInactive, + uint64(20211109), + // twilio config + "sms-id", + "sid", + &crypto.CryptoValue{}, + "sender-number", + }, + ), + }, + object: &SMSConfig{ + ID: "sms-id", + AggregateID: "agg-id", + CreationDate: testNow, + ChangeDate: testNow, + ResourceOwner: "ro", + State: domain.SMSConfigStateInactive, + Sequence: 20211109, + TwilioConfig: &Twilio{ + SID: "sid", + SenderNumber: "sender-number", + Token: &crypto.CryptoValue{}, + }, + }, + }, + { + name: "prepareSMSConfigQuery sql err", + prepare: prepareSMSConfigQuery, + want: want{ + sqlExpectations: mockQueryErr( + expectedSMSConfigQuery, + sql.ErrConnDone, + ), + err: func(err error) (error, bool) { + if !errors.Is(err, sql.ErrConnDone) { + return fmt.Errorf("err should be sql.ErrConnDone got: %w", err), false + } + return nil, true + }, + }, + object: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assertPrepare(t, tt.prepare, tt.object, tt.want.sqlExpectations, tt.want.err) + }) + } +} diff --git a/internal/repository/iam/eventstore.go b/internal/repository/iam/eventstore.go index 764cf4cc94..f0d1cabb6d 100644 --- a/internal/repository/iam/eventstore.go +++ b/internal/repository/iam/eventstore.go @@ -17,6 +17,12 @@ func RegisterEventMappers(es *eventstore.Eventstore) { RegisterFilterEventMapper(SMTPConfigChangedEventType, SMTPConfigChangedEventMapper). RegisterFilterEventMapper(SMTPConfigPasswordChangedEventType, SMTPConfigPasswordChangedEventMapper). RegisterFilterEventMapper(UniqueConstraintsMigratedEventType, MigrateUniqueConstraintEventMapper). + RegisterFilterEventMapper(SMSConfigTwilioAddedEventType, SMSConfigTwilioAddedEventMapper). + RegisterFilterEventMapper(SMSConfigTwilioChangedEventType, SMSConfigTwilioChangedEventMapper). + RegisterFilterEventMapper(SMSConfigTwilioTokenChangedEventType, SMSConfigTwilioTokenChangedEventMapper). + RegisterFilterEventMapper(SMSConfigActivatedEventType, SMSConfigActivatedEventMapper). + RegisterFilterEventMapper(SMSConfigDeactivatedEventType, SMSConfigDeactivatedEventMapper). + RegisterFilterEventMapper(SMSConfigRemovedEventType, SMSConfigRemovedEventMapper). RegisterFilterEventMapper(LabelPolicyAddedEventType, LabelPolicyAddedEventMapper). RegisterFilterEventMapper(LabelPolicyChangedEventType, LabelPolicyChangedEventMapper). RegisterFilterEventMapper(LabelPolicyActivatedEventType, LabelPolicyActivatedEventMapper). diff --git a/internal/repository/iam/sms.go b/internal/repository/iam/sms.go new file mode 100644 index 0000000000..c7f4dfd247 --- /dev/null +++ b/internal/repository/iam/sms.go @@ -0,0 +1,301 @@ +package iam + +import ( + "context" + "encoding/json" + + "github.com/caos/zitadel/internal/crypto" + "github.com/caos/zitadel/internal/errors" + "github.com/caos/zitadel/internal/eventstore" + "github.com/caos/zitadel/internal/eventstore/repository" +) + +const ( + smsConfigPrefix = "sms.config" + smsConfigTwilioPrefix = "twilio." + SMSConfigTwilioAddedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "added" + SMSConfigTwilioChangedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "changed" + SMSConfigTwilioTokenChangedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "token.changed" + SMSConfigActivatedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "activated" + SMSConfigDeactivatedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "deactivated" + SMSConfigRemovedEventType = iamEventTypePrefix + smsConfigPrefix + smsConfigTwilioPrefix + "removed" +) + +type SMSConfigTwilioAddedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + SID string `json:"sid,omitempty"` + Token *crypto.CryptoValue `json:"token,omitempty"` + SenderNumber string `json:"senderNumber,omitempty"` +} + +func NewSMSConfigTwilioAddedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id, + sid, + senderNumber string, + token *crypto.CryptoValue, +) *SMSConfigTwilioAddedEvent { + return &SMSConfigTwilioAddedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigTwilioAddedEventType, + ), + ID: id, + SID: sid, + Token: token, + SenderNumber: senderNumber, + } +} + +func (e *SMSConfigTwilioAddedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigTwilioAddedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigTwilioAddedEventMapper(event *repository.Event) (eventstore.Event, error) { + smsConfigAdded := &SMSConfigTwilioAddedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smsConfigAdded) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-smwiR", "unable to unmarshal sms config twilio added") + } + + return smsConfigAdded, nil +} + +type SMSConfigTwilioChangedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + SID *string `json:"sid,omitempty"` + SenderNumber *string `json:"senderNumber,omitempty"` +} + +func NewSMSConfigTwilioChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + changes []SMSConfigTwilioChanges, +) (*SMSConfigTwilioChangedEvent, error) { + if len(changes) == 0 { + return nil, errors.ThrowPreconditionFailed(nil, "IAM-smn8e", "Errors.NoChangesFound") + } + changeEvent := &SMSConfigTwilioChangedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigTwilioChangedEventType, + ), + ID: id, + } + for _, change := range changes { + change(changeEvent) + } + return changeEvent, nil +} + +type SMSConfigTwilioChanges func(event *SMSConfigTwilioChangedEvent) + +func ChangeSMSConfigTwilioSID(sid string) func(event *SMSConfigTwilioChangedEvent) { + return func(e *SMSConfigTwilioChangedEvent) { + e.SID = &sid + } +} + +func ChangeSMSConfigTwilioSenderNumber(senderNumber string) func(event *SMSConfigTwilioChangedEvent) { + return func(e *SMSConfigTwilioChangedEvent) { + e.SenderNumber = &senderNumber + } +} + +func (e *SMSConfigTwilioChangedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigTwilioChangedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigTwilioChangedEventMapper(event *repository.Event) (eventstore.Event, error) { + smsConfigChanged := &SMSConfigTwilioChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smsConfigChanged) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-smwiR", "unable to unmarshal sms config twilio added") + } + + return smsConfigChanged, nil +} + +type SMSConfigTwilioTokenChangedEvent struct { + eventstore.BaseEvent `json:"-"` + + ID string `json:"id,omitempty"` + Token *crypto.CryptoValue `json:"password,omitempty"` +} + +func NewSMSConfigTokenChangedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, + token *crypto.CryptoValue, +) *SMSConfigTwilioTokenChangedEvent { + return &SMSConfigTwilioTokenChangedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigTwilioTokenChangedEventType, + ), + ID: id, + Token: token, + } +} + +func (e *SMSConfigTwilioTokenChangedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigTwilioTokenChangedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigTwilioTokenChangedEventMapper(event *repository.Event) (eventstore.Event, error) { + smtpConfigTokenChagned := &SMSConfigTwilioTokenChangedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smtpConfigTokenChagned) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-fi9Wf", "unable to unmarshal sms config token changed") + } + + return smtpConfigTokenChagned, nil +} + +type SMSConfigActivatedEvent struct { + eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} + +func NewSMSConfigTwilioActivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, +) *SMSConfigActivatedEvent { + return &SMSConfigActivatedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigActivatedEventType, + ), + ID: id, + } +} + +func (e *SMSConfigActivatedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigActivatedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigActivatedEventMapper(event *repository.Event) (eventstore.Event, error) { + smsConfigActivated := &SMSConfigActivatedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smsConfigActivated) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-dn92f", "unable to unmarshal sms config twilio activated changed") + } + + return smsConfigActivated, nil +} + +type SMSConfigDeactivatedEvent struct { + eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} + +func NewSMSConfigDeactivatedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, +) *SMSConfigDeactivatedEvent { + return &SMSConfigDeactivatedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigDeactivatedEventType, + ), + ID: id, + } +} + +func (e *SMSConfigDeactivatedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigDeactivatedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigDeactivatedEventMapper(event *repository.Event) (eventstore.Event, error) { + smsConfigDeactivated := &SMSConfigDeactivatedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smsConfigDeactivated) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-dn92f", "unable to unmarshal sms config twilio deactivated changed") + } + + return smsConfigDeactivated, nil +} + +type SMSConfigRemovedEvent struct { + eventstore.BaseEvent `json:"-"` + ID string `json:"id,omitempty"` +} + +func NewSMSConfigRemovedEvent( + ctx context.Context, + aggregate *eventstore.Aggregate, + id string, +) *SMSConfigRemovedEvent { + return &SMSConfigRemovedEvent{ + BaseEvent: *eventstore.NewBaseEventForPush( + ctx, + aggregate, + SMSConfigRemovedEventType, + ), + ID: id, + } +} + +func (e *SMSConfigRemovedEvent) Data() interface{} { + return e +} + +func (e *SMSConfigRemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint { + return nil +} + +func SMSConfigRemovedEventMapper(event *repository.Event) (eventstore.Event, error) { + smsConfigRemoved := &SMSConfigRemovedEvent{ + BaseEvent: *eventstore.BaseEventFromRepo(event), + } + err := json.Unmarshal(event.Data, smsConfigRemoved) + if err != nil { + return nil, errors.ThrowInternal(err, "IAM-99iNF", "unable to unmarshal sms config removed") + } + + return smsConfigRemoved, nil +} diff --git a/migrations/cockroach/V1.111__settings.sql b/migrations/cockroach/V1.111__settings.sql index 3a5de4368a..ec486960d3 100644 --- a/migrations/cockroach/V1.111__settings.sql +++ b/migrations/cockroach/V1.111__settings.sql @@ -27,10 +27,31 @@ CREATE TABLE zitadel.projections.smtp_configs ( , tls BOOLEAN NOT NULL , sender_address STRING NOT NULL - , sender_name STRING NOT NULL + , sender_number STRING NOT NULL , host STRING NOT NULL , username STRING NOT NULL DEFAULT '' , password JSONB , PRIMARY KEY (aggregate_id) ); + +CREATE TABLE zitadel.projections.sms_configs ( + id STRING NOT NULL + ,aggregate_id STRING NOT NULL + , creation_date TIMESTAMPTZ NOT NULL + , change_date TIMESTAMPTZ NOT NULL + , resource_owner STRING NOT NULL + , sequence INT8 NOT NULL + , state INT2 + + , PRIMARY KEY (id) +); + +CREATE TABLE zitadel.projections.sms_configs_twilio ( + sms_id STRING NOT NULL + ,sid STRING NOT NULL + ,sender_name STRING NOT NULL + ,token JSONB + + , PRIMARY KEY (sms_id) +); diff --git a/pkg/grpc/settings/settings.go b/pkg/grpc/settings/settings.go new file mode 100644 index 0000000000..32b6d83125 --- /dev/null +++ b/pkg/grpc/settings/settings.go @@ -0,0 +1,3 @@ +package settings + +type SMSConfig = isSMSProvider_Config diff --git a/proto/zitadel/admin.proto b/proto/zitadel/admin.proto index daedc780ce..0e3dc4cd36 100644 --- a/proto/zitadel/admin.proto +++ b/proto/zitadel/admin.proto @@ -254,6 +254,66 @@ service AdminService { }; } + // list sms provider configurations + rpc ListSMSProviders(ListSMSProvidersRequest) returns (ListSMSProvidersResponse) { + option (google.api.http) = { + post: "/sms/_search" + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + } + + // Get sms provider + rpc GetSMSProvider(GetSMSProviderRequest) returns (GetSMSProviderResponse) { + option (google.api.http) = { + get: "/sms/{id}"; + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.read"; + }; + } + + // Add twilio sms provider + rpc AddSMSProviderTwilio(AddSMSProviderTwilioRequest) returns (AddSMSProviderTwilioResponse) { + option (google.api.http) = { + post: "/sms/twilio"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + } + + // Update twilio sms provider + rpc UpdateSMSProviderTwilio(UpdateSMSProviderTwilioRequest) returns (UpdateSMSProviderTwilioResponse) { + option (google.api.http) = { + put: "/sms/twilio/{id}"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + } + + // Update twilio sms provider token + rpc UpdateSMSProviderTwilioToken(UpdateSMSProviderTwilioTokenRequest) returns (UpdateSMSProviderTwilioTokenResponse) { + option (google.api.http) = { + put: "/sms/twilio/{id}/token"; + body: "*" + }; + + option (zitadel.v1.auth_option) = { + permission: "iam.write"; + }; + } + + // Returns an organisation by id rpc GetOrgByID(GetOrgByIDRequest) returns (GetOrgByIDResponse) { option (google.api.http) = { @@ -2431,6 +2491,54 @@ message UpdateSMTPConfigPasswordResponse { zitadel.v1.ObjectDetails details = 1; } +message ListSMSProvidersRequest { + //list limitations and ordering + zitadel.v1.ListQuery query = 1; +} + +message ListSMSProvidersResponse { + zitadel.v1.ListDetails details = 1; + repeated zitadel.settings.v1.SMSProvider result = 3; +} + +message GetSMSProviderRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 100}]; +} + +message GetSMSProviderResponse { + zitadel.settings.v1.SMSProvider config = 1; +} + +message AddSMSProviderTwilioRequest { + string sid = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string token = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string sender_number = 3 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message AddSMSProviderTwilioResponse { + zitadel.v1.ObjectDetails details = 1; + string id = 2; +} + +message UpdateSMSProviderTwilioRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string sid = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string sender_number = 3 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message UpdateSMSProviderTwilioResponse { + zitadel.v1.ObjectDetails details = 1; +} + +message UpdateSMSProviderTwilioTokenRequest { + string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}]; + string token = 2 [(validate.rules).string = {min_len: 1, max_len: 200}]; +} + +message UpdateSMSProviderTwilioTokenResponse { + zitadel.v1.ObjectDetails details = 1; +} + // if name or domain is already in use, org is not unique message IsOrgUniqueRequest { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = { diff --git a/proto/zitadel/settings.proto b/proto/zitadel/settings.proto index 81de52fd96..56cf4778b1 100644 --- a/proto/zitadel/settings.proto +++ b/proto/zitadel/settings.proto @@ -51,3 +51,25 @@ message SMTPConfig { string host = 5; string user = 6; } + +message SMSProvider { + zitadel.v1.ObjectDetails details = 1; + string id = 2; + SMSProviderConfigState state = 3; + + oneof config { + TwilioConfig twilio = 4; + } +} + +message TwilioConfig { + string sid = 1; + string sender_number = 2; +} + + +enum SMSProviderConfigState { + SMS_PROVIDER_CONFIG_STATE_UNSPECIFIED = 0; + SMS_PROVIDER_CONFIG_ACTIVE = 1; + SMS_PROVIDER_CONFIG_INACTIVE = 2; +}