diff --git a/README.md b/README.md
index 8c88aec09..c1dba4cb3 100644
--- a/README.md
+++ b/README.md
@@ -49,13 +49,20 @@ This is the Admin UI for the Canonical Identity Platform.
middleware is enabled default to `true`
- `AUTHENTICATION_ENABLED`: flag defining if the OAuth authentication middleware
is enabled, default to `false`
-- `OIDC_ISSUER`: URL of the OIDC provider
+- `OIDC_ISSUER`: URL of the OIDC provider
- `OAUTH2_CLIENT_ID`: OAuth2 client ID used for authentication purposes
- `OAUTH2_CLIENT_SECRET`: OAuth2 client secret used for authentication purposes
- `OAUTH2_REDIRECT_URI`: URI used by the Oauth2 provider for the redirecting callback
- `OAUTH2_CODEGRANT_SCOPES`: OAuth2 scopes, defaults to `openid,offline_access`
- `OAUTH2_AUTH_COOKIES_ENCRYPTION_KEY`: 32 bytes string used for encrypting cookies
- `ACCESS_TOKEN_VERIFICATION_STRATEGY`: OAuth2 verification startegy, one of `jwks` or `userinfo``
+- `MAIL_HOST`: host of the mail server (required)
+- `MAIL_PORT`: port exposed by the mail server (required)
+- `MAIL_USERNAME`: username to use for the simple authentication on the mail server (if present, both username and
+ password are used)
+- `MAIL_PASSWORD`: password to use for the simple authentication on the mail server
+- `MAIL_FROM_ADDRESS`: email address sending the email (required)
+- `MAIL_SEND_TIMEOUT_SECONDS`: timeout used to send emails (defaults to 15 seconds)
## Development setup
diff --git a/cmd/serve.go b/cmd/serve.go
index 4ec8f2e69..7552ba67e 100644
--- a/cmd/serve.go
+++ b/cmd/serve.go
@@ -23,6 +23,7 @@ import (
k8s "github.com/canonical/identity-platform-admin-ui/internal/k8s"
ik "github.com/canonical/identity-platform-admin-ui/internal/kratos"
"github.com/canonical/identity-platform-admin-ui/internal/logging"
+ "github.com/canonical/identity-platform-admin-ui/internal/mail"
"github.com/canonical/identity-platform-admin-ui/internal/monitoring/prometheus"
io "github.com/canonical/identity-platform-admin-ui/internal/oathkeeper"
"github.com/canonical/identity-platform-admin-ui/internal/openfga"
@@ -164,9 +165,11 @@ func serve() {
hydraAdminClient,
)
+ mailConfig := mail.NewConfig(specs.MailHost, specs.MailPort, specs.MailUsername, specs.MailPassword, specs.MailFromAddress, specs.MailSendTimeoutSeconds)
+
ollyConfig := web.NewO11yConfig(tracer, monitor, logger)
- routerConfig := web.NewRouterConfig(specs.ContextPath, specs.PayloadValidationEnabled, idpConfig, schemasConfig, rulesConfig, uiConfig, externalConfig, oauth2Config, ollyConfig)
+ routerConfig := web.NewRouterConfig(specs.ContextPath, specs.PayloadValidationEnabled, idpConfig, schemasConfig, rulesConfig, uiConfig, externalConfig, oauth2Config, mailConfig, ollyConfig)
router := web.NewRouter(routerConfig, wpool)
diff --git a/go.mod b/go.mod
index b9b6f57aa..633728e1d 100644
--- a/go.mod
+++ b/go.mod
@@ -90,6 +90,7 @@ require (
github.com/prometheus/common v0.44.0 // indirect
github.com/prometheus/procfs v0.11.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
+ github.com/wneessen/go-mail v0.4.4 // indirect
go.opentelemetry.io/otel/metric v1.19.0 // indirect
go.opentelemetry.io/proto/otlp v1.0.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
diff --git a/go.sum b/go.sum
index d90694074..e1d762ee5 100644
--- a/go.sum
+++ b/go.sum
@@ -179,6 +179,8 @@ github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJ
github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/wneessen/go-mail v0.4.4 h1:rI8wJzPYymUpUth87vFV3k313bmnid4v+FwhBAYYLFM=
+github.com/wneessen/go-mail v0.4.4/go.mod h1:zxOlafWCP/r6FEhAaRgH4IC1vg2YXxO0Nar9u0IScZ8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
diff --git a/internal/config/specs.go b/internal/config/specs.go
index d7b8ed93f..eaebcb2c9 100644
--- a/internal/config/specs.go
+++ b/internal/config/specs.go
@@ -57,4 +57,11 @@ type EnvSpec struct {
PayloadValidationEnabled bool `envconfig:"payload_validation_enabled" default:"true"`
OpenFGAWorkersTotal int `envconfig:"openfga_workers_total" default:"150"`
+
+ MailHost string `envconfig:"MAIL_HOST" required:"true"`
+ MailPort int `envconfig:"MAIL_PORT" required:"true"`
+ MailUsername string `envconfig:"MAIL_USERNAME"`
+ MailPassword string `envconfig:"MAIL_PASSWORD"`
+ MailFromAddress string `envconfig:"MAIL_FROM_ADDRESS" required:"true"`
+ MailSendTimeoutSeconds int `envconfig:"MAIL_SEND_TIMEOUT_SECONDS" default:"15"`
}
diff --git a/internal/mail/html/user-invite.html b/internal/mail/html/user-invite.html
new file mode 100644
index 000000000..c32050e29
--- /dev/null
+++ b/internal/mail/html/user-invite.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+ Verify Your Account
+
+
+
+
+
+
+
Hello,
+
Your account with the email address {{ .Email }} was recently created. To complete your
+ registration, click the button below:
+
Verification code {{ .RecoveryCode }}
+
Verify Account
+
+
+
+
+
diff --git a/internal/mail/interfaces.go b/internal/mail/interfaces.go
new file mode 100644
index 000000000..da8281ca7
--- /dev/null
+++ b/internal/mail/interfaces.go
@@ -0,0 +1,19 @@
+// Copyright 2024 Canonical Ltd.
+// SPDX-License-Identifier: AGPL-3.0
+
+package mail
+
+import (
+ "context"
+ "html/template"
+
+ mail2 "github.com/wneessen/go-mail"
+)
+
+type EmailServiceInterface interface {
+ Send(context.Context, string, string, *template.Template, any) error
+}
+
+type MailClientInterface interface {
+ DialAndSendWithContext(context.Context, ...*mail2.Msg) error
+}
diff --git a/internal/mail/service.go b/internal/mail/service.go
new file mode 100644
index 000000000..17d08ac80
--- /dev/null
+++ b/internal/mail/service.go
@@ -0,0 +1,105 @@
+// Copyright 2024 Canonical Ltd.
+// SPDX-License-Identifier: AGPL-3.0
+
+package mail
+
+import (
+ "context"
+ "html/template"
+ "time"
+
+ "github.com/wneessen/go-mail"
+ "go.opentelemetry.io/otel/trace"
+
+ "github.com/canonical/identity-platform-admin-ui/internal/logging"
+ "github.com/canonical/identity-platform-admin-ui/internal/monitoring"
+)
+
+type Config struct {
+ Host string `validate:"required"`
+ Port int `validate:"required"`
+ Username string
+ Password string
+ FromAddress string `validate:"required"`
+ SendTimeout time.Duration
+}
+
+func NewConfig(host string, port int, username, password, from string, sendTimeout int) *Config {
+ c := new(Config)
+
+ c.Host = host
+ c.Port = port
+ c.Username = username
+ c.Password = password
+ c.FromAddress = from
+ c.SendTimeout = time.Duration(sendTimeout) * time.Second
+
+ return c
+}
+
+type EmailService struct {
+ from string
+ client MailClientInterface
+
+ tracer trace.Tracer
+ monitor monitoring.MonitorInterface
+ logger logging.LoggerInterface
+}
+
+func (e *EmailService) Send(ctx context.Context, to, subject string, template *template.Template, templateArgs any) error {
+ ctx, span := e.tracer.Start(ctx, "mail.EmailService.Send")
+ defer span.End()
+
+ msg := mail.NewMsg()
+
+ if err := msg.From(e.from); err != nil {
+ return err
+ }
+
+ if err := msg.SetBodyHTMLTemplate(template, templateArgs); err != nil {
+ return err
+ }
+
+ if err := msg.To(to); err != nil {
+ return err
+ }
+
+ msg.Subject(subject)
+
+ return e.client.DialAndSendWithContext(ctx, msg)
+}
+
+func NewEmailService(config *Config, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *EmailService {
+ s := new(EmailService)
+ s.from = config.FromAddress
+
+ var err error
+ mailOpts := []mail.Option{
+ mail.WithPort(config.Port),
+ mail.WithTLSPolicy(mail.TLSOpportunistic),
+ mail.WithTimeout(config.SendTimeout),
+ }
+
+ // treat smtp connection as authenticated only if username is passed
+ if config.Username != "" {
+ mailOpts = append(
+ mailOpts,
+ []mail.Option{mail.WithSMTPAuth(mail.SMTPAuthPlain), mail.WithUsername(config.Username), mail.WithPassword(config.Password)}...,
+ )
+ }
+
+ s.client, err = mail.NewClient(
+ config.Host,
+ mailOpts...,
+ )
+
+ if err != nil {
+ logger.Fatalf("failed to create email client: %s", err)
+ }
+
+ s.monitor = monitor
+ s.tracer = tracer
+ s.logger = logger
+
+ return s
+}
diff --git a/internal/mail/service_test.go b/internal/mail/service_test.go
new file mode 100644
index 000000000..b3c9eeed4
--- /dev/null
+++ b/internal/mail/service_test.go
@@ -0,0 +1,119 @@
+// Copyright 2024 Canonical Ltd.
+// SPDX-License-Identifier: AGPL-3.0
+
+package mail
+
+import (
+ "context"
+ "errors"
+ "html/template"
+ "testing"
+
+ "go.opentelemetry.io/otel/trace"
+ "go.uber.org/mock/gomock"
+)
+
+//go:generate mockgen -build_flags=--mod=mod -package mail -destination ./mock_interfaces.go -source=./interfaces.go
+//go:generate mockgen -build_flags=--mod=mod -package mail -destination ./mock_logger.go -source=../../internal/logging/interfaces.go
+//go:generate mockgen -build_flags=--mod=mod -package mail -destination ./mock_monitor.go -source=../../internal/monitoring/interfaces.go
+//go:generate mockgen -build_flags=--mod=mod -package mail -destination ./mock_tracing.go go.opentelemetry.io/otel/trace Tracer
+
+func TestEmailService_Send(t *testing.T) {
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ mockTempl, _ := LoadTemplate(UserCreationInvite)
+
+ mockArgs := UserCreationInviteArgs{
+ InviteUrl: "test-url",
+ RecoveryCode: "test-code",
+ Email: "test-mail",
+ }
+
+ tests := []struct {
+ name string
+ from string
+ to string
+ template *template.Template
+ templateArgs any
+ setupMocks func(*MockMailClientInterface)
+ errMsg string
+ }{
+ {
+ name: "Success",
+ from: "example@mail.com",
+ to: "example@mail.com",
+ template: mockTempl,
+ setupMocks: func(c *MockMailClientInterface) {
+ c.EXPECT().DialAndSendWithContext(gomock.Any(), gomock.Any()).Return(nil)
+ },
+ },
+ {
+ name: "FromError",
+ from: "invalid from address",
+ to: "",
+ template: nil,
+ templateArgs: nil,
+ errMsg: "failed to parse mail address \"invalid from address\": mail: no angle-addr",
+ setupMocks: func(c *MockMailClientInterface) {},
+ },
+ {
+ name: "TemplateBodyError",
+ from: "from@example.com",
+ to: "",
+ template: nil,
+ templateArgs: nil,
+ errMsg: "template pointer is nil",
+ setupMocks: func(c *MockMailClientInterface) {},
+ },
+ {
+ name: "ToError",
+ from: "from@example.com",
+ to: "invalid to address",
+ template: mockTempl,
+ templateArgs: mockArgs,
+ errMsg: "failed to parse mail address \"invalid to address\": mail: no angle-addr",
+ setupMocks: func(c *MockMailClientInterface) {},
+ },
+ {
+ name: "SendError",
+ from: "from@example.com",
+ to: "to@example.com",
+ template: mockTempl,
+ templateArgs: mockArgs,
+ errMsg: "test-error",
+ setupMocks: func(c *MockMailClientInterface) {
+ c.EXPECT().DialAndSendWithContext(gomock.Any(), gomock.Any()).Return(errors.New("test-error"))
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+
+ mockTracer := NewMockTracer(ctrl)
+ mockCtx := context.TODO()
+ mockTracer.EXPECT().Start(gomock.Any(), "mail.EmailService.Send").Return(mockCtx, trace.SpanFromContext(mockCtx)).AnyTimes()
+
+ mockLogger := NewMockLoggerInterface(ctrl)
+ mockMonitor := NewMockMonitorInterface(ctrl)
+
+ mockClient := NewMockMailClientInterface(ctrl)
+
+ e := &EmailService{
+ from: tt.from,
+ client: mockClient,
+ tracer: mockTracer,
+ monitor: mockMonitor,
+ logger: mockLogger,
+ }
+
+ tt.setupMocks(mockClient)
+
+ if err := e.Send(context.TODO(), tt.to, "test-subject", tt.template, tt.templateArgs); (err != nil) != (tt.errMsg != "") {
+ t.Errorf("Send() error, got = %v, want %v", err.Error(), tt.errMsg)
+ }
+ })
+ }
+}
diff --git a/internal/mail/templates.go b/internal/mail/templates.go
new file mode 100644
index 000000000..b536ce4a9
--- /dev/null
+++ b/internal/mail/templates.go
@@ -0,0 +1,38 @@
+// Copyright 2024 Canonical Ltd.
+// SPDX-License-Identifier: AGPL-3.0
+
+package mail
+
+import (
+ "embed"
+ "fmt"
+ "html/template"
+ "strings"
+)
+
+var (
+ //go:embed html/user-invite.html
+ UserCreationInvite embed.FS
+)
+
+var (
+ templates = map[embed.FS]string{
+ UserCreationInvite: "html/user-invite.html",
+ }
+)
+
+type UserCreationInviteArgs struct {
+ InviteUrl string
+ RecoveryCode string
+ Email string
+}
+
+func LoadTemplate(templateFS embed.FS) (*template.Template, error) {
+ templatePattern, ok := templates[templateFS]
+ if !ok {
+ return nil, fmt.Errorf("template not found")
+ }
+
+ templateName := strings.SplitN(templatePattern, "/", 2)[1]
+ return template.New(templateName).ParseFS(templateFS, templatePattern)
+}
diff --git a/internal/mail/templates_test.go b/internal/mail/templates_test.go
new file mode 100644
index 000000000..dc5b0503a
--- /dev/null
+++ b/internal/mail/templates_test.go
@@ -0,0 +1,60 @@
+// Copyright 2024 Canonical Ltd.
+// SPDX-License-Identifier: AGPL-3.0
+
+package mail
+
+import (
+ "embed"
+ "testing"
+)
+
+func TestLoadTemplate(t *testing.T) {
+
+ tests := []struct {
+ name string
+ templateFS embed.FS
+ templateName string
+ errorMsg string
+ }{
+ {
+ name: "Template available",
+ templateFS: UserCreationInvite,
+ templateName: "user-invite.html",
+ },
+ {
+ name: "Template not available",
+ templateFS: embed.FS{},
+ errorMsg: "template not found",
+ },
+ }
+
+ for _, tt := range tests {
+ tt := tt
+ t.Run(tt.name, func(t *testing.T) {
+ template, err := LoadTemplate(tt.templateFS)
+
+ if tt.errorMsg != "" {
+ if err == nil {
+ t.Errorf("expected error != nil")
+ return
+ }
+
+ if err.Error() != tt.errorMsg {
+ t.Errorf("expected error message %q, got %q", tt.errorMsg, err.Error())
+ }
+ }
+
+ if tt.errorMsg == "" {
+ if err != nil {
+ t.Errorf("expected no error, got %v", err)
+ return
+ }
+
+ if template.Name() != tt.templateName {
+ t.Errorf("expected templateName %s, got %s", tt.templateName, template.Name())
+ }
+ }
+
+ })
+ }
+}
diff --git a/pkg/identities/handlers.go b/pkg/identities/handlers.go
index 846e9eab8..cdc57b6ad 100644
--- a/pkg/identities/handlers.go
+++ b/pkg/identities/handlers.go
@@ -160,6 +160,20 @@ func (a *API) handleCreate(w http.ResponseWriter, r *http.Request) {
return
}
+ createdIdentity := &ids.Identities[0]
+ err = a.service.SendUserCreationEmail(r.Context(), createdIdentity)
+ if err != nil {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(
+ types.Response{
+ Message: err.Error(),
+ Status: http.StatusInternalServerError,
+ },
+ )
+
+ return
+ }
+
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(
types.Response{
diff --git a/pkg/identities/handlers_test.go b/pkg/identities/handlers_test.go
index 35647dc4e..722b20808 100644
--- a/pkg/identities/handlers_test.go
+++ b/pkg/identities/handlers_test.go
@@ -313,6 +313,7 @@ func TestHandleCreateSuccess(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v0/identities", bytes.NewReader(payload))
mockService.EXPECT().CreateIdentity(gomock.Any(), identityBody).Return(&IdentityData{Identities: []kClient.Identity{*identity}}, nil)
+ mockService.EXPECT().SendUserCreationEmail(gomock.Any(), identity).Return(nil)
w := httptest.NewRecorder()
mux := chi.NewMux()
diff --git a/pkg/identities/interfaces.go b/pkg/identities/interfaces.go
index 5a4df59ec..0d59125ba 100644
--- a/pkg/identities/interfaces.go
+++ b/pkg/identities/interfaces.go
@@ -1,4 +1,4 @@
-// Copyright 2024 Canonical Ltd
+// Copyright 2024 Canonical Ltd.
// SPDX-License-Identifier: AGPL-3.0
package identities
@@ -22,6 +22,7 @@ type ServiceInterface interface {
CreateIdentity(context.Context, *kClient.CreateIdentityBody) (*IdentityData, error)
UpdateIdentity(context.Context, string, *kClient.UpdateIdentityBody) (*IdentityData, error)
DeleteIdentity(context.Context, string) (*IdentityData, error)
+ SendUserCreationEmail(context.Context, *kClient.Identity) error
}
type OpenFGAStoreInterface interface {
diff --git a/pkg/identities/service.go b/pkg/identities/service.go
index 22cb723cc..50be0e928 100644
--- a/pkg/identities/service.go
+++ b/pkg/identities/service.go
@@ -1,4 +1,4 @@
-// Copyright 2024 Canonical Ltd
+// Copyright 2024 Canonical Ltd.
// SPDX-License-Identifier: AGPL-3.0
package identities
@@ -20,16 +20,21 @@ import (
"github.com/canonical/identity-platform-admin-ui/internal/http/types"
"github.com/canonical/identity-platform-admin-ui/internal/logging"
+ "github.com/canonical/identity-platform-admin-ui/internal/mail"
"github.com/canonical/identity-platform-admin-ui/internal/monitoring"
ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga"
)
// TODO @shipperizer unify this value with schemas/service.go
-const DEFAULT_SCHEMA = "default.schema"
+const (
+ DEFAULT_SCHEMA = "default.schema"
+ userCreationEmailSubject = "Complete your registration"
+)
type Service struct {
kratos kClient.IdentityAPI
authz AuthorizerInterface
+ email mail.EmailServiceInterface
tracer trace.Tracer
monitor monitoring.MonitorInterface
@@ -166,6 +171,57 @@ func (s *Service) CreateIdentity(ctx context.Context, bodyID *kClient.CreateIden
return data, err
}
+func (s *Service) SendUserCreationEmail(ctx context.Context, identity *kClient.Identity) error {
+ ctx, span := s.tracer.Start(ctx, "identities.Service.SendUserCreationEmail")
+ defer span.End()
+
+ template, err := mail.LoadTemplate(mail.UserCreationInvite)
+ if err != nil {
+ return err
+ }
+
+ code, link, err := s.generateRecoveryInfo(ctx, identity.Id)
+ if err != nil {
+ return err
+ }
+
+ emailAddress := ""
+ if e, ok := identity.Traits.(map[string]interface{})["email"]; ok {
+ emailAddress = e.(string)
+ }
+
+ if emailAddress == "" {
+ return fmt.Errorf("\"email\" address not found in identity traits")
+ }
+
+ userCreationInviteArgs := mail.UserCreationInviteArgs{
+ Email: emailAddress,
+ InviteUrl: link,
+ RecoveryCode: code,
+ }
+
+ err = s.email.Send(ctx, emailAddress, userCreationEmailSubject, template, userCreationInviteArgs)
+
+ return err
+}
+
+func (s *Service) generateRecoveryInfo(ctx context.Context, identityId string) (string, string, error) {
+ request := kClient.CreateRecoveryCodeForIdentityBody{IdentityId: identityId}
+ recoveryInfo, response, err := s.kratos.CreateRecoveryCodeForIdentity(ctx).
+ CreateRecoveryCodeForIdentityBody(request).
+ Execute()
+
+ if err != nil {
+ return "", "", err
+ }
+
+ if response.StatusCode != http.StatusCreated {
+ return "", "", fmt.Errorf("unable to create recovery code for Identity %v, status code %d", identityId, response.StatusCode)
+ }
+
+ return recoveryInfo.RecoveryCode, recoveryInfo.RecoveryLink, nil
+}
+
func (s *Service) UpdateIdentity(ctx context.Context, ID string, bodyID *kClient.UpdateIdentityBody) (*IdentityData, error) {
ctx, span := s.tracer.Start(ctx, "identities.Service.UpdateIdentity")
defer span.End()
@@ -237,11 +293,12 @@ func (s *Service) DeleteIdentity(ctx context.Context, ID string) (*IdentityData,
return data, err
}
-func NewService(kratos kClient.IdentityAPI, authz AuthorizerInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service {
+func NewService(kratos kClient.IdentityAPI, authz AuthorizerInterface, email mail.EmailServiceInterface, tracer trace.Tracer, monitor monitoring.MonitorInterface, logger logging.LoggerInterface) *Service {
s := new(Service)
s.kratos = kratos
s.authz = authz
+ s.email = email
s.monitor = monitor
s.tracer = tracer
diff --git a/pkg/identities/service_test.go b/pkg/identities/service_test.go
index f3e196ca6..1b17dd569 100644
--- a/pkg/identities/service_test.go
+++ b/pkg/identities/service_test.go
@@ -1,4 +1,4 @@
-// Copyright 2024 Canonical Ltd
+// Copyright 2024 Canonical Ltd.
// SPDX-License-Identifier: AGPL-3.0
package identities
@@ -21,6 +21,7 @@ import (
gomock "go.uber.org/mock/gomock"
corev1 "k8s.io/api/core/v1"
+ "github.com/canonical/identity-platform-admin-ui/internal/mail"
ofga "github.com/canonical/identity-platform-admin-ui/internal/openfga"
)
@@ -40,6 +41,7 @@ func TestListIdentitiesSuccess(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -79,7 +81,7 @@ func TestListIdentitiesSuccess(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 10, "eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ", "")
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 10, "eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ", "")
if !reflect.DeepEqual(ids.Identities, identities) {
t.Fatalf("expected identities to be %v not %v", identities, ids.Identities)
@@ -106,6 +108,7 @@ func TestListIdentitiesFails(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -157,7 +160,7 @@ func TestListIdentitiesFails(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 10, "eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ", "test")
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).ListIdentities(ctx, 10, "eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ", "test")
if !reflect.DeepEqual(ids.Identities, identities) {
t.Fatalf("expected identities to be empty not %v", ids.Identities)
@@ -185,6 +188,7 @@ func TestGetIdentitySuccess(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
credID := "test-1"
@@ -199,7 +203,7 @@ func TestGetIdentitySuccess(t *testing.T) {
mockKratosIdentityAPI.EXPECT().GetIdentity(ctx, credID).Times(1).Return(identityRequest)
mockKratosIdentityAPI.EXPECT().GetIdentityExecute(gomock.Any()).Times(1).Return(identity, new(http.Response), nil)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID)
if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) {
t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities)
@@ -218,6 +222,7 @@ func TestGetIdentityFails(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
credID := "test"
@@ -254,7 +259,7 @@ func TestGetIdentityFails(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).GetIdentity(ctx, credID)
if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) {
t.Fatalf("expected identities to be empty not %v", ids.Identities)
@@ -282,6 +287,7 @@ func TestCreateIdentitySuccess(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -289,9 +295,9 @@ func TestCreateIdentitySuccess(t *testing.T) {
ApiService: mockKratosIdentityAPI,
}
- identity := kClient.NewIdentity("test", "test.json", "https://test.com/test.json", map[string]string{"name": "name"})
+ identity := kClient.NewIdentity("test", "test.json", "https://test.com/test.json", map[string]interface{}{"name": "name", "email": "test@example.com"})
credentials := kClient.NewIdentityWithCredentialsWithDefaults()
- identityBody := kClient.NewCreateIdentityBody("test.json", map[string]interface{}{"name": "name"})
+ identityBody := kClient.NewCreateIdentityBody("test.json", map[string]interface{}{"name": "name", "email": "test@example.com"})
identityBody.SetCredentials(*credentials)
mockTracer.EXPECT().Start(ctx, gomock.Any()).AnyTimes().Return(ctx, trace.SpanFromContext(ctx))
@@ -309,7 +315,7 @@ func TestCreateIdentitySuccess(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody)
if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) {
t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities)
@@ -329,6 +335,7 @@ func TestCreateIdentityFails(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -368,7 +375,7 @@ func TestCreateIdentityFails(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).CreateIdentity(ctx, identityBody)
if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) {
t.Fatalf("expected identities to be empty not %v", ids.Identities)
@@ -396,6 +403,7 @@ func TestUpdateIdentitySuccess(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -423,7 +431,7 @@ func TestUpdateIdentitySuccess(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, identity.Id, identityBody)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, identity.Id, identityBody)
if !reflect.DeepEqual(ids.Identities, []kClient.Identity{*identity}) {
t.Fatalf("expected identities to be %v not %v", *identity, ids.Identities)
@@ -443,6 +451,7 @@ func TestUpdateIdentityFails(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -485,7 +494,7 @@ func TestUpdateIdentityFails(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, credID, identityBody)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).UpdateIdentity(ctx, credID, identityBody)
if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) {
t.Fatalf("expected identities to be empty not %v", ids.Identities)
@@ -513,6 +522,7 @@ func TestDeleteIdentitySuccess(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
credID := "test-1"
@@ -526,7 +536,7 @@ func TestDeleteIdentitySuccess(t *testing.T) {
mockKratosIdentityAPI.EXPECT().DeleteIdentity(ctx, credID).Times(1).Return(identityRequest)
mockKratosIdentityAPI.EXPECT().DeleteIdentityExecute(gomock.Any()).Times(1).Return(new(http.Response), nil)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID)
if len(ids.Identities) > 0 {
t.Fatalf("invalid result, expected no identities, got %v", ids.Identities)
@@ -546,6 +556,7 @@ func TestDeleteIdentityFails(t *testing.T) {
mockMonitor := NewMockMonitorInterface(ctrl)
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
credID := "test-1"
@@ -582,7 +593,7 @@ func TestDeleteIdentityFails(t *testing.T) {
},
)
- ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID)
+ ids, err := NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger).DeleteIdentity(ctx, credID)
if !reflect.DeepEqual(ids.Identities, make([]kClient.Identity, 0)) {
t.Fatalf("expected identities to be empty not %v", ids.Identities)
@@ -709,6 +720,7 @@ func TestV1ServiceListIdentities(t *testing.T) {
mockCoreV1 := NewMockCoreV1Interface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -780,7 +792,7 @@ func TestV1ServiceListIdentities(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
r, err := svc.ListIdentities(
@@ -839,7 +851,7 @@ func TestV1ServiceCreateIdentity(t *testing.T) {
id,
"test",
"https://test.com/test.json",
- map[string]string{
+ map[string]interface{}{
"name": fmt.Sprintf("%s %s", name, surname),
"email": email,
},
@@ -891,6 +903,7 @@ func TestV1ServiceCreateIdentity(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
cfg := new(Config)
cfg.K8s = mockCoreV1
@@ -961,7 +974,7 @@ func TestV1ServiceCreateIdentity(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
newIdentity, err := svc.CreateIdentity(ctx, test.input.identity)
@@ -1053,6 +1066,7 @@ func TestV1ServiceGetIdentity(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1104,7 +1118,7 @@ func TestV1ServiceGetIdentity(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
identity, err := svc.GetIdentity(ctx, test.input)
@@ -1206,6 +1220,7 @@ func TestV1ServiceUpdateIdentity(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1271,7 +1286,7 @@ func TestV1ServiceUpdateIdentity(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
identity, err := svc.UpdateIdentity(ctx, test.input)
@@ -1345,6 +1360,7 @@ func TestV1ServiceDeleteIdentity(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1397,7 +1413,7 @@ func TestV1ServiceDeleteIdentity(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
ok, err := svc.DeleteIdentity(ctx, test.input)
@@ -1474,6 +1490,7 @@ func TestV1ServiceGetIdentityGroups(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1489,7 +1506,7 @@ func TestV1ServiceGetIdentityGroups(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
mockLogger.EXPECT().Error(gomock.Any()).AnyTimes()
@@ -1586,6 +1603,7 @@ func TestV1ServiceGetIdentityRoles(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1601,7 +1619,7 @@ func TestV1ServiceGetIdentityRoles(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
mockLogger.EXPECT().Error(gomock.Any()).AnyTimes()
@@ -1722,6 +1740,7 @@ func TestV1ServicePatchIdentityRoles(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1737,7 +1756,7 @@ func TestV1ServicePatchIdentityRoles(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
// AssignRoles(context.Context, string, ...string) error
@@ -1894,6 +1913,7 @@ func TestV1ServicePatchIdentityGroups(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -1909,7 +1929,7 @@ func TestV1ServicePatchIdentityGroups(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
// AssignGroups(context.Context, string, ...string) error
@@ -2061,6 +2081,7 @@ func TestV1ServiceGetIdentityEntitlements(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -2076,7 +2097,7 @@ func TestV1ServiceGetIdentityEntitlements(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
mockLogger.EXPECT().Error(gomock.Any()).AnyTimes()
@@ -2227,6 +2248,7 @@ func TestV1ServicePatchIdentityEntitlements(t *testing.T) {
mockAuthz := NewMockAuthorizerInterface(ctrl)
mockKratosIdentityAPI := NewMockIdentityAPI(ctrl)
mockOpenFGAStore := NewMockOpenFGAStoreInterface(ctrl)
+ mockEmail := mail.NewMockEmailServiceInterface(ctrl)
ctx := context.Background()
@@ -2242,7 +2264,7 @@ func TestV1ServicePatchIdentityEntitlements(t *testing.T) {
svc := NewV1Service(
cfg,
- NewService(mockKratosIdentityAPI, mockAuthz, mockTracer, mockMonitor, mockLogger),
+ NewService(mockKratosIdentityAPI, mockAuthz, mockEmail, mockTracer, mockMonitor, mockLogger),
)
// AssignGroups(context.Context, string, ...string) error
diff --git a/pkg/web/router.go b/pkg/web/router.go
index aedcf21b0..6c6ed2c55 100644
--- a/pkg/web/router.go
+++ b/pkg/web/router.go
@@ -12,6 +12,7 @@ import (
"github.com/canonical/identity-platform-admin-ui/internal/authorization"
"github.com/canonical/identity-platform-admin-ui/internal/logging"
+ "github.com/canonical/identity-platform-admin-ui/internal/mail"
"github.com/canonical/identity-platform-admin-ui/internal/monitoring"
"github.com/canonical/identity-platform-admin-ui/internal/pool"
"github.com/canonical/identity-platform-admin-ui/internal/tracing"
@@ -38,10 +39,11 @@ type RouterConfig struct {
ui *ui.Config
external ExternalClientsConfigInterface
oauth2 *authentication.Config
+ mail *mail.Config
olly O11yConfigInterface
}
-func NewRouterConfig(contextPath string, payloadValidationEnabled bool, idp *idp.Config, schemas *schemas.Config, rules *rules.Config, ui *ui.Config, external ExternalClientsConfigInterface, oauth2 *authentication.Config, olly O11yConfigInterface) *RouterConfig {
+func NewRouterConfig(contextPath string, payloadValidationEnabled bool, idp *idp.Config, schemas *schemas.Config, rules *rules.Config, ui *ui.Config, external ExternalClientsConfigInterface, oauth2 *authentication.Config, mail *mail.Config, olly O11yConfigInterface) *RouterConfig {
return &RouterConfig{
contextPath: contextPath,
payloadValidationEnabled: payloadValidationEnabled,
@@ -51,6 +53,7 @@ func NewRouterConfig(contextPath string, payloadValidationEnabled bool, idp *idp
ui: ui,
external: external,
oauth2: oauth2,
+ mail: mail,
olly: olly,
}
}
@@ -64,6 +67,7 @@ func NewRouter(config *RouterConfig, wpool pool.WorkerPoolInterface) http.Handle
uiConfig := config.ui
externalConfig := config.external
oauth2Config := config.oauth2
+ mailConfig := config.mail
logger := config.olly.Logger()
monitor := config.olly.Monitor()
@@ -91,8 +95,10 @@ func NewRouter(config *RouterConfig, wpool pool.WorkerPoolInterface) http.Handle
statusAPI := status.NewAPI(tracer, monitor, logger)
metricsAPI := metrics.NewAPI(logger)
+ mailService := mail.NewEmailService(mailConfig, tracer, monitor, logger)
+
identitiesAPI := identities.NewAPI(
- identities.NewService(externalConfig.KratosAdmin().IdentityAPI(), externalConfig.Authorizer(), tracer, monitor, logger),
+ identities.NewService(externalConfig.KratosAdmin().IdentityAPI(), externalConfig.Authorizer(), mailService, tracer, monitor, logger),
tracer,
monitor,
logger,