/* * Copyright (C) 2015 Square, Inc. * * 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 keywhiz.service.daos; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import java.time.Instant; import java.util.AbstractMap.SimpleEntry; import java.util.List; import java.util.Map; import java.util.Optional; import javax.annotation.Nullable; import javax.inject.Inject; import javax.ws.rs.NotFoundException; import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2; import keywhiz.api.model.Group; import keywhiz.api.model.SanitizedSecret; import keywhiz.api.model.Secret; import keywhiz.api.model.SecretContent; import keywhiz.api.model.SecretSeries; import keywhiz.api.model.SecretSeriesAndContent; import keywhiz.jooq.tables.Secrets; import keywhiz.service.config.Readonly; import keywhiz.service.crypto.ContentCryptographer; import keywhiz.service.crypto.ContentEncodingException; import keywhiz.service.daos.SecretContentDAO.SecretContentDAOFactory; import keywhiz.service.daos.SecretSeriesDAO.SecretSeriesDAOFactory; import org.jooq.Configuration; import org.jooq.DSLContext; import org.jooq.exception.DataAccessException; import org.jooq.impl.DSL; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static java.lang.String.format; import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.stream.Collectors.toList; import static keywhiz.jooq.tables.Secrets.SECRETS; /** * Primary class to interact with {@link Secret}s. * * Does not map to a table itself, but utilizes both {@link SecretSeriesDAO} and * {@link SecretContentDAO} to provide a more usable API. */ public class SecretDAO { private final DSLContext dslContext; private final SecretContentDAOFactory secretContentDAOFactory; private final SecretSeriesDAOFactory secretSeriesDAOFactory; private final ContentCryptographer cryptographer; private SecretDAO(DSLContext dslContext, SecretContentDAOFactory secretContentDAOFactory, SecretSeriesDAOFactory secretSeriesDAOFactory, ContentCryptographer cryptographer) { this.dslContext = dslContext; this.secretContentDAOFactory = secretContentDAOFactory; this.secretSeriesDAOFactory = secretSeriesDAOFactory; this.cryptographer = cryptographer; } @VisibleForTesting public long createSecret(String name, String encryptedSecret, String hmac, String creator, Map<String, String> metadata, long expiry, String description, @Nullable String type, @Nullable Map<String, String> generationOptions) { return dslContext.transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); Optional<SecretSeries> secretSeries = secretSeriesDAO.getSecretSeriesByName(name); long secretId; if (secretSeries.isPresent()) { SecretSeries secretSeries1 = secretSeries.get(); if (secretSeries1.currentVersion().isPresent()) { throw new DataAccessException(format("secret already present: %s", name)); } else { // Unreachable unless the implementation of getSecretSeriesByName is changed throw new IllegalStateException(format("secret %s retrieved without current version set", name)); } } else { secretId = secretSeriesDAO.createSecretSeries(name, creator, description, type, generationOptions); } long secretContentId = secretContentDAO.createSecretContent(secretId, encryptedSecret, hmac, creator, metadata, expiry); secretSeriesDAO.setCurrentVersion(secretId, secretContentId); return secretId; }); } @VisibleForTesting public long createOrUpdateSecret(String name, String encryptedSecret, String hmac, String creator, Map<String, String> metadata, long expiry, String description, @Nullable String type, @Nullable Map<String, String> generationOptions) { return dslContext.transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); Optional<SecretSeries> secretSeries = secretSeriesDAO.getSecretSeriesByName(name); long secretId; if (secretSeries.isPresent()) { SecretSeries secretSeries1 = secretSeries.get(); secretId = secretSeries1.id(); secretSeriesDAO.updateSecretSeries(secretId, name, creator, description, type, generationOptions); } else { secretId = secretSeriesDAO.createSecretSeries(name, creator, description, type, generationOptions); } long secretContentId = secretContentDAO.createSecretContent(secretId, encryptedSecret, hmac, creator, metadata, expiry); secretSeriesDAO.setCurrentVersion(secretId, secretContentId); return secretId; }); } @VisibleForTesting public long partialUpdateSecret(String name, String creator, PartialUpdateSecretRequestV2 request) { return dslContext.transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); // Get the current version of the secret, throwing exceptions if it is not found SecretSeries secretSeries = secretSeriesDAO.getSecretSeriesByName(name).orElseThrow( NotFoundException::new); Long currentVersion = secretSeries.currentVersion().orElseThrow(NotFoundException::new); SecretContent secretContent = secretContentDAO.getSecretContentById(currentVersion).orElseThrow(NotFoundException::new); long secretId = secretSeries.id(); // Set the fields to the original series and current version's values or the request values if provided String description = request.descriptionPresent() ? request.description() : secretSeries.description(); String type = request.typePresent() ? request.type() : secretSeries.type().orElse(""); ImmutableMap<String, String> metadata = request.metadataPresent() ? request.metadata() : secretContent.metadata(); Long expiry = request.expiryPresent() ? request.expiry() : secretContent.expiry(); String encryptedContent = secretContent.encryptedContent(); String hmac = secretContent.hmac(); // Mirrors hmac-creation in SecretController if (request.contentPresent()) { hmac = cryptographer.computeHmac(request.content().getBytes(UTF_8)); // Compute HMAC on base64 encoded data if (hmac == null) { throw new ContentEncodingException("Error encoding content for SecretBuilder!"); } encryptedContent = cryptographer.encryptionKeyDerivedFrom(name).encrypt(request.content()); } secretSeriesDAO.updateSecretSeries(secretId, name, creator, description, type, secretSeries.generationOptions()); long secretContentId = secretContentDAO.createSecretContent(secretId, encryptedContent, hmac, creator, metadata, expiry); secretSeriesDAO.setCurrentVersion(secretId, secretContentId); return secretId; }); } public boolean setExpiration(String name, Instant expiration) { return dslContext.transactionResult(configuration -> { SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); Optional<SecretSeries> secretSeries = secretSeriesDAO.getSecretSeriesByName(name); if (secretSeries.isPresent()) { Optional<Long> currentVersion = secretSeries.get().currentVersion(); if (currentVersion.isPresent()) { return secretSeriesDAO.setExpiration(currentVersion.get(), expiration) > 0; } } return false; }); } /** * @param secretId external secret series id to look up secrets by. * @return Secret matching input parameters or Optional.absent(). */ public Optional<SecretSeriesAndContent> getSecretById(long secretId) { return dslContext.<Optional<SecretSeriesAndContent>>transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); Optional<SecretSeries> series = secretSeriesDAO.getSecretSeriesById(secretId); if (series.isPresent() && series.get().currentVersion().isPresent()) { long secretContentId = series.get().currentVersion().get(); Optional<SecretContent> contents = secretContentDAO.getSecretContentById(secretContentId); if (!contents.isPresent()) { throw new IllegalStateException(format("failed to fetch secret %d, content %d not found.", secretId, secretContentId)); } return Optional.of(SecretSeriesAndContent.of(series.get(), contents.get())); } return Optional.empty(); }); } /** * @param name of secret series to look up secrets by. * @return Secret matching input parameters or Optional.absent(). */ public Optional<SecretSeriesAndContent> getSecretByName(String name) { checkArgument(!name.isEmpty()); // In the past, the two data fetches below were wrapped in a transaction. The transaction was // removed because jOOQ transactions doesn't play well with MySQL readonly connections // (see https://github.com/jOOQ/jOOQ/issues/3955). // // A possible work around is to write a transaction manager (see http://git.io/vkuFM) // // Removing the transaction however seems to be simpler and safe. The first data fetch's // secret.id is used for the second data fetch. // // A third way to work around this issue is to write a SQL join. Jooq makes it relatively easy, // but such joins hurt code re-use. SecretContentDAO secretContentDAO = secretContentDAOFactory.using(dslContext.configuration()); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(dslContext.configuration()); Optional<SecretSeries> series = secretSeriesDAO.getSecretSeriesByName(name); if (series.isPresent() && series.get().currentVersion().isPresent()) { long secretContentId = series.get().currentVersion().get(); Optional<SecretContent> secretContent = secretContentDAO.getSecretContentById(secretContentId); if (!secretContent.isPresent()) { return Optional.empty(); } return Optional.of(SecretSeriesAndContent.of(series.get(), secretContent.get())); } return Optional.empty(); } /** @return list of secrets. can limit/sort by expiry, and for group if given */ public ImmutableList<SecretSeriesAndContent> getSecrets(@Nullable Long expireMaxTime, Group group) { return dslContext.transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); ImmutableList.Builder<SecretSeriesAndContent> secretsBuilder = ImmutableList.builder(); for (SecretSeries series : secretSeriesDAO.getSecretSeries(expireMaxTime, group)) { SecretContent content = secretContentDAO.getSecretContentById(series.currentVersion().get()).get(); SecretSeriesAndContent seriesAndContent = SecretSeriesAndContent.of(series, content); secretsBuilder.add(seriesAndContent); } return secretsBuilder.build(); }); } /** * @return A list of id, name */ public ImmutableList<SimpleEntry<Long, String>> getSecretsNameOnly() { List<SimpleEntry<Long, String>> results = dslContext.select(SECRETS.ID, SECRETS.NAME) .from(SECRETS) .where(SECRETS.CURRENT.isNotNull()) .fetchInto(Secrets.SECRETS) .map(r -> new SimpleEntry<>(r.getId(), r.getName())); return ImmutableList.copyOf(results); } /** * @param idx the first index to select in a list of secrets sorted by creation time * @param num the number of secrets after idx to select in the list of secrets * @param newestFirst if true, order the secrets from newest creation time to oldest * @return A list of secrets */ public ImmutableList<SecretSeriesAndContent> getSecretsBatched(int idx, int num, boolean newestFirst) { return dslContext.transactionResult(configuration -> { SecretContentDAO secretContentDAO = secretContentDAOFactory.using(configuration); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(configuration); ImmutableList.Builder<SecretSeriesAndContent> secretsBuilder = ImmutableList.builder(); for (SecretSeries series : secretSeriesDAO.getSecretSeriesBatched(idx, num, newestFirst)) { SecretContent content = secretContentDAO.getSecretContentById(series.currentVersion().get()).get(); SecretSeriesAndContent seriesAndContent = SecretSeriesAndContent.of(series, content); secretsBuilder.add(seriesAndContent); } return secretsBuilder.build(); }); } /** * @param name of secret series to look up secrets by. * @param versionIdx the first index to select in a list of versions sorted by creation time * @param numVersions the number of versions after versionIdx to select in the list of versions * @return Versions of a secret matching input parameters or Optional.absent(). */ public Optional<ImmutableList<SanitizedSecret>> getSecretVersionsByName(String name, int versionIdx, int numVersions) { checkArgument(!name.isEmpty()); checkArgument(versionIdx >= 0); checkArgument(numVersions >= 0); SecretContentDAO secretContentDAO = secretContentDAOFactory.using(dslContext.configuration()); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(dslContext.configuration()); Optional<SecretSeries> series = secretSeriesDAO.getSecretSeriesByName(name); if (series.isPresent()) { SecretSeries s = series.get(); long secretId = s.id(); Optional<ImmutableList<SecretContent>> contents = secretContentDAO.getSecretVersionsBySecretId(secretId, versionIdx, numVersions); if (contents.isPresent()) { ImmutableList.Builder<SanitizedSecret> b = new ImmutableList.Builder<>(); b.addAll(contents.get() .stream() .map(c -> SanitizedSecret.fromSecretSeriesAndContent(SecretSeriesAndContent.of(s, c))) .collect(toList())); return Optional.of(b.build()); } } return Optional.empty(); } /** * @param name of secret series for which to reset secret version * @param versionId The identifier for the desired current version * @throws NotFoundException if secret not found */ public void setCurrentSecretVersionByName(String name, long versionId) { checkArgument(!name.isEmpty()); checkArgument(versionId >= 0); SecretSeriesDAO secretSeriesDAO = secretSeriesDAOFactory.using(dslContext.configuration()); SecretSeries series = secretSeriesDAO.getSecretSeriesByName(name).orElseThrow( NotFoundException::new); secretSeriesDAO.setCurrentVersion(series.id(), versionId); } /** * Deletes the series and all associated version of the given secret series name. * * @param name of secret series to delete. */ public void deleteSecretsByName(String name) { checkArgument(!name.isEmpty()); secretSeriesDAOFactory.using(dslContext.configuration()) .deleteSecretSeriesByName(name); } public static class SecretDAOFactory implements DAOFactory<SecretDAO> { private final DSLContext jooq; private final DSLContext readonlyJooq; private final SecretContentDAOFactory secretContentDAOFactory; private final SecretSeriesDAOFactory secretSeriesDAOFactory; private final ContentCryptographer cryptographer; @Inject public SecretDAOFactory(DSLContext jooq, @Readonly DSLContext readonlyJooq, SecretContentDAOFactory secretContentDAOFactory, SecretSeriesDAOFactory secretSeriesDAOFactory, ContentCryptographer cryptographer) { this.jooq = jooq; this.readonlyJooq = readonlyJooq; this.secretContentDAOFactory = secretContentDAOFactory; this.secretSeriesDAOFactory = secretSeriesDAOFactory; this.cryptographer = cryptographer; } @Override public SecretDAO readwrite() { return new SecretDAO(jooq, secretContentDAOFactory, secretSeriesDAOFactory, cryptographer); } @Override public SecretDAO readonly() { return new SecretDAO(readonlyJooq, secretContentDAOFactory, secretSeriesDAOFactory, cryptographer); } @Override public SecretDAO using(Configuration configuration) { DSLContext dslContext = DSL.using(checkNotNull(configuration)); return new SecretDAO(dslContext, secretContentDAOFactory, secretSeriesDAOFactory, cryptographer); } } }