diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index 67cc5a17f..b1f7ac7f6 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -415,6 +415,14 @@ public final class RegistryConfig { return config.cloudSql.instanceConnectionName; } + @Provides + @Config("cloudSqlDbInstanceName") + public static String providesCloudSqlDbInstance(RegistryConfigSettings config) { + // Format of instanceConnectionName: project-id:region:instance-name + int lastColonIndex = config.cloudSql.instanceConnectionName.lastIndexOf(':'); + return config.cloudSql.instanceConnectionName.substring(lastColonIndex + 1); + } + @Provides @Config("cloudDnsRootUrl") public static Optional getCloudDnsRootUrl(RegistryConfigSettings config) { diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClient.java b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClient.java index cb8ce4aca..fdf062f60 100644 --- a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClient.java +++ b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClient.java @@ -22,6 +22,9 @@ import java.util.Optional; /** A Cloud Secret Manager client for Nomulus, bound to a specific GCP project. */ public interface SecretManagerClient { + /** Returns the project name with which this client is associated. */ + String getProject(); + /** * Creates a new secret in the Cloud Secret Manager with no data. * @@ -32,6 +35,9 @@ public interface SecretManagerClient { */ void createSecret(String secretId); + /** Checks if a secret with the given {@code secretId} already exists. */ + boolean secretExists(String secretId); + /** Returns all secret IDs in the Cloud Secret Manager. */ Iterable listSecrets(); @@ -67,6 +73,24 @@ public interface SecretManagerClient { */ String getSecretData(String secretId, Optional version); + /** + * Enables a secret version. + * + * @param secretId The ID of the secret + * @param version The version of the secret to fetch. If not provided, the {@code latest} version + * will be returned + */ + void enableSecretVersion(String secretId, String version); + + /** + * Disables a secret version. + * + * @param secretId The ID of the secret + * @param version The version of the secret to fetch. If not provided, the {@code latest} version + * will be returned + */ + void disableSecretVersion(String secretId, String version); + /** * Destroys a secret version. * diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClientImpl.java b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClientImpl.java index 5c2e61fce..599eb7c7d 100644 --- a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClientImpl.java +++ b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerClientImpl.java @@ -29,12 +29,11 @@ import com.google.cloud.secretmanager.v1.SecretName; import com.google.cloud.secretmanager.v1.SecretPayload; import com.google.cloud.secretmanager.v1.SecretVersion; import com.google.cloud.secretmanager.v1.SecretVersionName; -import com.google.common.collect.Iterables; +import com.google.common.collect.Streams; import com.google.protobuf.ByteString; import google.registry.util.Retrier; import java.util.Optional; import java.util.concurrent.Callable; -import javax.inject.Inject; /** Implements {@link SecretManagerClient} on Google Cloud Platform. */ public class SecretManagerClientImpl implements SecretManagerClient { @@ -42,13 +41,17 @@ public class SecretManagerClientImpl implements SecretManagerClient { private final SecretManagerServiceClient csmClient; private final Retrier retrier; - @Inject SecretManagerClientImpl(String project, SecretManagerServiceClient csmClient, Retrier retrier) { this.project = project; this.csmClient = csmClient; this.retrier = retrier; } + @Override + public String getProject() { + return project; + } + @Override public void createSecret(String secretId) { checkNotNull(secretId, "secretId"); @@ -57,12 +60,25 @@ public class SecretManagerClientImpl implements SecretManagerClient { () -> csmClient.createSecret(ProjectName.of(project), secretId, secretSettings)); } + @Override + public boolean secretExists(String secretId) { + checkNotNull(secretId, "secretId"); + try { + callSecretManager(() -> csmClient.getSecret(SecretName.of(project, secretId))); + return true; + } catch (NoSuchSecretResourceException e) { + return false; + } + } + @Override public Iterable listSecrets() { ListSecretsPagedResponse response = callSecretManager(() -> csmClient.listSecrets(ProjectName.of(project))); - return Iterables.transform( - response.iterateAll(), secret -> SecretName.parse(secret.getName()).getSecret()); + return () -> + Streams.stream(response.iterateAll()) + .map(secret -> SecretName.parse(secret.getName()).getSecret()) + .iterator(); } @Override @@ -70,8 +86,10 @@ public class SecretManagerClientImpl implements SecretManagerClient { checkNotNull(secretId, "secretId"); ListSecretVersionsPagedResponse response = callSecretManager(() -> csmClient.listSecretVersions(SecretName.of(project, secretId))); - return Iterables.transform( - response.iterateAll(), SecretManagerClientImpl::toSecretVersionState); + return () -> + Streams.stream(response.iterateAll()) + .map(SecretManagerClientImpl::toSecretVersionState) + .iterator(); } private static SecretVersionState toSecretVersionState(SecretVersion secretVersion) { @@ -108,6 +126,22 @@ public class SecretManagerClientImpl implements SecretManagerClient { .toStringUtf8()); } + @Override + public void enableSecretVersion(String secretId, String version) { + checkNotNull(secretId, "secretId"); + checkNotNull(version, "version"); + callSecretManager( + () -> csmClient.enableSecretVersion(SecretVersionName.of(project, secretId, version))); + } + + @Override + public void disableSecretVersion(String secretId, String version) { + checkNotNull(secretId, "secretId"); + checkNotNull(version, "version"); + callSecretManager( + () -> csmClient.disableSecretVersion(SecretVersionName.of(project, secretId, version))); + } + @Override public void destroySecretVersion(String secretId, String version) { checkNotNull(secretId, "secretId"); diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerModule.java b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerModule.java index 4cfaf09f3..f829d50ff 100644 --- a/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerModule.java +++ b/core/src/main/java/google/registry/privileges/secretmanager/SecretManagerModule.java @@ -14,12 +14,12 @@ package google.registry.privileges.secretmanager; -import static com.google.common.base.Preconditions.checkNotNull; import com.google.cloud.secretmanager.v1.SecretManagerServiceClient; import dagger.Component; import dagger.Module; import dagger.Provides; +import google.registry.config.RegistryConfig.Config; import google.registry.config.RegistryConfig.ConfigModule; import google.registry.util.Retrier; import google.registry.util.UtilsModule; @@ -28,19 +28,16 @@ import javax.inject.Singleton; /** Provides bindings for {@link SecretManagerClient}. */ @Module -public class SecretManagerModule { - - private final String project; - - public SecretManagerModule(String project) { - this.project = checkNotNull(project, "project"); - } +public abstract class SecretManagerModule { @Provides @Singleton - SecretManagerClient provideSecretManagerClient(Retrier retrier) { + static SecretManagerClient provideSecretManagerClient( + @Config("projectId") String project, Retrier retrier) { try { - return new SecretManagerClientImpl(project, SecretManagerServiceClient.create(), retrier); + SecretManagerServiceClient stub = SecretManagerServiceClient.create(); + Runtime.getRuntime().addShutdownHook(new Thread(stub::close)); + return new SecretManagerClientImpl(project, stub, retrier); } catch (IOException e) { throw new RuntimeException(e); } diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SqlCredential.java b/core/src/main/java/google/registry/privileges/secretmanager/SqlCredential.java new file mode 100644 index 000000000..05caa53dd --- /dev/null +++ b/core/src/main/java/google/registry/privileges/secretmanager/SqlCredential.java @@ -0,0 +1,55 @@ +// Copyright 2020 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.privileges.secretmanager; + +import static avro.shaded.com.google.common.base.Preconditions.checkState; + +import com.google.auto.value.AutoValue; +import java.util.List; + +/** + * Contains the login name and password of a Cloud SQL user. + * + *

User must take care not to include the {@link #SEPARATOR} in property values. + */ +@AutoValue +public abstract class SqlCredential { + + public static final Character SEPARATOR = ' '; + + public abstract String login(); + + public abstract String password(); + + @Override + public final String toString() { + // Use Object.toString(), which does not show object data. + return super.toString(); + } + + public final String toFormattedString() { + return String.format("%s%c%s", login(), SEPARATOR, password()); + } + + public static SqlCredential fromFormattedString(String sqlCredential) { + List items = com.google.common.base.Splitter.on(SEPARATOR).splitToList(sqlCredential); + checkState(items.size() == 2, "Invalid SqlCredential string."); + return of(items.get(0), items.get(1)); + } + + public static SqlCredential of(String login, String password) { + return new AutoValue_SqlCredential(login, password); + } +} diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SqlCredentialStore.java b/core/src/main/java/google/registry/privileges/secretmanager/SqlCredentialStore.java new file mode 100644 index 000000000..078fc91f7 --- /dev/null +++ b/core/src/main/java/google/registry/privileges/secretmanager/SqlCredentialStore.java @@ -0,0 +1,118 @@ +// Copyright 2020 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.privileges.secretmanager; + +import com.google.cloud.secretmanager.v1.SecretVersionName; +import google.registry.config.RegistryConfig.Config; +import google.registry.privileges.secretmanager.SecretManagerClient.NoSuchSecretResourceException; +import google.registry.privileges.secretmanager.SecretManagerClient.SecretAlreadyExistsException; +import java.util.Optional; +import javax.inject.Inject; + +/** + * Storage of SQL users' login credentials, backed by Cloud Secret Manager. + * + *

A user's credential is stored with one level of indirection using two secret IDs: Each version + * of the credential data is stored as follows: its secret ID is determined by {@link + * #getCredentialDataSecretId(SqlUser, String dbInstance)}, and the value of each version is a + * {@link SqlCredential}, serialized using {@link SqlCredential#toFormattedString}. The 'live' + * version of the credential is saved under the 'live pointer' secret explained below. + * + *

The pointer to the 'live' version of the credential data is stored as follows: its secret ID + * is determined by {@link #getLiveLabelSecretId(SqlUser, String dbInstance)}; and the value of each + * version is a {@link SecretVersionName} in String form, pointing to a version of the credential + * data. Only the 'latest' version of this secret should be used. It is guaranteed to be valid. + * + *

The indirection in credential storage makes it easy to handle failures in the credential + * change process. + */ +public class SqlCredentialStore { + private final SecretManagerClient csmClient; + private final String dbInstance; + + @Inject + SqlCredentialStore( + SecretManagerClient csmClient, @Config("cloudSqlDbInstanceName") String dbInstance) { + this.csmClient = csmClient; + this.dbInstance = dbInstance; + } + + public SqlCredential getCredential(SqlUser user) { + SecretVersionName credentialName = getLiveCredentialSecretVersion(user); + return SqlCredential.fromFormattedString( + csmClient.getSecretData( + credentialName.getSecret(), Optional.of(credentialName.getSecretVersion()))); + } + + public void createOrUpdateCredential(SqlUser user, String password) { + SecretVersionName dataName = saveCredentialData(user, password); + saveLiveLabel(user, dataName); + } + + public void deleteCredential(SqlUser user) { + try { + csmClient.deleteSecret(getCredentialDataSecretId(user, dbInstance)); + } catch (NoSuchSecretResourceException e) { + // ok + } + try { + csmClient.deleteSecret(getLiveLabelSecretId(user, dbInstance)); + } catch (NoSuchSecretResourceException e) { + // ok. + } + } + + private void createSecretIfAbsent(String secretId) { + try { + csmClient.createSecret(secretId); + } catch (SecretAlreadyExistsException ignore) { + // Not a problem. + } + } + + private SecretVersionName saveCredentialData(SqlUser user, String password) { + String credentialDataSecretId = getCredentialDataSecretId(user, dbInstance); + createSecretIfAbsent(credentialDataSecretId); + String credentialVersion = + csmClient.addSecretVersion( + credentialDataSecretId, + SqlCredential.of(createDatabaseLoginName(user), password).toFormattedString()); + return SecretVersionName.of(csmClient.getProject(), credentialDataSecretId, credentialVersion); + } + + private void saveLiveLabel(SqlUser user, SecretVersionName dataVersionName) { + String liveLabelSecretId = getLiveLabelSecretId(user, dbInstance); + createSecretIfAbsent(liveLabelSecretId); + csmClient.addSecretVersion(liveLabelSecretId, dataVersionName.toString()); + } + + private SecretVersionName getLiveCredentialSecretVersion(SqlUser user) { + return SecretVersionName.parse( + csmClient.getSecretData(getLiveLabelSecretId(user, dbInstance), Optional.empty())); + } + + private static String getLiveLabelSecretId(SqlUser user, String dbInstance) { + return String.format("sql-cred-live-label-%s-%s", user.geUserName(), dbInstance); + } + + private static String getCredentialDataSecretId(SqlUser user, String dbInstance) { + return String.format("sql-cred-data-%s-%s", user.geUserName(), dbInstance); + } + + // WIP: when b/170230882 is complete, login will be versioned. + private static String createDatabaseLoginName(SqlUser user) { + return user.geUserName(); + } +} diff --git a/core/src/main/java/google/registry/privileges/secretmanager/SqlUser.java b/core/src/main/java/google/registry/privileges/secretmanager/SqlUser.java new file mode 100644 index 000000000..394570824 --- /dev/null +++ b/core/src/main/java/google/registry/privileges/secretmanager/SqlUser.java @@ -0,0 +1,62 @@ +// Copyright 2020 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.privileges.secretmanager; + +import com.google.common.base.Ascii; + +/** + * SQL user information for privilege management purposes. + * + *

A {@link RobotUser} represents a software system accessing the database using its own + * credential. Robots are well known and enumerated in {@link RobotId}. + */ +public abstract class SqlUser { + + private final UserType type; + private final String userName; + + protected SqlUser(UserType type, String userName) { + this.type = type; + this.userName = userName; + } + + public UserType getType() { + return type; + } + + public String geUserName() { + return userName; + } + + /** Cloud SQL user types. Please see class javadoc of {@link SqlUser} for more information. */ + enum UserType { + // Work in progress. Human user will be added. + ROBOT + } + + /** Enumerates the {@link RobotUser RobotUsers} in the system. */ + public enum RobotId { + NOMULUS; + } + + /** Information of a RobotUser for privilege management purposes. */ + // Work in progress. Eventually will be provided based on configuration. + public static class RobotUser extends SqlUser { + + public RobotUser(RobotId robot) { + super(UserType.ROBOT, Ascii.toLowerCase(robot.name())); + } + } +} diff --git a/core/src/main/java/google/registry/tools/GetSqlCredentialCommand.java b/core/src/main/java/google/registry/tools/GetSqlCredentialCommand.java new file mode 100644 index 000000000..0a56c4cc1 --- /dev/null +++ b/core/src/main/java/google/registry/tools/GetSqlCredentialCommand.java @@ -0,0 +1,73 @@ +// Copyright 2020 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.tools; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.base.Ascii; +import google.registry.privileges.secretmanager.SecretManagerClient.SecretManagerException; +import google.registry.privileges.secretmanager.SqlCredential; +import google.registry.privileges.secretmanager.SqlCredentialStore; +import google.registry.privileges.secretmanager.SqlUser; +import google.registry.privileges.secretmanager.SqlUser.RobotUser; +import google.registry.tools.params.PathParameter; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import javax.inject.Inject; + +/** + * Command to get a Cloud SQL credential in the Secret Manager. + * + *

This command is a short-term tool that will be deprecated by the planned privilege server. + */ +@Parameters(separators = " =", commandDescription = "Get the Cloud SQL Credential for a given user") +public class GetSqlCredentialCommand implements Command { + + @Inject SqlCredentialStore store; + + @Parameter(names = "--user", description = "The Cloud SQL user.", required = true) + private String user; + + @Parameter( + names = {"-o", "--output"}, + description = "Name of output file for key data.", + validateWith = PathParameter.OutputFile.class) + private Path outputPath = null; + + @Inject + GetSqlCredentialCommand() {} + + @Override + public void run() throws Exception { + SqlUser sqlUser = new RobotUser(SqlUser.RobotId.valueOf(Ascii.toUpperCase(user))); + + SqlCredential credential; + try { + credential = store.getCredential(sqlUser); + } catch (SecretManagerException e) { + System.out.println(e.getMessage()); + return; + } + + if (outputPath == null) { + System.out.printf("[%s]\n", credential.toFormattedString()); + return; + } + try (FileOutputStream out = new FileOutputStream(outputPath.toFile())) { + out.write(credential.toFormattedString().getBytes(StandardCharsets.UTF_8)); + } + } +} diff --git a/core/src/main/java/google/registry/tools/RegistryTool.java b/core/src/main/java/google/registry/tools/RegistryTool.java index 285fc080f..881260df7 100644 --- a/core/src/main/java/google/registry/tools/RegistryTool.java +++ b/core/src/main/java/google/registry/tools/RegistryTool.java @@ -78,6 +78,7 @@ public final class RegistryTool { .put("get_routing_map", GetRoutingMapCommand.class) .put("get_schema", GetSchemaCommand.class) .put("get_schema_tree", GetSchemaTreeCommand.class) + .put("get_sql_credential", GetSqlCredentialCommand.class) .put("get_tld", GetTldCommand.class) .put("ghostryde", GhostrydeCommand.class) .put("hash_certificate", HashCertificateCommand.class) @@ -104,6 +105,7 @@ public final class RegistryTool { .put("resave_entities", ResaveEntitiesCommand.class) .put("resave_environment_entities", ResaveEnvironmentEntitiesCommand.class) .put("resave_epp_resource", ResaveEppResourceCommand.class) + .put("save_sql_credential", SaveSqlCredentialCommand.class) .put("send_escrow_report_to_icann", SendEscrowReportToIcannCommand.class) .put("set_num_instances", SetNumInstancesCommand.class) .put("setup_ote", SetupOteCommand.class) diff --git a/core/src/main/java/google/registry/tools/RegistryToolComponent.java b/core/src/main/java/google/registry/tools/RegistryToolComponent.java index 09943422b..7d0f82a80 100644 --- a/core/src/main/java/google/registry/tools/RegistryToolComponent.java +++ b/core/src/main/java/google/registry/tools/RegistryToolComponent.java @@ -34,6 +34,7 @@ import google.registry.keyring.kms.KmsModule; import google.registry.persistence.PersistenceModule; import google.registry.persistence.PersistenceModule.NomulusToolJpaTm; import google.registry.persistence.transaction.JpaTransactionManager; +import google.registry.privileges.secretmanager.SecretManagerModule; import google.registry.rde.RdeModule; import google.registry.request.Modules.DatastoreServiceModule; import google.registry.request.Modules.Jackson2Module; @@ -74,6 +75,7 @@ import javax.inject.Singleton; PersistenceModule.class, RdeModule.class, RequestFactoryModule.class, + SecretManagerModule.class, URLFetchServiceModule.class, UrlFetchTransportModule.class, UserServiceModule.class, @@ -118,6 +120,8 @@ interface RegistryToolComponent { void inject(GetOperationStatusCommand command); + void inject(GetSqlCredentialCommand command); + void inject(GhostrydeCommand command); void inject(ImportDatastoreCommand command); @@ -138,6 +142,8 @@ interface RegistryToolComponent { void inject(RenewDomainCommand command); + void inject(SaveSqlCredentialCommand command); + void inject(SendEscrowReportToIcannCommand command); void inject(SetNumInstancesCommand command); diff --git a/core/src/main/java/google/registry/tools/SaveSqlCredentialCommand.java b/core/src/main/java/google/registry/tools/SaveSqlCredentialCommand.java new file mode 100644 index 000000000..81c3279ca --- /dev/null +++ b/core/src/main/java/google/registry/tools/SaveSqlCredentialCommand.java @@ -0,0 +1,69 @@ +// Copyright 2020 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.tools; + +import com.beust.jcommander.Parameter; +import com.beust.jcommander.Parameters; +import com.google.common.base.Ascii; +import google.registry.privileges.secretmanager.SqlCredentialStore; +import google.registry.privileges.secretmanager.SqlUser; +import google.registry.privileges.secretmanager.SqlUser.RobotUser; +import google.registry.tools.params.PathParameter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import javax.inject.Inject; + +/** + * Command to create or update a Cloud SQL credential in the Secret Manager. + * + *

This command is a short-term tool that will be deprecated by the planned privilege server. + */ +@Parameters( + separators = " =", + commandDescription = "Create or update the Cloud SQL Credential for a given user") +public class SaveSqlCredentialCommand implements Command { + + @Inject SqlCredentialStore store; + + @Parameter(names = "--user", description = "The Cloud SQL user.", required = true) + private String user; + + @Parameter( + names = {"--input"}, + description = + "Name of input file for the password. If absent, command will prompt for " + + "password in console.", + validateWith = PathParameter.InputFile.class) + private Path inputPath = null; + + @Inject + SaveSqlCredentialCommand() {} + + @Override + public void run() throws Exception { + String password = getPassword(); + SqlUser sqlUser = new RobotUser(SqlUser.RobotId.valueOf(Ascii.toUpperCase(user))); + store.createOrUpdateCredential(sqlUser, password); + System.out.printf("\nDone:[%s]\n", password); + } + + private String getPassword() throws Exception { + if (inputPath != null) { + return Files.readAllLines(inputPath, StandardCharsets.UTF_8).get(0); + } + return System.console().readLine("Please enter the password: ").trim(); + } +} diff --git a/core/src/test/java/google/registry/privileges/secretmanager/FakeSecretManagerClient.java b/core/src/test/java/google/registry/privileges/secretmanager/FakeSecretManagerClient.java index 9399e8b80..46c56534c 100644 --- a/core/src/test/java/google/registry/privileges/secretmanager/FakeSecretManagerClient.java +++ b/core/src/test/java/google/registry/privileges/secretmanager/FakeSecretManagerClient.java @@ -32,6 +32,11 @@ public class FakeSecretManagerClient implements SecretManagerClient { @Inject FakeSecretManagerClient() {} + @Override + public String getProject() { + return "fake_project"; + } + @Override public void createSecret(String secretId) { checkNotNull(secretId, "secretId"); @@ -41,6 +46,12 @@ public class FakeSecretManagerClient implements SecretManagerClient { secrets.put(secretId, new SecretEntry(secretId)); } + @Override + public boolean secretExists(String secretId) { + checkNotNull(secretId, "secretId"); + return secrets.containsKey(secretId); + } + @Override public Iterable listSecrets() { return ImmutableSet.copyOf(secrets.keySet()); @@ -78,6 +89,28 @@ public class FakeSecretManagerClient implements SecretManagerClient { return secretEntry.getVersion(version).getData(); } + @Override + public void enableSecretVersion(String secretId, String version) { + checkNotNull(secretId, "secretId"); + checkNotNull(version, "version"); + SecretEntry secretEntry = secrets.get(secretId); + if (secretEntry == null) { + throw new NoSuchSecretResourceException(null); + } + secretEntry.enableVersion(version); + } + + @Override + public void disableSecretVersion(String secretId, String version) { + checkNotNull(secretId, "secretId"); + checkNotNull(version, "version"); + SecretEntry secretEntry = secrets.get(secretId); + if (secretEntry == null) { + throw new NoSuchSecretResourceException(null); + } + secretEntry.disableVersion(version); + } + @Override public void destroySecretVersion(String secretId, String version) { checkNotNull(secretId, "secretId"); @@ -118,6 +151,20 @@ public class FakeSecretManagerClient implements SecretManagerClient { return state; } + void enable() { + if (state.equals(State.DESTROYED)) { + throw new SecretManagerException(null); + } + state = State.ENABLED; + } + + void disable() { + if (state.equals(State.DESTROYED)) { + throw new SecretManagerException(null); + } + state = State.DISABLED; + } + void destroy() { data = null; state = State.DESTROYED; @@ -145,6 +192,8 @@ public class FakeSecretManagerClient implements SecretManagerClient { return versions.get(index); } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid version " + version.get()); + } catch (ArrayIndexOutOfBoundsException e) { + throw new NoSuchSecretResourceException(null); } } @@ -156,15 +205,16 @@ public class FakeSecretManagerClient implements SecretManagerClient { return builder.build(); } + void enableVersion(String version) { + getVersion(Optional.of(version)).enable(); + } + + void disableVersion(String version) { + getVersion(Optional.of(version)).disable(); + } + void destroyVersion(String version) { - try { - int index = Integer.valueOf(version); - versions.get(index).destroy(); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid version " + version); - } catch (ArrayIndexOutOfBoundsException e) { - throw new NoSuchSecretResourceException(null); - } + getVersion(Optional.of(version)).destroy(); } } } diff --git a/core/src/test/java/google/registry/privileges/secretmanager/SecretManagerClientTest.java b/core/src/test/java/google/registry/privileges/secretmanager/SecretManagerClientTest.java index 283631fad..a626a80e1 100644 --- a/core/src/test/java/google/registry/privileges/secretmanager/SecretManagerClientTest.java +++ b/core/src/test/java/google/registry/privileges/secretmanager/SecretManagerClientTest.java @@ -18,12 +18,17 @@ import static com.google.common.truth.Truth.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; import com.google.cloud.secretmanager.v1.SecretVersion.State; +import google.registry.privileges.secretmanager.SecretManagerClient.NoSuchSecretResourceException; import google.registry.privileges.secretmanager.SecretManagerClient.SecretAlreadyExistsException; import google.registry.privileges.secretmanager.SecretManagerClient.SecretManagerException; +import google.registry.util.Retrier; +import google.registry.util.SystemSleeper; +import java.io.IOException; import java.util.Optional; import java.util.UUID; -import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** @@ -43,52 +48,50 @@ public class SecretManagerClientTest { private static final String SECRET_ID_PREFIX = "TEST_" + UUID.randomUUID() + "_"; // Used for unique secret id generation. private static int seqno = 0; - private static SecretManagerClient secretManagerClient; private static boolean isUnitTest = true; - private static String nextSecretId() { - return SECRET_ID_PREFIX + seqno++; - } + private String secretId; @BeforeAll static void beforeAll() { String environmentName = System.getProperty("test.gcp_integration.env"); if (environmentName != null) { secretManagerClient = - DaggerSecretManagerModule_SecretManagerComponent.builder() - .secretManagerModule( - new SecretManagerModule(String.format("domain-registry-%s", environmentName))) - .build() - .secretManagerClient(); + SecretManagerModule.provideSecretManagerClient( + String.format("domain-registry-%s", environmentName), + new Retrier(new SystemSleeper(), 1)); isUnitTest = false; } else { secretManagerClient = new FakeSecretManagerClient(); } } - @AfterAll - static void afterAll() { + @BeforeEach + void beforeEach() { + secretId = SECRET_ID_PREFIX + seqno++; + } + + @AfterEach + void afterEach() throws IOException { if (isUnitTest) { return; } - for (String secretId : secretManagerClient.listSecrets()) { - if (secretId.startsWith(SECRET_ID_PREFIX)) { - secretManagerClient.deleteSecret(secretId); - } + try { + secretManagerClient.deleteSecret(secretId); + } catch (NoSuchSecretResourceException e) { + // deleteSecret() deleted it already. } } @Test void createSecret_success() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); assertThat(secretManagerClient.listSecrets()).contains(secretId); } @Test void createSecret_duplicate() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); assertThrows( SecretAlreadyExistsException.class, () -> secretManagerClient.createSecret(secretId)); @@ -96,16 +99,25 @@ public class SecretManagerClientTest { @Test void addSecretVersion() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); String version = secretManagerClient.addSecretVersion(secretId, "mydata"); assertThat(secretManagerClient.listSecretVersions(secretId, State.ENABLED)) .containsExactly(version); } + @Test + void secretExists_true() { + secretManagerClient.createSecret(secretId); + assertThat(secretManagerClient.secretExists(secretId)).isTrue(); + } + + @Test + void secretExists_False() { + assertThat(secretManagerClient.secretExists(secretId)).isFalse(); + } + @Test void getSecretData_byVersion() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); String version = secretManagerClient.addSecretVersion(secretId, "mydata"); assertThat(secretManagerClient.getSecretData(secretId, Optional.of(version))) @@ -114,15 +126,70 @@ public class SecretManagerClientTest { @Test void getSecretData_latestVersion() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); secretManagerClient.addSecretVersion(secretId, "mydata"); assertThat(secretManagerClient.getSecretData(secretId, Optional.empty())).isEqualTo("mydata"); } + @Test + void disableSecretVersion() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + secretManagerClient.disableSecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.DISABLED)).contains(version); + } + + @Test + void disableSecretVersion_ignoreAlreadyDisabled() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + secretManagerClient.disableSecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.DISABLED)).contains(version); + secretManagerClient.disableSecretVersion(secretId, version); + } + + @Test + void disableSecretVersion_destroyed() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + secretManagerClient.destroySecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.DESTROYED)).contains(version); + assertThrows( + SecretManagerException.class, + () -> secretManagerClient.disableSecretVersion(secretId, version)); + } + + @Test + void enableSecretVersion_ignoreAlreadyEnabled() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + assertThat(secretManagerClient.listSecretVersions(secretId, State.ENABLED)).contains(version); + secretManagerClient.enableSecretVersion(secretId, version); + } + + @Test + void enableSecretVersion() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + secretManagerClient.disableSecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.DISABLED)).contains(version); + secretManagerClient.enableSecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.ENABLED)).contains(version); + } + + @Test + void enableSecretVersion_destroyed() { + secretManagerClient.createSecret(secretId); + String version = secretManagerClient.addSecretVersion(secretId, "mydata"); + secretManagerClient.destroySecretVersion(secretId, version); + assertThat(secretManagerClient.listSecretVersions(secretId, State.DESTROYED)).contains(version); + assertThrows( + SecretManagerException.class, + () -> secretManagerClient.enableSecretVersion(secretId, version)); + } + @Test void destroySecretVersion() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); String version = secretManagerClient.addSecretVersion(secretId, "mydata"); secretManagerClient.destroySecretVersion(secretId, version); @@ -134,7 +201,6 @@ public class SecretManagerClientTest { @Test void deleteSecret() { - String secretId = nextSecretId(); secretManagerClient.createSecret(secretId); assertThat(secretManagerClient.listSecrets()).contains(secretId); secretManagerClient.deleteSecret(secretId); diff --git a/core/src/test/java/google/registry/privileges/secretmanager/SqlCredentialStoreTest.java b/core/src/test/java/google/registry/privileges/secretmanager/SqlCredentialStoreTest.java new file mode 100644 index 000000000..5eba4dcd4 --- /dev/null +++ b/core/src/test/java/google/registry/privileges/secretmanager/SqlCredentialStoreTest.java @@ -0,0 +1,63 @@ +// Copyright 2020 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.privileges.secretmanager; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.cloud.secretmanager.v1.SecretVersionName; +import google.registry.privileges.secretmanager.SqlUser.RobotId; +import google.registry.privileges.secretmanager.SqlUser.RobotUser; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +/** Unit tests for {@link SqlCredentialStore}. */ +public class SqlCredentialStoreTest { + + private final SecretManagerClient client = new FakeSecretManagerClient(); + private final SqlCredentialStore credentialStore = new SqlCredentialStore(client, "db"); + private SqlUser user = new RobotUser(RobotId.NOMULUS); + + @Test + void createSecret() { + credentialStore.createOrUpdateCredential(user, "password"); + assertThat(client.secretExists("sql-cred-live-label-nomulus-db")).isTrue(); + assertThat( + SecretVersionName.parse( + client.getSecretData("sql-cred-live-label-nomulus-db", Optional.empty())) + .getSecret()) + .isEqualTo("sql-cred-data-nomulus-db"); + assertThat(client.secretExists("sql-cred-data-nomulus-db")).isTrue(); + assertThat(client.getSecretData("sql-cred-data-nomulus-db", Optional.empty())) + .isEqualTo("nomulus password"); + } + + @Test + void getCredential() { + credentialStore.createOrUpdateCredential(user, "password"); + SqlCredential credential = credentialStore.getCredential(user); + assertThat(credential.login()).isEqualTo("nomulus"); + assertThat(credential.password()).isEqualTo("password"); + } + + @Test + void deleteCredential() { + credentialStore.createOrUpdateCredential(user, "password"); + assertThat(client.secretExists("sql-cred-live-label-nomulus-db")).isTrue(); + assertThat(client.secretExists("sql-cred-data-nomulus-db")).isTrue(); + credentialStore.deleteCredential(user); + assertThat(client.secretExists("sql-cred-live-label-nomulus-db")).isFalse(); + assertThat(client.secretExists("sql-cred-data-nomulus-db")).isFalse(); + } +}