/*
* 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.resources.admin;
import com.codahale.metrics.annotation.ExceptionMetered;
import com.codahale.metrics.annotation.Timed;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import io.dropwizard.auth.Auth;
import io.dropwizard.jersey.params.LongParam;
import java.net.URI;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import javax.inject.Inject;
import javax.validation.Valid;
import javax.ws.rs.BadRequestException;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.DefaultValue;
import javax.ws.rs.GET;
import javax.ws.rs.NotFoundException;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import keywhiz.api.CreateSecretRequest;
import keywhiz.api.SecretDetailResponse;
import keywhiz.api.automation.v2.CreateOrUpdateSecretRequestV2;
import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2;
import keywhiz.api.model.Client;
import keywhiz.api.model.Group;
import keywhiz.api.model.SanitizedSecret;
import keywhiz.api.model.Secret;
import keywhiz.auth.User;
import keywhiz.log.AuditLog;
import keywhiz.log.Event;
import keywhiz.log.EventTag;
import keywhiz.service.daos.AclDAO;
import keywhiz.service.daos.AclDAO.AclDAOFactory;
import keywhiz.service.daos.SecretController;
import keywhiz.service.daos.SecretDAO;
import keywhiz.service.daos.SecretDAO.SecretDAOFactory;
import keywhiz.service.exceptions.ConflictException;
import org.apache.http.HttpStatus;
import org.jooq.exception.DataAccessException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import static java.lang.String.format;
import static java.util.stream.Collectors.toSet;
import static javax.ws.rs.core.MediaType.APPLICATION_JSON;
/**
* @parentEndpointName secrets-admin
*
* @resourcePath /admin/secrets
* @resourceDescription Create, retrieve, and delete secrets
*/
@Path("/admin/secrets")
@Produces(APPLICATION_JSON)
public class SecretsResource {
private static final Logger logger = LoggerFactory.getLogger(SecretsResource.class);
private final SecretController secretController;
private final AclDAO aclDAOReadOnly;
private final SecretDAO secretDAOReadWrite;
private final SecretDAO secretDAOReadOnly;
private final AuditLog auditLog;
@SuppressWarnings("unused")
@Inject public SecretsResource(SecretController secretController, AclDAOFactory aclDAOFactory,
SecretDAOFactory secretDAOFactory, AuditLog auditLog) {
this.secretController = secretController;
this.aclDAOReadOnly = aclDAOFactory.readonly();
this.secretDAOReadWrite = secretDAOFactory.readwrite();
this.secretDAOReadOnly = secretDAOFactory.readonly();
this.auditLog = auditLog;
}
/** Constructor for testing */
@VisibleForTesting SecretsResource(SecretController secretController, AclDAO aclDAOReadOnly,
SecretDAO secretDAOReadWrite, AuditLog auditLog) {
this.secretController = secretController;
this.aclDAOReadOnly = aclDAOReadOnly;
this.secretDAOReadWrite = secretDAOReadWrite;
this.secretDAOReadOnly = secretDAOReadWrite;
this.auditLog = auditLog;
}
/**
* Retrieve Secret by a specified name and version, or all Secrets if name is not given
*
* @excludeParams user
* @optionalParams name
* @param name the name of the Secret to retrieve, if provided
* @optionalParams version
* @param nameOnly if set, the result only contains the id and name for the secrets.
* @param idx if set, the desired starting index in a list of secrets to be retrieved
* @param num if set, the number of secrets to retrieve
* @param newestFirst whether to order the secrets by creation date with newest first; defaults to true
*
* @description Returns a single Secret or a set of all Secrets for this user.
* Used by Keywhiz CLI and the web ui.
* @responseMessage 200 Found and retrieved Secret(s)
* @responseMessage 404 Secret with given name not found (if name provided)
*/
@Timed @ExceptionMetered
@GET
public Response findSecrets(@Auth User user, @DefaultValue("") @QueryParam("name") String name,
@DefaultValue("") @QueryParam("nameOnly") String nameOnly, @QueryParam("idx") Integer idx,
@QueryParam("num") Integer num,
@DefaultValue("true") @QueryParam("newestFirst") Boolean newestFirst) {
if (!name.isEmpty() && idx != null && num != null) {
throw new BadRequestException("Name and idx/num cannot both be specified");
}
validateArguments(name, nameOnly, idx, num);
if (name.isEmpty()) {
if (nameOnly.isEmpty()) {
if (idx == null || num == null) {
return Response.ok().entity(listSecrets(user)).build();
} else {
return Response.ok().entity(listSecretsBatched(user, idx, num, newestFirst)).build();
}
} else {
return Response.ok().entity(listSecretsNameOnly(user)).build();
}
}
return Response.ok().entity(retrieveSecret(user, name)).build();
}
private void validateArguments(String name, String nameOnly, Integer idx, Integer num) {
if (idx == null && num != null || idx != null && num != null) {
throw new IllegalArgumentException("Both idx and num must be specified");
}
if (!name.isEmpty() && idx != null && num != null) {
throw new IllegalArgumentException("Name, idx, and num must not all be specified");
}
if (nameOnly.isEmpty() && idx != null && num != null) {
throw new IllegalArgumentException("nameOnly option is not valid for batched secret retrieval");
}
}
protected List<SanitizedSecret> listSecrets(@Auth User user) {
logger.info("User '{}' listing secrets.", user);
return secretController.getSanitizedSecrets(null, null);
}
protected List<SanitizedSecret> listSecretsNameOnly(@Auth User user) {
logger.info("User '{}' listing secrets.", user);
return secretController.getSecretsNameOnly();
}
protected List<SanitizedSecret> listSecretsBatched(@Auth User user, int idx, int num, boolean newestFirst) {
logger.info("User '{}' listing secrets with idx '{}', num '{}', newestFirst '{}'.", user, idx, num, newestFirst);
return secretController.getSecretsBatched(idx, num, newestFirst);
}
protected SanitizedSecret retrieveSecret(@Auth User user, String name) {
logger.info("User '{}' retrieving secret name={}.", user, name);
return sanitizedSecretFromName(name);
}
/**
* Create Secret
*
* @excludeParams user
* @param request the JSON client request used to formulate the Secret
*
* @description Creates a Secret with the name from a valid secret request.
* Used by Keywhiz CLI and the web ui.
* @responseMessage 200 Successfully created Secret
* @responseMessage 400 Secret with given name already exists
*/
@Timed @ExceptionMetered
@POST
@Consumes(APPLICATION_JSON)
public Response createSecret(@Auth User user, @Valid CreateSecretRequest request) {
logger.info("User '{}' creating secret '{}'.", user, request.name);
Secret secret;
try {
SecretController.SecretBuilder builder =
secretController.builder(request.name, request.content, user.getName(), request.expiry);
if (request.description != null) {
builder.withDescription(request.description);
}
if (request.metadata != null) {
builder.withMetadata(request.metadata);
}
secret = builder.create();
} catch (DataAccessException e) {
logger.info(format("Cannot create secret %s", request.name), e);
throw new ConflictException(format("Cannot create secret %s.", request.name));
}
URI uri = UriBuilder.fromResource(SecretsResource.class).path("{secretId}").build(secret.getId());
Response response = Response
.created(uri)
.entity(secretDetailResponseFromId(secret.getId()))
.build();
if (response.getStatus() == HttpStatus.SC_CREATED) {
Map<String, String> extraInfo = new HashMap<>();
if (request.description != null) {
extraInfo.put("description", request.description);
}
if (request.metadata != null) {
extraInfo.put("metadata", request.metadata.toString());
}
extraInfo.put("expiry", Long.toString(request.expiry));
auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_CREATE, user.getName(), request.name, extraInfo));
}
// TODO (jessep): Should we also log failures?
return response;
}
/**
* Create or update secret
*
* @excludeParams user
* @param request the JSON client request used to formulate the Secret
*
* @responseMessage 200 Successfully created Secret
*/
@Path("{name}")
@Timed @ExceptionMetered
@POST
@Consumes(APPLICATION_JSON)
public Response createOrUpdateSecret(@Auth User user, @PathParam("name") String secretName, @Valid CreateOrUpdateSecretRequestV2 request) {
logger.info("User '{}' createOrUpdate secret '{}'.", user, secretName);
Secret secret = secretController
.builder(secretName, request.content(), user.getName(), request.expiry())
.withDescription(request.description())
.withMetadata(request.metadata())
.withType(request.type())
.createOrUpdate();
URI uri = UriBuilder.fromResource(SecretsResource.class).path(secretName).build();
Response response = Response.created(uri).entity(secretDetailResponseFromId(secret.getId())).build();
if (response.getStatus() == HttpStatus.SC_CREATED) {
Map<String, String> extraInfo = new HashMap<>();
if (request.description() != null && !request.description().isEmpty()) {
extraInfo.put("description", request.description());
}
if (request.metadata() != null && !request.metadata().isEmpty()) {
extraInfo.put("metadata", request.metadata().toString());
}
extraInfo.put("expiry", Long.toString(request.expiry()));
auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_CREATEORUPDATE, user.getName(), secretName, extraInfo));
}
return response;
}
/**
* Update a subset of the fields of an existing secret
*
* @excludeParams user
* @param request the JSON client request used to formulate the Secret
*
* @responseMessage 200 Successfully updated Secret
*/
@Path("{name}/partialupdate")
@Timed @ExceptionMetered
@POST
@Consumes(APPLICATION_JSON)
public Response partialUpdateSecret(@Auth User user, @PathParam("name") String secretName, @Valid
PartialUpdateSecretRequestV2 request) {
logger.info("User '{}' partialUpdate secret '{}'.", user, secretName);
long id = secretDAOReadWrite.partialUpdateSecret(secretName, user.getName(), request);
URI uri = UriBuilder.fromResource(SecretsResource.class)
.path(secretName)
.path("partialupdate")
.build();
Response response = Response.created(uri).entity(secretDetailResponseFromId(id)).build();
if (response.getStatus() == HttpStatus.SC_CREATED) {
Map<String, String> extraInfo = new HashMap<>();
if (request.descriptionPresent()) {
extraInfo.put("description", request.description());
}
if (request.metadataPresent()) {
extraInfo.put("metadata", request.metadata().toString());
}
if (request.expiryPresent()) {
extraInfo.put("expiry", Long.toString(request.expiry()));
}
auditLog.recordEvent(
new Event(Instant.now(), EventTag.SECRET_UPDATE, user.getName(), secretName, extraInfo));
}
return response;
}
/**
* Retrieve Secret by ID
*
* @excludeParams user
* @param secretId the ID of the secret to retrieve
*
* @description Returns a single Secret if found.
* Used by Keywhiz CLI and the web ui.
* @responseMessage 200 Found and retrieved Secret with given ID
* @responseMessage 404 Secret with given ID not Found
*/
@Path("{secretId}")
@Timed @ExceptionMetered
@GET
public SecretDetailResponse retrieveSecret(@Auth User user,
@PathParam("secretId") LongParam secretId) {
logger.info("User '{}' retrieving secret id={}.", user, secretId);
return secretDetailResponseFromId(secretId.get());
}
/**
* Retrieve the given range of versions of this secret, sorted from newest to
* oldest update time. If versionIdx is nonzero, then numVersions versions,
* starting from versionIdx in the list and increasing in index, will be
* returned (set numVersions to a very large number to retrieve all versions).
* For instance, versionIdx = 5 and numVersions = 10 will retrieve entries
* at indices 5 through 14.
*
* @excludeParams user
* @param name Secret series name
* @param versionIdx The index in the list of versions of the first version to retrieve
* @param numVersions The number of versions to retrieve
* @excludeParams automationClient
* @responseMessage 200 Secret series information retrieved
* @responseMessage 404 Secret series not found
*/
@Timed @ExceptionMetered
@GET
@Path("versions/{name}")
@Produces(APPLICATION_JSON)
public List<SanitizedSecret> secretVersions(@Auth User user,
@PathParam("name") String name, @QueryParam("versionIdx") int versionIdx,
@QueryParam("numVersions") int numVersions) {
logger.info("User '{}' listing {} versions starting at index {} for secret '{}'.", user,
numVersions, versionIdx, name);
ImmutableList<SanitizedSecret> versions =
secretDAOReadOnly.getSecretVersionsByName(name, versionIdx, numVersions)
.orElseThrow(NotFoundException::new);
return versions;
}
/**
* Rollback to a previous secret version
*
* @param secretName the name of the secret to rollback
* @param versionId the ID of the version to return to
* @excludeParams user
* @description Returns the previous versions of the secret if found Used by Keywhiz CLI.
* @responseMessage 200 Found and reset the secret to this version
* @responseMessage 404 Secret with given name not found or invalid version provided
*/
@Path("rollback/{secretName}/{versionId}")
@Timed @ExceptionMetered
@POST
public Response resetSecretVersion(@Auth User user, @PathParam("secretName") String secretName,
@PathParam("versionId") LongParam versionId) {
logger.info("User '{}' rolling back secret '{}' to version with ID '{}'.", user, secretName,
versionId);
secretDAOReadWrite.setCurrentSecretVersionByName(secretName, versionId.get());
// If the secret wasn't found or the request was misformed, setCurrentSecretVersionByName
// already threw an exception
Map<String, String> extraInfo = new HashMap<>();
extraInfo.put("new version", versionId.toString());
auditLog.recordEvent(
new Event(Instant.now(), EventTag.SECRET_CHANGEVERSION, user.getName(), secretName,
extraInfo));
// Send the new secret in response
URI uri = UriBuilder.fromResource(SecretsResource.class).path("rollback/{secretName}/{versionID}").build(secretName, versionId);
return Response.created(uri).entity(secretDetailResponseFromName(secretName)).build();
}
/**
* Delete Secret by ID
*
* @excludeParams user
* @param secretId the ID of the Secret to be deleted
*
* @description Deletes a single Secret if found.
* Used by Keywhiz CLI and the web ui.
* @responseMessage 200 Found and deleted Secret with given ID
* @responseMessage 404 Secret with given ID not Found
*/
@Path("{secretId}")
@Timed @ExceptionMetered
@DELETE
public Response deleteSecret(@Auth User user, @PathParam("secretId") LongParam secretId) {
Optional<Secret> secret = secretController.getSecretById(secretId.get());
if (!secret.isPresent()) {
logger.info("User '{}' tried deleting a secret which was not found (id={})", user, secretId.get());
throw new NotFoundException("Secret not found.");
}
logger.info("User '{}' deleting secret id={}, name='{}'", user, secretId, secret.get().getName());
// Get the groups for this secret, so they can be restored manually if necessary
Set<String> groups = aclDAOReadOnly.getGroupsFor(secret.get()).stream().map(Group::getName).collect(toSet());
secretDAOReadWrite.deleteSecretsByName(secret.get().getName());
// Record the deletion
Map<String, String> extraInfo = new HashMap<>();
extraInfo.put("groups", groups.toString());
extraInfo.put("current version", secret.get().getVersion().toString());
auditLog.recordEvent(new Event(Instant.now(), EventTag.SECRET_DELETE, user.getName(), secret.get().getName(), extraInfo));
return Response.noContent().build();
}
private SecretDetailResponse secretDetailResponseFromId(long secretId) {
Optional<Secret> secrets = secretController.getSecretById(secretId);
if (!secrets.isPresent()) {
throw new NotFoundException("Secret not found.");
}
ImmutableList<Group> groups = ImmutableList.copyOf(aclDAOReadOnly.getGroupsFor(secrets.get()));
ImmutableList<Client> clients = ImmutableList.copyOf(aclDAOReadOnly.getClientsFor(secrets.get()));
return SecretDetailResponse.fromSecret(secrets.get(), groups, clients);
}
private SecretDetailResponse secretDetailResponseFromName(String secretName) {
Optional<Secret> secrets = secretController.getSecretByName(secretName);
if (!secrets.isPresent()) {
throw new NotFoundException("Secret not found.");
}
ImmutableList<Group> groups = ImmutableList.copyOf(aclDAOReadOnly.getGroupsFor(secrets.get()));
ImmutableList<Client> clients = ImmutableList.copyOf(aclDAOReadOnly.getClientsFor(secrets.get()));
return SecretDetailResponse.fromSecret(secrets.get(), groups, clients);
}
private SanitizedSecret sanitizedSecretFromName(String name) {
Optional<Secret> optionalSecret = secretController.getSecretByName(name);
if (!optionalSecret.isPresent()) {
throw new NotFoundException("Secret not found.");
}
Secret secret = optionalSecret.get();
return SanitizedSecret.fromSecret(secret);
}
}