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

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,