summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorOla Aunrønning <olaa@yahooinc.com>2022-10-27 14:47:25 +0200
committerGitHub <noreply@github.com>2022-10-27 14:47:25 +0200
commit30a93b4910c1de1a10ebccfa545d03e1deac8056 (patch)
tree48f239e59fd895064f653165652b8d111140d991
parent0cb3aa286942e998a598c390332466a432b6e956 (diff)
parentc6903a0afe6244587ccf2ae345acaff3b55fb12b (diff)
Merge pull request #24555 from vespa-engine/olaa/email-verification
Add email verification
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Mailer.java4
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/MockMailer.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java6
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java3
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java56
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java81
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java14
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java18
-rw-r--r--controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java4
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java6
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java116
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java5
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java32
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java53
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java20
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java106
-rw-r--r--controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java11
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java102
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java19
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java7
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java31
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java11
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java30
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java18
-rw-r--r--controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json1
26 files changed, 687 insertions, 79 deletions
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Mailer.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Mailer.java
index 18662edc85e..27eec34cf4e 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Mailer.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/organization/Mailer.java
@@ -1,6 +1,8 @@
// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package com.yahoo.vespa.hosted.controller.api.integration.organization;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+
/**
* Allows sending e-mail from a particular <code>user@domain</code>.
*
@@ -17,4 +19,6 @@ public interface Mailer {
/** Returns the domain this is configured to use. */
String domain();
+ void sendVerificationMail(PendingMailVerification pendingMailVerification);
+
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/MockMailer.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/MockMailer.java
index cb2b76d845c..dcfa9b7ea07 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/MockMailer.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/integration/stubs/MockMailer.java
@@ -3,6 +3,7 @@ package com.yahoo.vespa.hosted.controller.api.integration.stubs;
import com.yahoo.vespa.hosted.controller.api.integration.organization.Mail;
import com.yahoo.vespa.hosted.controller.api.integration.organization.Mailer;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
import java.util.ArrayList;
import java.util.HashMap;
@@ -47,6 +48,11 @@ public class MockMailer implements Mailer {
return "domain";
}
+ @Override
+ public void sendVerificationMail(PendingMailVerification pendingMailVerification) {
+ send(new Mail(List.of(pendingMailVerification.getMailAddress()), "subject", "message"));
+ }
+
/** Returns the list of mails sent to the given recipient. Modifications affect the set of mails stored in this. */
public List<Mail> inbox(String recipient) {
return mails.getOrDefault(recipient, List.of());
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
index c2682334ce0..b8de63b61a9 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/PathGroup.java
@@ -58,6 +58,7 @@ enum PathGroup {
"/application/v4/tenant/{tenant}/info/profile",
"/application/v4/tenant/{tenant}/info/billing",
"/application/v4/tenant/{tenant}/info/contacts",
+ "/application/v4/tenant/{tenant}/info/resend-mail-verification",
"/application/v4/tenant/{tenant}/notifications",
"/routing/v1/status/tenant/{tenant}/{*}"),
@@ -255,7 +256,10 @@ enum PathGroup {
/** Paths used to approve requests to access tenant resources */
accessRequestApproval(Matcher.tenant, "/application/v4/tenant/{tenant}/access/approve/operator",
- "/application/v4/tenant/{tenant}/access/managed/operator");
+ "/application/v4/tenant/{tenant}/access/managed/operator"),
+
+ /** Path used for email verification */
+ emailVerification("/user/v1/email/verify");
final List<String> pathSpecs;
final List<Matcher> matchers;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
index 91eaec53aa4..2d4f98dfa8d 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/Policy.java
@@ -209,7 +209,11 @@ enum Policy {
horizonProxyOperations(Privilege.grant(Action.all())
.on(PathGroup.horizonProxy)
- .in(SystemName.PublicCd, SystemName.Public));
+ .in(SystemName.PublicCd, SystemName.Public)),
+
+ emailVerification(Privilege.grant(Action.create)
+ .on(PathGroup.emailVerification)
+ .in(SystemName.PublicCd, SystemName.Public));
private final Set<Privilege> privileges;
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
index a9d67c2d78a..e40c99a64be 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/api/role/RoleDefinition.java
@@ -28,7 +28,8 @@ public enum RoleDefinition {
everyone(Policy.classifiedRead,
Policy.publicRead,
Policy.user,
- Policy.tenantCreate),
+ Policy.tenantCreate,
+ Policy.emailVerification),
/** Build service which may submit new applications for continuous deployment. */
buildService(everyone,
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java
new file mode 100644
index 00000000000..ea6e0e9c754
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/Email.java
@@ -0,0 +1,56 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.tenant;
+
+import java.util.Objects;
+
+/**
+ * @author olaa
+ */
+public class Email {
+
+ private final String emailAddress;
+ private final boolean isVerified;
+
+ public Email(String emailAddress, boolean isVerified) {
+ this.emailAddress = emailAddress;
+ this.isVerified = isVerified;
+ }
+
+ public String getEmailAddress() {
+ return emailAddress;
+ }
+
+ public boolean isVerified() {
+ return isVerified;
+ }
+
+ public static Email empty() {
+ return new Email("", true);
+ }
+
+ public Email withEmailAddress(String emailAddress) {
+ return new Email(emailAddress, isVerified);
+ }
+
+ public Email withVerification(boolean isVerified) {
+ return new Email(emailAddress, isVerified);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Email email = (Email) o;
+ return isVerified == email.isVerified && Objects.equals(emailAddress, email.emailAddress);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(emailAddress, isVerified);
+ }
+
+ @Override
+ public String toString() {
+ return emailAddress;
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java
new file mode 100644
index 00000000000..af5ae746d22
--- /dev/null
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/PendingMailVerification.java
@@ -0,0 +1,81 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.tenant;
+
+import com.yahoo.config.provision.TenantName;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * @author olaa
+ */
+public class PendingMailVerification {
+
+ private final TenantName tenantName;
+ private final String mailAddress;
+ private final String verificationCode;
+ private final Instant verificationDeadline;
+ private final MailType mailType;
+
+ public PendingMailVerification(TenantName tenantName, String mailAddress, String verificationCode, Instant verificationDeadline, MailType mailType) {
+ this.tenantName = tenantName;
+ this.mailAddress = mailAddress;
+ this.verificationCode = verificationCode;
+ this.verificationDeadline = verificationDeadline;
+ this.mailType = mailType;
+ }
+
+ public TenantName getTenantName() {
+ return tenantName;
+ }
+
+ public String getMailAddress() {
+ return mailAddress;
+ }
+
+ public String getVerificationCode() {
+ return verificationCode;
+ }
+
+ public Instant getVerificationDeadline() {
+ return verificationDeadline;
+ }
+
+ public MailType getMailType() {
+ return mailType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ PendingMailVerification that = (PendingMailVerification) o;
+ return Objects.equals(tenantName, that.tenantName) &&
+ Objects.equals(mailAddress, that.mailAddress) &&
+ Objects.equals(verificationCode, that.verificationCode) &&
+ Objects.equals(verificationDeadline, that.verificationDeadline) &&
+ mailType == that.mailType;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(tenantName, mailAddress, verificationCode, verificationDeadline, mailType);
+ }
+
+ @Override
+ public String toString() {
+ return "PendingMailVerification{" +
+ "tenantName=" + tenantName +
+ ", mailAddress='" + mailAddress + '\'' +
+ ", verificationCode='" + verificationCode + '\'' +
+ ", verificationDeadline=" + verificationDeadline +
+ ", mailType=" + mailType +
+ '}';
+ }
+
+ public enum MailType {
+ TENANT_CONTACT,
+ NOTIFICATIONS
+ }
+}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java
index 3aa5600ed87..482bb26bcf9 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContact.java
@@ -8,36 +8,36 @@ import java.util.Objects;
*/
public class TenantContact {
private final String name;
- private final String email;
+ private final Email email;
private final String phone;
- private TenantContact(String name, String email, String phone) {
+ private TenantContact(String name, Email email, String phone) {
this.name = Objects.requireNonNull(name);
this.email = Objects.requireNonNull(email);
this.phone = Objects.requireNonNull(phone);
}
- public static TenantContact from(String name, String email, String phone) {
+ public static TenantContact from(String name, Email email, String phone) {
return new TenantContact(name, email, phone);
}
- public static TenantContact from(String name, String email) {
+ public static TenantContact from(String name, Email email) {
return TenantContact.from(name, email, "");
}
public static TenantContact empty() {
- return new TenantContact("", "", "");
+ return new TenantContact("", Email.empty(), "");
}
public String name() { return name; }
- public String email() { return email; }
+ public Email email() { return email; }
public String phone() { return phone; }
public TenantContact withName(String name) {
return new TenantContact(name, email, phone);
}
- public TenantContact withEmail(String email) {
+ public TenantContact withEmail(Email email) {
return new TenantContact(name, email, phone);
}
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java
index bd8671d814f..7e0fc50660e 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantContacts.java
@@ -5,6 +5,7 @@ import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
+import java.util.stream.Collectors;
/**
* Tenant contacts are targets of the notification system. Sometimes they
@@ -35,6 +36,13 @@ public class TenantContacts {
return contacts;
}
+ public <T extends Contact> List<T> ofType(Class<T> type) {
+ return contacts.stream()
+ .filter(type::isInstance)
+ .map(type::cast)
+ .collect(Collectors.toList());
+ }
+
public boolean isEmpty() {
return contacts.isEmpty();
}
@@ -77,14 +85,18 @@ public class TenantContacts {
}
public static class EmailContact extends Contact {
- private final String email;
+ private final Email email;
- public EmailContact(List<Audience> audiences, String email) {
+ public EmailContact(List<Audience> audiences, Email email) {
super(audiences);
this.email = email;
}
- public String email() { return email; }
+ public Email email() { return email; }
+
+ public EmailContact withEmail(Email email) {
+ return new EmailContact(audiences(), email);
+ }
@Override
public Type type() {
diff --git a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java
index 0ca7863fbce..36a302ed0d8 100644
--- a/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java
+++ b/controller-api/src/main/java/com/yahoo/vespa/hosted/controller/tenant/TenantInfo.java
@@ -23,7 +23,7 @@ public class TenantInfo {
private final TenantBilling billingContact;
private final TenantContacts contacts;
- TenantInfo(String name, String email, String website, String contactName, String contactEmail,
+ TenantInfo(String name, String email, String website, String contactName, Email contactEmail,
TenantAddress address, TenantBilling billingContact, TenantContacts contacts) {
this(name, email, website, TenantContact.from(contactName, contactEmail), address, billingContact, contacts);
}
@@ -39,7 +39,7 @@ public class TenantInfo {
}
public static TenantInfo empty() {
- return new TenantInfo("", "", "", "", "", TenantAddress.empty(), TenantBilling.empty(), TenantContacts.empty());
+ return new TenantInfo("", "", "", "", Email.empty(), TenantAddress.empty(), TenantBilling.empty(), TenantContacts.empty());
}
public String name() {
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
index af56666c6eb..22ce4db10e5 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java
@@ -86,6 +86,7 @@ public class Controller extends AbstractComponent {
private final NotificationsDb notificationsDb;
private final SupportAccessControl supportAccessControl;
private final Notifier notifier;
+ private final MailVerifier mailVerifier;
/**
* Creates a controller
@@ -126,6 +127,7 @@ public class Controller extends AbstractComponent {
notifier = new Notifier(curator, serviceRegistry.zoneRegistry(), serviceRegistry.mailer(), flagSource);
notificationsDb = new NotificationsDb(this);
supportAccessControl = new SupportAccessControl(this);
+ mailVerifier = new MailVerifier(tenantController, serviceRegistry.mailer(), curator, clock);
// Record the version of this controller
curator().writeControllerVersion(this.hostname(), serviceRegistry.controllerVersion());
@@ -339,4 +341,8 @@ public class Controller extends AbstractComponent {
public Notifier notifier() {
return notifier;
}
+
+ public MailVerifier mailVerifier() {
+ return mailVerifier;
+ }
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java
new file mode 100644
index 00000000000..a7f3a3ca3b2
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java
@@ -0,0 +1,116 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.api.integration.organization.Mailer;
+import com.yahoo.vespa.hosted.controller.persistence.CuratorDb;
+import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Tenant;
+import com.yahoo.vespa.hosted.controller.tenant.TenantContacts;
+import com.yahoo.vespa.hosted.controller.tenant.TenantInfo;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Optional;
+import java.util.UUID;
+
+
+/**
+ * @author olaa
+ */
+public class MailVerifier {
+
+ private final TenantController tenantController;
+ private final Mailer mailer;
+ private final CuratorDb curatorDb;
+ private final Clock clock;
+ private static final Duration VERIFICATION_DEADLINE = Duration.ofDays(7);
+
+ public MailVerifier(TenantController tenantController, Mailer mailer, CuratorDb curatorDb, Clock clock) {
+ this.tenantController = tenantController;
+ this.mailer = mailer;
+ this.curatorDb = curatorDb;
+ this.clock = clock;
+ }
+
+ public PendingMailVerification sendMailVerification(TenantName tenantName, String email, PendingMailVerification.MailType mailType) {
+ if (!email.contains("@")) {
+ throw new IllegalArgumentException("Invalid email address");
+ }
+
+ var verificationCode = UUID.randomUUID().toString();
+ var verificationDeadline = clock.instant().plus(VERIFICATION_DEADLINE);
+ var pendingMailVerification = new PendingMailVerification(tenantName, email, verificationCode, verificationDeadline, mailType);
+ writePendingVerification(pendingMailVerification);
+ mailer.sendVerificationMail(pendingMailVerification);
+ return pendingMailVerification;
+ }
+
+ public Optional<PendingMailVerification> resendMailVerification(TenantName tenantName, String email, PendingMailVerification.MailType mailType) {
+ var oldPendingVerification = curatorDb.listPendingMailVerifications()
+ .stream()
+ .filter(pendingMailVerification ->
+ pendingMailVerification.getMailAddress().equals(email) &&
+ pendingMailVerification.getMailType().equals(mailType) &&
+ pendingMailVerification.getTenantName().equals(tenantName)
+ ).findFirst();
+
+ if (oldPendingVerification.isEmpty())
+ return Optional.empty();
+
+ try (var lock = curatorDb.lockPendingMailVerification(oldPendingVerification.get().getVerificationCode())) {
+ curatorDb.deletePendingMailVerification(oldPendingVerification.get());
+ }
+
+ return Optional.of(sendMailVerification(tenantName, email, mailType));
+ }
+
+ public boolean verifyMail(String verificationCode) {
+ return curatorDb.getPendingMailVerification(verificationCode)
+ .filter(pendingMailVerification -> pendingMailVerification.getVerificationDeadline().isAfter(clock.instant()))
+ .map(pendingMailVerification -> {
+ var tenant = requireCloudTenant(pendingMailVerification.getTenantName());
+ var oldTenantInfo = tenant.info();
+ var updatedTenantInfo = switch (pendingMailVerification.getMailType()) {
+ case NOTIFICATIONS -> withTenantContacts(oldTenantInfo, pendingMailVerification);
+ case TENANT_CONTACT -> oldTenantInfo.withContact(oldTenantInfo.contact()
+ .withEmail(oldTenantInfo.contact().email().withVerification(true)));
+ };
+
+ tenantController.lockOrThrow(tenant.name(), LockedTenant.Cloud.class, lockedTenant -> {
+ lockedTenant = lockedTenant.withInfo(updatedTenantInfo);
+ tenantController.store(lockedTenant);
+ });
+
+ try (var lock = curatorDb.lockPendingMailVerification(pendingMailVerification.getVerificationCode())) {
+ curatorDb.deletePendingMailVerification(pendingMailVerification);
+ }
+ return true;
+ }).orElse(false);
+ }
+
+ private TenantInfo withTenantContacts(TenantInfo oldInfo, PendingMailVerification pendingMailVerification) {
+ var newContacts = oldInfo.contacts().ofType(TenantContacts.EmailContact.class)
+ .stream()
+ .map(contact -> {
+ if (pendingMailVerification.getMailAddress().equals(contact.email().getEmailAddress()))
+ return contact.withEmail(contact.email().withVerification(true));
+ return contact;
+ }).toList();
+ return oldInfo.withContacts(new TenantContacts(newContacts));
+ }
+
+ private void writePendingVerification(PendingMailVerification pendingMailVerification) {
+ try (var lock = curatorDb.lockPendingMailVerification(pendingMailVerification.getVerificationCode())) {
+ curatorDb.writePendingMailVerification(pendingMailVerification);
+ }
+ }
+
+ private CloudTenant requireCloudTenant(TenantName tenantName) {
+ return tenantController.get(tenantName)
+ .filter(tenant -> tenant.type() == Tenant.Type.cloud)
+ .map(CloudTenant.class::cast)
+ .orElseThrow(() -> new IllegalStateException("Mail verification is only applicable for cloud tenants"));
+ }
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
index f2c9d55b2a2..1074649ca7a 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java
@@ -110,7 +110,10 @@ public class Notifier {
private void dispatch(Notification notification, Collection<TenantContacts.EmailContact> contacts) {
try {
var content = formatter.format(notification);
- mailer.send(mailOf(content, contacts.stream().map(c -> c.email()).collect(Collectors.toList())));
+ mailer.send(mailOf(content, contacts.stream()
+ .filter(c -> c.email().isVerified())
+ .map(c -> c.email().getEmailAddress())
+ .collect(Collectors.toList())));
} catch (MailerException e) {
log.log(Level.SEVERE, "Failed sending email", e);
} catch (MissingOptionalException e) {
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
index 0e8f1648765..d582937cb0b 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java
@@ -34,6 +34,7 @@ import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy;
import com.yahoo.vespa.hosted.controller.routing.RoutingStatus;
import com.yahoo.vespa.hosted.controller.routing.ZoneRoutingPolicy;
import com.yahoo.vespa.hosted.controller.support.access.SupportAccess;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.versions.OsVersionStatus;
import com.yahoo.vespa.hosted.controller.versions.OsVersionTarget;
@@ -94,6 +95,7 @@ public class CuratorDb {
private static final Path changeRequestsRoot = root.append("changeRequests");
private static final Path notificationsRoot = root.append("notifications");
private static final Path supportAccessRoot = root.append("supportAccess");
+ private static final Path mailVerificationRoot = root.append("mailVerification");
private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer();
private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer);
@@ -231,6 +233,10 @@ public class CuratorDb {
return curator.lock(lockRoot.append("deploymentRetriggerQueue"), defaultLockTimeout);
}
+ public Mutex lockPendingMailVerification(String verificationCode) {
+ return curator.lock(lockRoot.append("pendingMailVerification").append(verificationCode), defaultLockTimeout);
+ }
+
// -------------- Helpers ------------------------------------------
/** Try locking with a low timeout, meaning it is OK to fail lock acquisition.
@@ -655,6 +661,28 @@ public class CuratorDb {
curator.set(deploymentRetriggerPath(), asJson(retriggerEntrySerializer.toSlime(retriggerEntries)));
}
+ // -------------- Pending mail verification -------------------------------
+
+ public Optional<PendingMailVerification> getPendingMailVerification(String verificationCode) {
+ return readSlime(mailVerificationPath(verificationCode)).map(MailVerificationSerializer::fromSlime);
+ }
+
+ public List<PendingMailVerification> listPendingMailVerifications() {
+ return curator.getChildren(mailVerificationRoot)
+ .stream()
+ .map(this::getPendingMailVerification)
+ .flatMap(Optional::stream)
+ .collect(Collectors.toList());
+ }
+
+ public void writePendingMailVerification(PendingMailVerification pendingMailVerification) {
+ curator.set(mailVerificationPath(pendingMailVerification.getVerificationCode()), asJson(MailVerificationSerializer.toSlime(pendingMailVerification)));
+ }
+
+ public void deletePendingMailVerification(PendingMailVerification pendingMailVerification) {
+ curator.delete(mailVerificationPath(pendingMailVerification.getVerificationCode()));
+ }
+
// -------------- Paths ---------------------------------------------------
private static Path upgradesPerMinutePath() {
@@ -755,4 +783,8 @@ public class CuratorDb {
return root.append("deploymentRetriggerQueue");
}
+ private static Path mailVerificationPath(String verificationCode) {
+ return mailVerificationRoot.append(verificationCode);
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java
new file mode 100644
index 00000000000..6910d5c21c0
--- /dev/null
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java
@@ -0,0 +1,53 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.slime.Cursor;
+import com.yahoo.slime.Inspector;
+import com.yahoo.slime.Slime;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+
+import java.time.Instant;
+
+/**
+ * @author olaa
+ */
+public class MailVerificationSerializer {
+
+ private static final String tenantField = "tenant";
+ private static final String audiencesField = "audiences";
+ private static final String emailField = "email";
+ private static final String emailTypeField = "emailType";
+ private static final String emailVerificationCodeField = "emailVerificationCode";
+ private static final String emailVerificationDeadlineField = "emailVerificationDeadline";
+ private static final String rolesField = "roles";
+
+ public static Slime toSlime(PendingMailVerification pendingMailVerification) {
+ var slime = new Slime();
+ var object = slime.setObject();
+ toSlime(pendingMailVerification, object);
+ return slime;
+ }
+
+ public static void toSlime(PendingMailVerification pendingMailVerification, Cursor object) {
+ object.setString(tenantField, pendingMailVerification.getTenantName().value());
+ object.setString(emailVerificationCodeField, pendingMailVerification.getVerificationCode());
+ object.setString(emailField, pendingMailVerification.getMailAddress());
+ object.setLong(emailVerificationDeadlineField, pendingMailVerification.getVerificationDeadline().toEpochMilli());
+ object.setString(emailTypeField, pendingMailVerification.getMailType().name());
+ }
+
+ public static PendingMailVerification fromSlime(Slime slime) {
+ return fromSlime(slime.get());
+ }
+
+ public static PendingMailVerification fromSlime(Inspector inspector) {
+ var tenant = TenantName.from(inspector.field(tenantField).asString());
+ var address = inspector.field(emailField).asString();
+ var verificationCode = inspector.field(emailVerificationCodeField).asString();
+ var deadline = Instant.ofEpochMilli(inspector.field(emailVerificationDeadlineField).asLong());
+ var type = PendingMailVerification.MailType.valueOf(inspector.field(emailTypeField).asString());
+ return new PendingMailVerification(tenant, address, verificationCode, deadline, type);
+ }
+
+}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
index fc7cafe4c89..b6d0155b6ab 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java
@@ -21,6 +21,7 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess;
import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.tenant.TenantAddress;
@@ -234,7 +235,7 @@ public class TenantSerializer {
.withWebsite(infoObject.field("website").asString())
.withContact(TenantContact.from(
infoObject.field("contactName").asString(),
- infoObject.field("contactEmail").asString()))
+ new Email(infoObject.field("contactEmail").asString(), asBoolOrTrue(infoObject.field("contactEmailVerified")))))
.withAddress(tenantInfoAddressFromSlime(infoObject.field("address")))
.withBilling(tenantInfoBillingContactFromSlime(infoObject.field("billingContact")))
.withContacts(tenantContactsFrom(infoObject.field("contacts")));
@@ -253,7 +254,7 @@ public class TenantSerializer {
return TenantBilling.empty()
.withContact(TenantContact.from(
billingObject.field("name").asString(),
- billingObject.field("email").asString(),
+ new Email(billingObject.field("email").asString(), true),
billingObject.field("phone").asString()))
.withAddress(tenantInfoAddressFromSlime(billingObject.field("address")));
}
@@ -283,7 +284,8 @@ public class TenantSerializer {
infoCursor.setString("email", info.email());
infoCursor.setString("website", info.website());
infoCursor.setString("contactName", info.contact().name());
- infoCursor.setString("contactEmail", info.contact().email());
+ infoCursor.setString("contactEmail", info.contact().email().getEmailAddress());
+ infoCursor.setBool("contactEmailVerified", info.contact().email().isVerified());
toSlime(info.address(), infoCursor);
toSlime(info.billingContact(), infoCursor);
toSlime(info.contacts(), infoCursor);
@@ -305,7 +307,7 @@ public class TenantSerializer {
Cursor addressCursor = parentCursor.setObject("billingContact");
addressCursor.setString("name", billingContact.contact().name());
- addressCursor.setString("email", billingContact.contact().email());
+ addressCursor.setString("email", billingContact.contact().email().getEmailAddress());
addressCursor.setString("phone", billingContact.contact().phone());
toSlime(billingContact.address(), addressCursor);
}
@@ -386,7 +388,8 @@ public class TenantSerializer {
switch (contact.type()) {
case EMAIL:
var email = (TenantContacts.EmailContact) contact;
- data.setString("email", email.email());
+ data.setString("email", email.email().getEmailAddress());
+ data.setBool("emailVerified", email.email().isVerified());
return;
default:
throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type());
@@ -401,7 +404,8 @@ public class TenantSerializer {
.collect(Collectors.toUnmodifiableList());
switch (type) {
case EMAIL:
- return new TenantContacts.EmailContact(audiences, inspector.field("data").field("email").asString());
+ var isVerified = asBoolOrTrue(inspector.field("data").field("emailVerified"));
+ return new TenantContacts.EmailContact(audiences, new Email(inspector.field("data").field("email").asString(), isVerified));
default:
throw new IllegalArgumentException("Serialization for contact type not implemented: " + type);
}
@@ -460,4 +464,8 @@ public class TenantSerializer {
}
}
+ private boolean asBoolOrTrue(Inspector inspector) {
+ return !inspector.valid() || inspector.asBool();
+ }
+
}
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
index 161e4b30dd9..23f945e02be 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java
@@ -111,7 +111,9 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess;
import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import com.yahoo.vespa.hosted.controller.tenant.TenantAddress;
import com.yahoo.vespa.hosted.controller.tenant.TenantBilling;
@@ -301,6 +303,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (path.matches("/application/v4/tenant/{tenant}/info/profile")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoProfile);
if (path.matches("/application/v4/tenant/{tenant}/info/billing")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoBilling);
if (path.matches("/application/v4/tenant/{tenant}/info/contacts")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoContacts);
+ if (path.matches("/application/v4/tenant/{tenant}/info/resend-mail-verification")) return withCloudTenant(path.get("tenant"), request, this::resendEmailVerification);
if (path.matches("/application/v4/tenant/{tenant}/archive-access")) return allowAwsArchiveAccess(path.get("tenant"), request); // TODO(enygaard, 2022-05-25) Remove when no longer used by console
if (path.matches("/application/v4/tenant/{tenant}/archive-access/aws")) return allowAwsArchiveAccess(path.get("tenant"), request);
if (path.matches("/application/v4/tenant/{tenant}/archive-access/gcp")) return allowGcpArchiveAccess(path.get("tenant"), request);
@@ -522,7 +525,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
infoCursor.setString("email", info.email());
infoCursor.setString("website", info.website());
infoCursor.setString("contactName", info.contact().name());
- infoCursor.setString("contactEmail", info.contact().email());
+ infoCursor.setString("contactEmail", info.contact().email().getEmailAddress());
+ infoCursor.setBool("contactEmailVerified", info.contact().email().isVerified());
toSlime(info.address(), infoCursor);
toSlime(info.billingContact(), infoCursor);
toSlime(info.contacts(), infoCursor);
@@ -539,7 +543,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (!info.isEmpty()) {
var contact = root.setObject("contact");
contact.setString("name", info.contact().name());
- contact.setString("email", info.contact().email());
+ contact.setString("email", info.contact().email().getEmailAddress());
+ contact.setBool("emailVerified", info.contact().email().isVerified());
var tenant = root.setObject("tenant");
tenant.setString("company", info.name());
@@ -560,9 +565,17 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
private SlimeJsonResponse putTenantInfoProfile(CloudTenant cloudTenant, Inspector inspector) {
var info = cloudTenant.info();
+ var mergedEmail = optional("email", inspector.field("contact"))
+ .filter(address -> !address.equals(info.contact().email().getEmailAddress()))
+ .map(address -> {
+ controller.mailVerifier().sendMailVerification(cloudTenant.name(), address, PendingMailVerification.MailType.TENANT_CONTACT);
+ return new Email(address, false);
+ })
+ .orElse(info.contact().email());
+
var mergedContact = TenantContact.empty()
.withName(getString(inspector.field("contact").field("name"), info.contact().name()))
- .withEmail(getString(inspector.field("contact").field("email"), info.contact().email()));
+ .withEmail(mergedEmail);
var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.address());
@@ -592,7 +605,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
var contact = root.setObject("contact");
contact.setString("name", billingContact.contact().name());
- contact.setString("email", billingContact.contact().email());
+ contact.setString("email", billingContact.contact().email().getEmailAddress());
contact.setString("phone", billingContact.contact().phone());
toSlime(billingContact.address(), root); // will create "address" on the parent
@@ -606,7 +619,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
var contact = info.billingContact().contact();
var address = info.billingContact().address();
- var mergedContact = updateTenantInfoContact(inspector.field("contact"), contact);
+ var mergedContact = updateTenantInfoContact(inspector.field("contact"), cloudTenant.name(), contact, false);
var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.billingContact().address());
var mergedBilling = info.billingContact()
@@ -633,7 +646,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
private SlimeJsonResponse putTenantInfoContacts(CloudTenant cloudTenant, Inspector inspector) {
var mergedInfo = cloudTenant.info()
- .withContacts(updateTenantInfoContacts(inspector.field("contacts"), cloudTenant.info().contacts()));
+ .withContacts(updateTenantInfoContacts(inspector.field("contacts"), cloudTenant.name(), cloudTenant.info().contacts()));
// Store changes
controller.tenants().lockOrThrow(cloudTenant.name(), LockedTenant.Cloud.class, lockedTenant -> {
@@ -649,10 +662,10 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
if (mergedInfo.contact().name().isBlank()) {
throw new IllegalArgumentException("'contactName' cannot be empty");
}
- if (mergedInfo.contact().email().isBlank()) {
+ if (mergedInfo.contact().email().getEmailAddress().isBlank()) {
throw new IllegalArgumentException("'contactEmail' cannot be empty");
}
- if (! mergedInfo.contact().email().contains("@")) {
+ if (! mergedInfo.contact().email().getEmailAddress().contains("@")) {
// email address validation is notoriously hard - we should probably just try to send a
// verification email to this address. checking for @ is a simple best-effort.
throw new IllegalArgumentException("'contactEmail' needs to be an email address");
@@ -682,7 +695,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
Cursor addressCursor = parentCursor.setObject("billingContact");
addressCursor.setString("name", billingContact.contact().name());
- addressCursor.setString("email", billingContact.contact().email());
+ addressCursor.setString("email", billingContact.contact().email().getEmailAddress());
addressCursor.setString("phone", billingContact.contact().phone());
toSlime(billingContact.address(), addressCursor);
}
@@ -696,7 +709,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
switch (contact.type()) {
case EMAIL:
var email = (TenantContacts.EmailContact) contact;
- contactCursor.setString("email", email.email());
+ contactCursor.setString("email", email.email().getEmailAddress());
+ contactCursor.setBool("emailVerified", email.email().isVerified());
return;
default:
throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type());
@@ -737,9 +751,17 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
// Merge info from request with the existing info
Inspector insp = toSlime(request.getData()).get();
+ var mergedEmail = optional("contactEmail", insp)
+ .filter(address -> !address.equals(oldInfo.contact().email().getEmailAddress()))
+ .map(address -> {
+ controller.mailVerifier().sendMailVerification(tenant.name(), address, PendingMailVerification.MailType.TENANT_CONTACT);
+ return new Email(address, false);
+ })
+ .orElse(oldInfo.contact().email());
+
TenantContact mergedContact = TenantContact.empty()
.withName(getString(insp.field("contactName"), oldInfo.contact().name()))
- .withEmail(getString(insp.field("contactEmail"), oldInfo.contact().email()));
+ .withEmail(mergedEmail);
TenantInfo mergedInfo = TenantInfo.empty()
.withName(getString(insp.field("name"), oldInfo.name()))
@@ -747,8 +769,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
.withWebsite(getString(insp.field("website"), oldInfo.website()))
.withContact(mergedContact)
.withAddress(updateTenantInfoAddress(insp.field("address"), oldInfo.address()))
- .withBilling(updateTenantInfoBillingContact(insp.field("billingContact"), oldInfo.billingContact()))
- .withContacts(updateTenantInfoContacts(insp.field("contacts"), oldInfo.contacts()));
+ .withBilling(updateTenantInfoBillingContact(insp.field("billingContact"), tenant.name(), oldInfo.billingContact()))
+ .withContacts(updateTenantInfoContacts(insp.field("contacts"), tenant.name(), oldInfo.contacts()));
validateMergedTenantInfo(mergedInfo);
@@ -782,32 +804,34 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
throw new IllegalArgumentException("All address fields must be set");
}
- private TenantContact updateTenantInfoContact(Inspector insp, TenantContact oldContact) {
+ private TenantContact updateTenantInfoContact(Inspector insp, TenantName tenantName, TenantContact oldContact, boolean isBillingContact) {
if (!insp.valid()) return oldContact;
- String email = getString(insp.field("email"), oldContact.email());
-
- if (!email.isBlank() && !email.contains("@")) {
- // email address validation is notoriously hard - we should probably just try to send a
- // verification email to this address. checking for @ is a simple best-effort.
- throw new IllegalArgumentException("'email' needs to be an email address");
- }
+ var mergedEmail = optional("email", insp)
+ .filter(address -> !address.equals(oldContact.email().getEmailAddress()))
+ .map(address -> {
+ if (isBillingContact)
+ return new Email(address, true);
+ controller.mailVerifier().sendMailVerification(tenantName, address, PendingMailVerification.MailType.TENANT_CONTACT);
+ return new Email(address, false);
+ })
+ .orElse(oldContact.email());
return TenantContact.empty()
.withName(getString(insp.field("name"), oldContact.name()))
- .withEmail(getString(insp.field("email"), oldContact.email()))
+ .withEmail(mergedEmail)
.withPhone(getString(insp.field("phone"), oldContact.phone()));
}
- private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantBilling oldContact) {
+ private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantName tenantName, TenantBilling oldContact) {
if (!insp.valid()) return oldContact;
return TenantBilling.empty()
- .withContact(updateTenantInfoContact(insp, oldContact.contact()))
+ .withContact(updateTenantInfoContact(insp, tenantName, oldContact.contact(), true))
.withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address()));
}
- private TenantContacts updateTenantInfoContacts(Inspector insp, TenantContacts oldContacts) {
+ private TenantContacts updateTenantInfoContacts(Inspector insp, TenantName tenantName, TenantContacts oldContacts) {
if (!insp.valid()) return oldContacts;
List<TenantContacts.EmailContact> contacts = SlimeUtils.entriesStream(insp).map(inspector -> {
@@ -816,11 +840,16 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
.map(audience -> fromAudience(audience.asString()))
.toList();
- if (!email.contains("@")) {
- throw new IllegalArgumentException("'email' needs to be an email address");
- }
-
- return new TenantContacts.EmailContact(audiences, email);
+ // If contact exists, update audience. Otherwise, create new unverified contact
+ return oldContacts.ofType(TenantContacts.EmailContact.class)
+ .stream()
+ .filter(contact -> contact.email().getEmailAddress().equals(email))
+ .findAny()
+ .map(emailContact -> new TenantContacts.EmailContact(audiences, emailContact.email()))
+ .orElseGet(() -> {
+ controller.mailVerifier().sendMailVerification(tenantName, email, PendingMailVerification.MailType.NOTIFICATIONS);
+ return new TenantContacts.EmailContact(audiences, new Email(email, false));
+ });
}).toList();
return new TenantContacts(contacts);
@@ -1499,6 +1528,21 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
return new MessageResponse(type.jobName() + " for " + id + " resumed");
}
+ private SlimeJsonResponse resendEmailVerification(CloudTenant tenant, Inspector inspector) {
+ var mail = mandatory("mail", inspector).asString();
+ var type = mandatory("mailType", inspector).asString();
+
+ var mailType = switch (type) {
+ case "contact" -> PendingMailVerification.MailType.TENANT_CONTACT;
+ case "notifications" -> PendingMailVerification.MailType.NOTIFICATIONS;
+ default -> throw new IllegalArgumentException("Unknown mail type " + type);
+ };
+
+ var pendingVerification = controller.mailVerifier().resendMailVerification(tenant.name(), mail, mailType);
+ return pendingVerification.isPresent() ? new MessageResponse("Re-sent verification mail to " + mail) :
+ ErrorResponse.notFoundError("No pending mail verification found for " + mail);
+ }
+
private void toSlime(Cursor object, Application application, HttpRequest request) {
object.setString("tenant", application.id().tenant().value());
object.setString("application", application.id().application().value());
@@ -2012,7 +2056,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler {
User user = getAttribute(request, User.ATTRIBUTE_NAME, User.class);
TenantInfo info = controller.tenants().require(tenant, CloudTenant.class)
.info()
- .withContact(TenantContact.from(user.name(), user.email()));
+ .withContact(TenantContact.from(user.name(), new Email(user.email(), true)));
// Store changes
controller.tenants().lockOrThrow(tenant, LockedTenant.Cloud.class, lockedTenant -> {
lockedTenant = lockedTenant.withInfo(info);
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java
index 3491ef4ab03..7a8ef1d4ee6 100644
--- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java
+++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java
@@ -112,6 +112,7 @@ public class UserApiHandler extends ThreadedHttpRequestHandler {
private HttpResponse handlePOST(Path path, HttpRequest request) {
if (path.matches("/user/v1/tenant/{tenant}")) return addTenantRoleMember(path.get("tenant"), request);
+ if (path.matches("/user/v1/email/verify")) return verifyEmail(request);
return ErrorResponse.notFoundError(Text.format("No '%s' handler at '%s'", request.getMethod(),
request.getUri().getPath()));
@@ -311,6 +312,16 @@ public class UserApiHandler extends ThreadedHttpRequestHandler {
return new MessageResponse(user + " is now a member of " + roles.stream().map(Role::toString).collect(Collectors.joining(", ")));
}
+ private HttpResponse verifyEmail(HttpRequest request) {
+ var inspector = bodyInspector(request);
+ var verificationCode = require("verificationCode", Inspector::asString, inspector);
+ var verified = controller.mailVerifier().verifyMail(verificationCode);
+
+ if (verified)
+ return new MessageResponse("Email with verification code " + verificationCode + " has been verified");
+ return ErrorResponse.notFoundError("No pending email verification with code " + verificationCode + " found");
+ }
+
private HttpResponse removeTenantRoleMember(String tenantName, HttpRequest request) {
Inspector requestObject = bodyInspector(request);
var tenant = TenantName.from(tenantName);
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java
new file mode 100644
index 00000000000..873ab435444
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java
@@ -0,0 +1,102 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller;
+
+import com.yahoo.config.provision.SystemName;
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMailer;
+import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+import com.yahoo.vespa.hosted.controller.tenant.Tenant;
+import com.yahoo.vespa.hosted.controller.tenant.TenantContacts;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+
+/**
+ * @author olaa
+ */
+class MailVerifierTest {
+
+ private final ControllerTester tester = new ControllerTester(SystemName.Public);
+ private final MockMailer mailer = tester.serviceRegistry().mailer();
+ private final MailVerifier mailVerifier = new MailVerifier(tester.controller().tenants(), mailer, tester.curator(), tester.clock());
+
+ private static final TenantName tenantName = TenantName.from("scoober");
+ private static final String mail = "unverified@bar.com";
+ private static final List<TenantContacts.Audience> audiences = List.of(TenantContacts.Audience.NOTIFICATIONS, TenantContacts.Audience.TENANT);
+
+ @BeforeEach
+ public void setup() {
+ tester.createTenant(tenantName.value(), Tenant.Type.cloud);
+
+ tester.controller().tenants().lockOrThrow(tenantName, LockedTenant.Cloud.class, lockedTenant -> {
+ var contacts = List.of(
+ new TenantContacts.EmailContact(audiences, new Email("verified@bar.com", true)),
+ new TenantContacts.EmailContact(audiences, new Email(mail, false)),
+ new TenantContacts.EmailContact(audiences, new Email("another-unverified@bar.com", false))
+ );
+ lockedTenant = lockedTenant.withInfo(lockedTenant.get().info().withContacts(new TenantContacts(contacts)));
+ tester.controller().tenants().store(lockedTenant);
+ });
+ }
+
+ @Test
+ public void test_new_mail_verification() {
+ mailVerifier.sendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS);
+
+ // Verify mail is sent
+ var expectedMail = "message";
+ assertEquals(1, mailer.inbox(mail).size());
+ assertEquals(expectedMail, mailer.inbox(mail).get(0).message());
+
+ // Verify ZK data is updated
+ var writtenMailVerification = tester.curator().listPendingMailVerifications().get(0);
+ assertEquals(PendingMailVerification.MailType.NOTIFICATIONS, writtenMailVerification.getMailType());
+ assertEquals(tenantName, writtenMailVerification.getTenantName());
+ assertEquals(tester.clock().instant().plus(Duration.ofDays(7)), writtenMailVerification.getVerificationDeadline());
+ assertEquals(mail, writtenMailVerification.getMailAddress());
+
+ // Mail verification is no-op if deadline has passed
+ tester.clock().advance(Duration.ofDays(14));
+ assertFalse(mailVerifier.verifyMail(writtenMailVerification.getVerificationCode()));
+ assertFalse(tester.curator().listPendingMailVerifications().isEmpty());
+
+ // Mail is verified
+ tester.clock().retreat(Duration.ofDays(14));
+ mailVerifier.verifyMail(writtenMailVerification.getVerificationCode());
+ assertTrue(tester.curator().listPendingMailVerifications().isEmpty());
+ var tenant = tester.controller().tenants().require(tenantName, CloudTenant.class);
+ var expectedContacts = List.of(
+ new TenantContacts.EmailContact(audiences, new Email("verified@bar.com", true)),
+ new TenantContacts.EmailContact(audiences, new Email(mail, true)),
+ new TenantContacts.EmailContact(audiences, new Email("another-unverified@bar.com", false))
+ );
+ assertEquals(expectedContacts, tenant.info().contacts().all());
+ }
+
+ @Test
+ public void resending_verification_deletes_old_one() {
+ var pendingMailVerification = mailVerifier.sendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS);
+ var tenant = tester.controller().tenants().require(tenantName, CloudTenant.class);
+
+ // Unknown mail is no-op
+ var resentVerification = mailVerifier.resendMailVerification(tenantName, "unknown-mail", PendingMailVerification.MailType.NOTIFICATIONS);
+ assertTrue(resentVerification.isEmpty());
+ assertTrue(tester.curator().getPendingMailVerification(pendingMailVerification.getVerificationCode()).isPresent());
+
+ // Verification mail is re-sent, old data is replaced
+ resentVerification = mailVerifier.resendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS);
+ assertTrue(resentVerification.isPresent());
+ assertTrue(tester.curator().getPendingMailVerification(pendingMailVerification.getVerificationCode()).isEmpty());
+ assertTrue(tester.curator().getPendingMailVerification(resentVerification.get().getVerificationCode()).isPresent());
+ }
+
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java
index 4c1344650f8..852d4847b7e 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java
@@ -22,6 +22,7 @@ import com.yahoo.vespa.hosted.controller.integration.ZoneRegistryMock;
import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb;
import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.TenantContacts;
import com.yahoo.vespa.hosted.controller.tenant.TenantInfo;
@@ -49,7 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
public class NotificationsDbTest {
private static final TenantName tenant = TenantName.from("tenant1");
- private static final String email = "user1@example.com";
+ private static final Email email = new Email("user1@example.com", true);
private static final CloudTenant cloudTenant = new CloudTenant(tenant,
Instant.now(),
LastLoginInfo.EMPTY,
@@ -111,19 +112,19 @@ public class NotificationsDbTest {
;
var a = notifications.get(0);
notificationsDb.setNotification(a.source(), a.type(), a.level(), a.messages());
- assertEquals(0, mailer.inbox(email).size());
+ assertEquals(0, mailer.inbox(email.getEmailAddress()).size());
// Replace the 3rd notification. but don't change source or type
notificationsDb.setNotification(notification1.source(), notification1.type(), notification1.level(), notification1.messages());
- assertEquals(0, mailer.inbox(email).size());
+ assertEquals(0, mailer.inbox(email.getEmailAddress()).size());
// Notification for a new app, add without replacement
notificationsDb.setNotification(notification2.source(), notification2.type(), notification2.level(), notification2.messages());
- assertEquals(1, mailer.inbox(email).size());
+ assertEquals(1, mailer.inbox(email.getEmailAddress()).size());
// Notification for new type on existing app
notificationsDb.setNotification(notification3.source(), notification3.type(), notification3.level(), notification3.messages());
- assertEquals(2, mailer.inbox(email).size());
+ assertEquals(2, mailer.inbox(email.getEmailAddress()).size());
}
@Test
@@ -157,19 +158,19 @@ public class NotificationsDbTest {
// No metrics, no new notification
notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of());
- assertEquals(0, mailer.inbox(email).size());
+ assertEquals(0, mailer.inbox(email.getEmailAddress()).size());
// Metrics that contain none of the feed block metrics does not create new notification
notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", null, null, null, null, Map.of())));
- assertEquals(0, mailer.inbox(email).size());
+ assertEquals(0, mailer.inbox(email.getEmailAddress()).size());
// One resource is at warning
notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", 0.88, 0.9, 0.3, 0.5, Map.of())));
- assertEquals(1, mailer.inbox(email).size());
+ assertEquals(1, mailer.inbox(email.getEmailAddress()).size());
// One resource over the limit
notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", 0.95, 0.9, 0.3, 0.5, Map.of())));
- assertEquals(2, mailer.inbox(email).size());
+ assertEquals(2, mailer.inbox(email.getEmailAddress()).size());
}
@Test
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java
index 7c07192d633..c0501206494 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java
@@ -13,6 +13,7 @@ import com.yahoo.vespa.hosted.controller.integration.ZoneRegistryMock;
import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb;
import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.TenantContacts;
import com.yahoo.vespa.hosted.controller.tenant.TenantInfo;
@@ -28,7 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
public class NotifierTest {
private static final TenantName tenant = TenantName.from("tenant1");
- private static final String email = "user1@example.com";
+ private static final Email email = new Email("user1@example.com", true);
private static final CloudTenant cloudTenant = new CloudTenant(tenant,
Instant.now(),
@@ -63,8 +64,8 @@ public class NotifierTest {
List.of("test package has production tests, but no production tests are declared in deployment.xml",
"see https://docs.vespa.ai/en/testing.html for details on how to write system tests for Vespa"));
notifier.dispatch(notification);
- assertEquals(1, mailer.inbox(email).size());
- var mail = mailer.inbox(email).get(0);
+ assertEquals(1, mailer.inbox(email.getEmailAddress()).size());
+ var mail = mailer.inbox(email.getEmailAddress()).get(0);
assertEquals("[WARNING] Test package Vespa Notification for tenant1.default.default", mail.subject());
assertEquals("""
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java
new file mode 100644
index 00000000000..69c6e13ba62
--- /dev/null
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java
@@ -0,0 +1,31 @@
+// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.vespa.hosted.controller.persistence;
+
+import com.yahoo.config.provision.TenantName;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * @author olaa
+ */
+public class MailVerificationSerializerTest {
+
+ @Test
+ public void test_serialization() {
+ var original = new PendingMailVerification(TenantName.from("test-tenant"),
+ "email@mycomp.any",
+ "xyz-123",
+ Instant.now().truncatedTo(ChronoUnit.MILLIS),
+ PendingMailVerification.MailType.TENANT_CONTACT
+ );
+
+ var serialized = MailVerificationSerializer.toSlime(original);
+ var deserialized = MailVerificationSerializer.fromSlime(serialized);
+ assertEquals(original, deserialized);
+ }
+} \ No newline at end of file
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java
index 636620acf07..5144c5cb7b4 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java
@@ -17,6 +17,7 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess;
import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant;
import com.yahoo.vespa.hosted.controller.tenant.CloudTenant;
import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant;
+import com.yahoo.vespa.hosted.controller.tenant.Email;
import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo;
import com.yahoo.vespa.hosted.controller.tenant.TenantAddress;
import com.yahoo.vespa.hosted.controller.tenant.TenantBilling;
@@ -190,7 +191,7 @@ public class TenantSerializerTest {
Slime slime = new Slime();
Cursor parentObject = slime.setObject();
serializer.toSlime(partialInfo, parentObject);
- assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString());
+ assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"contactEmailVerified\":true,\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString());
}
@Test
@@ -199,7 +200,7 @@ public class TenantSerializerTest {
.withName("My Company")
.withEmail("email@mycomp.any")
.withWebsite("http://mycomp.any")
- .withContact(TenantContact.from("My Name", "ceo@mycomp.any"))
+ .withContact(TenantContact.from("My Name", new Email("ceo@mycomp.any", true)))
.withAddress(TenantAddress.empty()
.withCity("Hønefoss")
.withAddress("Riperbakken 2")
@@ -207,7 +208,7 @@ public class TenantSerializerTest {
.withCode("3510")
.withRegion("Viken"))
.withBilling(TenantBilling.empty()
- .withContact(TenantContact.from("Thomas The Tank Engine", "thomas@sodor.com", "NA"))
+ .withContact(TenantContact.from("Thomas The Tank Engine", new Email("ceo@mycomp.any", true), "NA"))
.withAddress(TenantAddress.empty()
.withCity("Suddery")
.withCountry("Sodor")
@@ -226,8 +227,8 @@ public class TenantSerializerTest {
void cloud_tenant_with_tenant_info_contacts() {
TenantInfo tenantInfo = TenantInfo.empty()
.withContacts(new TenantContacts(List.of(
- new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT), "email1@email.com"),
- new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), "email2@email.com"))));
+ new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT), new Email("email1@email.com", true)),
+ new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), new Email("email2@email.com", true)))));
Slime slime = new Slime();
Cursor parentCursor = slime.setObject();
serializer.toSlime(tenantInfo, parentCursor);
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
index ab671be23eb..4a816ebeee9 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java
@@ -90,7 +90,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
.roles(Set.of(Role.administrator(tenantName)));
tester.assertResponse(updateRequest, "{\"message\":\"Tenant info updated\"}", 200);
- tester.assertResponse(request, "{\"contact\":{\"name\":\"Some Name\",\"email\":\"foo@example.com\"},\"tenant\":{\"company\":\"\",\"website\":\"https://example.com/\"}}", 200);
+ tester.assertResponse(request, "{\"contact\":{\"name\":\"Some Name\",\"email\":\"foo@example.com\",\"emailVerified\":false},\"tenant\":{\"company\":\"\",\"website\":\"https://example.com/\"}}", 200);
}
@Test
@@ -117,7 +117,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
tester.assertResponse(request, "{\"contacts\":[]}", 200);
- var fullContacts = "{\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\"},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\"}]}";
+ var fullContacts = "{\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\",\"emailVerified\":false},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\",\"emailVerified\":false}]}";
var updateRequest = request("/application/v4/tenant/scoober/info/contacts", PUT)
.data(fullContacts)
.roles(Set.of(Role.administrator(tenantName)));
@@ -147,12 +147,12 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
tester.assertResponse(postPartialContacts, "{\"message\":\"Tenant info updated\"}", 200);
// Read back the updated info
- tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"},\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"}]}", 200);
+ tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"contactEmailVerified\":false,\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"},\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false}]}", 200);
String fullAddress = "{\"addressLines\":\"addressLines\",\"postalCodeOrZip\":\"postalCodeOrZip\",\"city\":\"city\",\"stateRegionProvince\":\"stateRegionProvince\",\"country\":\"country\"}";
String fullBillingContact = "{\"name\":\"name\",\"email\":\"foo@example\",\"phone\":\"phone\",\"address\":" + fullAddress + "}";
- String fullContacts = "[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\"},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\"}]";
- String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + ",\"contacts\":" + fullContacts + "}";
+ String fullContacts = "[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\",\"emailVerified\":false},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\",\"emailVerified\":false}]";
+ String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"contactEmailVerified\":false,\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + ",\"contacts\":" + fullContacts + "}";
// Now set all fields
var postFull =
@@ -163,6 +163,20 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
// Now compare the updated info with the full info we sent
tester.assertResponse(infoRequest, fullInfo, 200);
+
+ var invalidBody = "{\"mail\":\"contact1@example.com\", \"mailType\":\"blurb\"}";
+ var resendMailRequest =
+ request("/application/v4/tenant/scoober/info/resend-mail-verification", PUT)
+ .data(invalidBody)
+ .roles(Set.of(Role.administrator(tenantName)));
+ tester.assertResponse(resendMailRequest, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Unknown mail type blurb\"}", 400);
+
+ var resendMailBody = "{\"mail\":\"contact1@example.com\", \"mailType\":\"notifications\"}";
+ resendMailRequest =
+ request("/application/v4/tenant/scoober/info/resend-mail-verification", PUT)
+ .data(resendMailBody)
+ .roles(Set.of(Role.administrator(tenantName)));
+ tester.assertResponse(resendMailRequest, "{\"message\":\"Re-sent verification mail to contact1@example.com\"}", 200);
}
@Test
@@ -185,13 +199,13 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
var missingEmailResponse = request("/application/v4/tenant/scoober/info", PUT)
.data(partialInfoMissingEmail)
.roles(Set.of(Role.administrator(tenantName)));
- tester.assertResponse(missingEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'contactEmail' cannot be empty\"}", 400);
+ tester.assertResponse(missingEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400);
var partialInfoBadEmail = "{\"contactName\": \"Scoober Rentals Inc.\", \"contactEmail\": \"somethingweird\"}";
var badEmailResponse = request("/application/v4/tenant/scoober/info", PUT)
.data(partialInfoBadEmail)
.roles(Set.of(Role.administrator(tenantName)));
- tester.assertResponse(badEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'contactEmail' needs to be an email address\"}", 400);
+ tester.assertResponse(badEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400);
var invalidWebsite = "{\"contactName\": \"Scoober Rentals Inc.\", \"contactEmail\": \"email@scoober.com\", \"website\": \"scoober\" }";
var badWebsiteResponse = request("/application/v4/tenant/scoober/info", PUT)
@@ -231,7 +245,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest {
var contactsWithInvalidEmailResponse = request("/application/v4/tenant/scoober/info", PUT)
.data(contactsWithInvalidEmail)
.roles(Set.of(Role.administrator(tenantName)));
- tester.assertResponse(contactsWithInvalidEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'email' needs to be an email address\"}", 400);
+ tester.assertResponse(contactsWithInvalidEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400);
// duplicate contact is not allowed
var contactsWithDuplicateEmail = "{\"contacts\": [{\"audiences\": [\"tenant\"],\"email\": \"contact1@email.com\"}, {\"audiences\": [\"tenant\"],\"email\": \"contact1@email.com\"}]}";
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java
index b573940d150..075e001655f 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java
@@ -10,11 +10,11 @@ import com.yahoo.vespa.flags.PermanentFlags;
import com.yahoo.vespa.hosted.controller.ControllerTester;
import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId;
import com.yahoo.jdisc.http.filter.security.misc.User;
-import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockUserManagement;
import com.yahoo.vespa.hosted.controller.api.integration.user.UserId;
import com.yahoo.vespa.hosted.controller.api.role.Role;
import com.yahoo.vespa.hosted.controller.restapi.ContainerTester;
import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest;
+import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification;
import com.yahoo.vespa.hosted.controller.tenant.Tenant;
import org.junit.jupiter.api.Test;
@@ -321,4 +321,20 @@ public class UserApiTest extends ControllerContainerCloudTest {
}
}
+
+ @Test
+ public void verifyMail() {
+ var tester = new ContainerTester(container, responseFiles);
+ var controller = new ControllerTester(tester);
+ controller.createTenant("tenant1", Tenant.Type.cloud);
+ var pendingMailVerification = tester.controller().mailVerifier().sendMailVerification(TenantName.from("tenant1"), "foo@bar.com", PendingMailVerification.MailType.NOTIFICATIONS);
+
+ tester.assertResponse(request("/user/v1/email/verify", POST)
+ .data("{\"verificationCode\":\"blurb\"}"),
+ "{\"error-code\":\"NOT_FOUND\",\"message\":\"No pending email verification with code blurb found\"}", 404);
+
+ tester.assertResponse(request("/user/v1/email/verify", POST)
+ .data("{\"verificationCode\":\"" + pendingMailVerification.getVerificationCode() + "\"}"),
+ "{\"message\":\"Email with verification code " + pendingMailVerification.getVerificationCode() + " has been verified\"}", 200);
+ }
}
diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json
index 2a33f35545c..6702eff8dde 100644
--- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json
+++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json
@@ -4,5 +4,6 @@
"website": "",
"contactName": "administrator",
"contactEmail": "administrator@tenant",
+ "contactEmailVerified": true,
"contacts": [ ]
}