diff --git a/core/src/main/java/google/registry/model/console/UserBase.java b/core/src/main/java/google/registry/model/console/UserBase.java index 22cf3524215..785df8982b2 100644 --- a/core/src/main/java/google/registry/model/console/UserBase.java +++ b/core/src/main/java/google/registry/model/console/UserBase.java @@ -22,6 +22,7 @@ import static google.registry.util.PasswordUtils.hashPassword; import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; +import com.google.gson.annotations.Expose; import google.registry.model.Buildable; import google.registry.model.UpdateAutoTimestampEntity; import google.registry.util.PasswordUtils; @@ -50,12 +51,13 @@ public class UserBase extends UpdateAutoTimestampEntity implements Buildable { private static final long serialVersionUID = 6936728603828566721L; /** Email address of the user in question. */ - @Transient String emailAddress; + @Transient @Expose String emailAddress; /** Optional external email address to use for registry lock confirmation emails. */ @Column String registryLockEmailAddress; /** Roles (which grant permissions) associated with this user. */ + @Expose @Column(nullable = false) UserRoles userRoles; diff --git a/core/src/main/java/google/registry/model/console/UserRoles.java b/core/src/main/java/google/registry/model/console/UserRoles.java index 8244f6e7342..81684d32a84 100644 --- a/core/src/main/java/google/registry/model/console/UserRoles.java +++ b/core/src/main/java/google/registry/model/console/UserRoles.java @@ -18,6 +18,7 @@ import static google.registry.util.PreconditionsUtils.checkArgumentNotNull; import com.google.common.collect.ImmutableMap; +import com.google.gson.annotations.Expose; import google.registry.model.Buildable; import google.registry.model.ImmutableObject; import google.registry.persistence.converter.RegistrarToRoleMapUserType; @@ -53,6 +54,7 @@ public class UserRoles extends ImmutableObject implements Buildable { private GlobalRole globalRole = GlobalRole.NONE; /** Any per-registrar roles that this user may have. */ + @Expose @Type(RegistrarToRoleMapUserType.class) private Map registrarRoles = ImmutableMap.of(); diff --git a/core/src/main/java/google/registry/module/RequestComponent.java b/core/src/main/java/google/registry/module/RequestComponent.java index 604af4a88e9..1b3edbadd65 100644 --- a/core/src/main/java/google/registry/module/RequestComponent.java +++ b/core/src/main/java/google/registry/module/RequestComponent.java @@ -119,6 +119,7 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction; import google.registry.ui.server.console.ConsoleUpdateRegistrarAction; import google.registry.ui.server.console.ConsoleUserDataAction; +import google.registry.ui.server.console.ConsoleUsersAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; import google.registry.ui.server.console.settings.SecurityAction; @@ -189,6 +190,8 @@ interface RequestComponent { ConsoleUserDataAction consoleUserDataAction(); + ConsoleUsersAction consoleUsersAction(); + ConsoleDumDownloadAction consoleDumDownloadAction(); ContactAction contactAction(); diff --git a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java index 30c20b5c47c..fc1b9e9640e 100644 --- a/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java +++ b/core/src/main/java/google/registry/module/frontend/FrontendRequestComponent.java @@ -35,6 +35,7 @@ import google.registry.ui.server.console.ConsoleRegistryLockVerifyAction; import google.registry.ui.server.console.ConsoleUpdateRegistrarAction; import google.registry.ui.server.console.ConsoleUserDataAction; +import google.registry.ui.server.console.ConsoleUsersAction; import google.registry.ui.server.console.RegistrarsAction; import google.registry.ui.server.console.settings.ContactAction; import google.registry.ui.server.console.settings.SecurityAction; @@ -81,6 +82,8 @@ public interface FrontendRequestComponent { ConsoleUserDataAction consoleUserDataAction(); + ConsoleUsersAction consoleUsersAction(); + ConsoleDumDownloadAction consoleDumDownloadAction(); ContactAction contactAction(); diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java new file mode 100644 index 00000000000..1b5790e7038 --- /dev/null +++ b/core/src/main/java/google/registry/ui/server/console/ConsoleUsersAction.java @@ -0,0 +1,154 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static google.registry.model.console.RegistrarRole.ACCOUNT_MANAGER; +import static google.registry.persistence.transaction.TransactionManagerFactory.tm; +import static google.registry.request.Action.Method.GET; +import static google.registry.request.Action.Method.POST; +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; +import static jakarta.servlet.http.HttpServletResponse.SC_INTERNAL_SERVER_ERROR; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; + +import com.google.api.services.directory.Directory; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import google.registry.model.console.ConsolePermission; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.persistence.VKey; +import google.registry.request.Action; +import google.registry.request.Action.GkeService; +import google.registry.request.HttpException.BadRequestException; +import google.registry.request.Parameter; +import google.registry.request.auth.Auth; +import google.registry.util.StringGenerator; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import javax.inject.Inject; +import javax.inject.Named; + +@Action( + service = Action.GaeService.DEFAULT, + gkeService = GkeService.CONSOLE, + path = ConsoleUsersAction.PATH, + method = {GET, POST}, + auth = Auth.AUTH_PUBLIC_LOGGED_IN) +public class ConsoleUsersAction extends ConsoleApiAction { + static final String PATH = "/console-api/users"; + private static final int PASSWORD_LENGTH = 16; + + private static final Splitter EMAIL_SPLITTER = Splitter.on('@').trimResults(); + + private final Gson gson; + private final String registrarId; + private final Directory directory; + private final StringGenerator passwordGenerator; + + @Inject + public ConsoleUsersAction( + ConsoleApiParams consoleApiParams, + Gson gson, + Directory directory, + @Named("base58StringGenerator") StringGenerator passwordGenerator, + @Parameter("registrarId") String registrarId) { + super(consoleApiParams); + this.gson = gson; + this.registrarId = registrarId; + this.directory = directory; + this.passwordGenerator = passwordGenerator; + } + + private static String generateNewEmailAddress(User user, String increment) { + List emailParts = EMAIL_SPLITTER.splitToList(user.getEmailAddress()); + return String.format("%s-%s@%s", emailParts.get(0), increment, emailParts.get(1)); + } + + @Override + protected void postHandler(User user) { + // Temporary flag while testing + if (user.getUserRoles().isAdmin()) { + checkPermission(user, registrarId, ConsolePermission.MANAGE_USERS); + tm().transact(() -> runInTransaction(user)); + } else { + consoleApiParams.response().setStatus(SC_FORBIDDEN); + } + } + + @Override + protected void getHandler(User user) { + checkPermission(user, registrarId, ConsolePermission.MANAGE_USERS); + List users = + getAllUsers().stream() + .filter(u -> u.getUserRoles().getRegistrarRoles().containsKey(registrarId)) + .collect(Collectors.toList()); + consoleApiParams.response().setPayload(gson.toJson(users)); + consoleApiParams.response().setStatus(SC_OK); + } + + private void runInTransaction(User user) throws IOException { + String nextAvailableIncrement = + Stream.of("1", "2", "3") + .filter( + increment -> + tm().loadByKeyIfPresent( + VKey.create(User.class, generateNewEmailAddress(user, increment))) + .isEmpty()) + .findFirst() + .orElseThrow(() -> new BadRequestException("Extra users amount is limited to 3")); + + com.google.api.services.directory.model.User newUser = + new com.google.api.services.directory.model.User(); + newUser.setPassword(passwordGenerator.createString(PASSWORD_LENGTH)); + newUser.setPrimaryEmail(generateNewEmailAddress(user, nextAvailableIncrement)); + + try { + directory.users().insert(newUser).execute(); + } catch (IOException e) { + setFailedResponse("Failed to create the user workspace account", SC_INTERNAL_SERVER_ERROR); + throw e; + } + + UserRoles userRoles = + new UserRoles.Builder() + .setRegistrarRoles(ImmutableMap.of(registrarId, ACCOUNT_MANAGER)) + .build(); + + User.Builder builder = + new User.Builder().setUserRoles(userRoles).setEmailAddress(newUser.getPrimaryEmail()); + tm().put(builder.build()); + + consoleApiParams.response().setStatus(SC_OK); + consoleApiParams + .response() + .setPayload( + gson.toJson( + ImmutableMap.of( + "password", newUser.getPassword(), "email", newUser.getPrimaryEmail()))); + } + + private ImmutableList getAllUsers() { + return tm().transact( + () -> + tm().loadAllOf(User.class).stream() + .filter(u -> !u.getUserRoles().getRegistrarRoles().isEmpty()) + .collect(toImmutableList())); + } +} diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java new file mode 100644 index 00000000000..0b33b32c87c --- /dev/null +++ b/core/src/test/java/google/registry/ui/server/console/ConsoleUsersActionTest.java @@ -0,0 +1,189 @@ +// Copyright 2024 The Nomulus Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package google.registry.ui.server.console; + +import static com.google.common.truth.Truth.assertThat; +import static jakarta.servlet.http.HttpServletResponse.SC_BAD_REQUEST; +import static jakarta.servlet.http.HttpServletResponse.SC_OK; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.api.services.directory.Directory; +import com.google.api.services.directory.Directory.Users; +import com.google.api.services.directory.Directory.Users.Insert; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.gson.Gson; +import google.registry.model.console.GlobalRole; +import google.registry.model.console.RegistrarRole; +import google.registry.model.console.User; +import google.registry.model.console.UserRoles; +import google.registry.persistence.transaction.JpaTestExtensions; +import google.registry.request.RequestModule; +import google.registry.request.auth.AuthResult; +import google.registry.testing.ConsoleApiParamsUtils; +import google.registry.testing.DatabaseHelper; +import google.registry.testing.DeterministicStringGenerator; +import google.registry.testing.FakeResponse; +import google.registry.util.StringGenerator; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class ConsoleUsersActionTest { + + private static final Gson GSON = RequestModule.provideGson(); + + private final Directory directory = mock(Directory.class); + private final Users users = mock(Users.class); + private final Insert insert = mock(Insert.class); + + private StringGenerator passwordGenerator = + new DeterministicStringGenerator("abcdefghijklmnopqrstuvwxyz"); + + private ConsoleApiParams consoleApiParams; + + @RegisterExtension + final JpaTestExtensions.JpaIntegrationTestExtension jpa = + new JpaTestExtensions.Builder().buildIntegrationTestExtension(); + + @BeforeEach + void beforeEach() { + User dbUser1 = + new User.Builder() + .setEmailAddress("test1@test.com") + .setUserRoles( + new UserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT)) + .build()) + .build(); + User dbUser2 = + new User.Builder() + .setEmailAddress("test2@test.com") + .setUserRoles( + new UserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT)) + .build()) + .build(); + User dbUser3 = + new User.Builder() + .setEmailAddress("test3@test.com") + .setUserRoles( + new UserRoles() + .asBuilder() + .setRegistrarRoles( + ImmutableMap.of("NewRegistrar", RegistrarRole.PRIMARY_CONTACT)) + .build()) + .build(); + DatabaseHelper.persistResources(ImmutableList.of(dbUser1, dbUser2, dbUser3)); + } + + @Test + void testSuccess_registrarAccess() throws IOException { + UserRoles userRoles = + new UserRoles.Builder() + .setGlobalRole(GlobalRole.NONE) + .setIsAdmin(false) + .setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.PRIMARY_CONTACT)) + .build(); + + User user = + new User.Builder().setEmailAddress("email@email.com").setUserRoles(userRoles).build(); + + AuthResult authResult = AuthResult.createUser(user); + ConsoleUsersAction action = + createAction(Optional.of(ConsoleApiParamsUtils.createFake(authResult)), Optional.of("GET")); + action.run(); + var response = ((FakeResponse) consoleApiParams.response()); + User[] users = GSON.fromJson(response.getPayload(), User[].class); + assertThat(Arrays.stream(users).map(u -> u.getEmailAddress()).collect(Collectors.toList())) + .containsExactlyElementsIn(ImmutableList.of("test1@test.com", "test2@test.com")); + } + + @Test + void testFailure_noPermission() throws IOException { + UserRoles userRoles = + new UserRoles.Builder() + .setGlobalRole(GlobalRole.NONE) + .setIsAdmin(false) + .setRegistrarRoles(ImmutableMap.of("TheRegistrar", RegistrarRole.ACCOUNT_MANAGER)) + .build(); + + User user = + new User.Builder().setEmailAddress("email@email.com").setUserRoles(userRoles).build(); + + AuthResult authResult = AuthResult.createUser(user); + ConsoleUsersAction action = + createAction(Optional.of(ConsoleApiParamsUtils.createFake(authResult)), Optional.of("GET")); + action.run(); + var response = ((FakeResponse) consoleApiParams.response()); + assertThat(response.getStatus()).isEqualTo(HttpServletResponse.SC_FORBIDDEN); + } + + @Test + void testSuccess_createsUser() throws IOException { + User user = DatabaseHelper.createAdminUser("email@email.com"); + AuthResult authResult = AuthResult.createUser(user); + ConsoleUsersAction action = + createAction( + Optional.of(ConsoleApiParamsUtils.createFake(authResult)), Optional.of("POST")); + when(directory.users()).thenReturn(users); + when(users.insert(any(com.google.api.services.directory.model.User.class))).thenReturn(insert); + action.run(); + var response = ((FakeResponse) consoleApiParams.response()); + assertThat(response.getStatus()).isEqualTo(SC_OK); + assertThat(response.getPayload()) + .contains("{\"password\":\"abcdefghijklmnop\",\"email\":\"email-1@email.com\"}"); + } + + @Test + void testFailure_limitedTo3NewUsers() throws IOException { + User user = DatabaseHelper.createAdminUser("email@email.com"); + DatabaseHelper.createAdminUser("email-1@email.com"); + DatabaseHelper.createAdminUser("email-2@email.com"); + DatabaseHelper.createAdminUser("email-3@email.com"); + AuthResult authResult = AuthResult.createUser(user); + ConsoleUsersAction action = + createAction( + Optional.of(ConsoleApiParamsUtils.createFake(authResult)), Optional.of("POST")); + when(directory.users()).thenReturn(users); + when(users.insert(any(com.google.api.services.directory.model.User.class))).thenReturn(insert); + action.run(); + var response = ((FakeResponse) consoleApiParams.response()); + assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST); + assertThat(response.getPayload()).contains("Extra users amount is limited to 3"); + } + + private ConsoleUsersAction createAction( + Optional maybeConsoleApiParams, Optional method) + throws IOException { + consoleApiParams = + maybeConsoleApiParams.orElseGet( + () -> ConsoleApiParamsUtils.createFake(AuthResult.NOT_AUTHENTICATED)); + when(consoleApiParams.request().getMethod()).thenReturn(method.orElse("GET")); + return new ConsoleUsersAction( + consoleApiParams, GSON, directory, passwordGenerator, "TheRegistrar"); + } +} diff --git a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt index c955841fd52..82d86702fb7 100644 --- a/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt +++ b/core/src/test/resources/google/registry/module/frontend/frontend_routing.txt @@ -20,4 +20,5 @@ CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockVerifyAction GET CONSOLE /console-api/settings/contacts ContactAction GET,POST n USER PUBLIC CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC CONSOLE /console-api/settings/whois-fields WhoisRegistrarFieldsAction POST n USER PUBLIC -CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC \ No newline at end of file +CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC +CONSOLE /console-api/users ConsoleUsersAction GET,POST n USER PUBLIC \ No newline at end of file diff --git a/core/src/test/resources/google/registry/module/routing.txt b/core/src/test/resources/google/registry/module/routing.txt index c86c57c3475..09424010422 100644 --- a/core/src/test/resources/google/registry/module/routing.txt +++ b/core/src/test/resources/google/registry/module/routing.txt @@ -78,4 +78,5 @@ CONSOLE /console-api/registry-lock-verify ConsoleRegistryLockV CONSOLE /console-api/settings/contacts ContactAction GET,POST n USER PUBLIC CONSOLE /console-api/settings/security SecurityAction POST n USER PUBLIC CONSOLE /console-api/settings/whois-fields WhoisRegistrarFieldsAction POST n USER PUBLIC -CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC \ No newline at end of file +CONSOLE /console-api/userdata ConsoleUserDataAction GET n USER PUBLIC +CONSOLE /console-api/users ConsoleUsersAction GET,POST n USER PUBLIC