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;
+}