/* * 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.client; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.ImmutableMap; import java.io.IOException; import java.util.Base64; import java.util.List; import javax.ws.rs.core.HttpHeaders; import keywhiz.api.ClientDetailResponse; import keywhiz.api.CreateClientRequest; import keywhiz.api.CreateGroupRequest; import keywhiz.api.CreateSecretRequest; import keywhiz.api.GroupDetailResponse; import keywhiz.api.LoginRequest; import keywhiz.api.SecretDetailResponse; import keywhiz.api.automation.v2.CreateOrUpdateSecretRequestV2; import keywhiz.api.automation.v2.PartialUpdateSecretRequestV2; import keywhiz.api.automation.v2.SecretDetailResponseV2; import keywhiz.api.model.Client; import keywhiz.api.model.Group; import keywhiz.api.model.SanitizedSecret; import okhttp3.Call; import okhttp3.HttpUrl; import okhttp3.MediaType; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.apache.http.HttpStatus; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; import static java.lang.String.format; /** * Client for interacting with the Keywhiz Server. * * Facilitates the manipulation of Clients, Groups, Secrets and the connections between them. */ public class KeywhizClient { public static final MediaType JSON = MediaType.parse("application/json"); public static class MalformedRequestException extends IOException { @Override public String getMessage() { return "Malformed request syntax from client (400)"; } } public static class UnauthorizedException extends IOException { @Override public String getMessage() { return "Not allowed to login, password may be incorrect (401)"; } } public static class ForbiddenException extends IOException { @Override public String getMessage() { return "Resource forbidden (403)"; } } public static class NotFoundException extends IOException { @Override public String getMessage() { return "Resource not found (404)"; } } public static class UnsupportedMediaTypeException extends IOException { @Override public String getMessage() { return "Resource media type is incorrect or incompatible (415)"; } } public static class ConflictException extends IOException { @Override public String getMessage() { return "Conflicting resource (409)"; } } public static class ValidationException extends IOException { @Override public String getMessage() { return "Malformed request semantics from client (422)"; } } private final ObjectMapper mapper; private final OkHttpClient client; private final HttpUrl baseUrl; public KeywhizClient(ObjectMapper mapper, OkHttpClient client, HttpUrl baseUrl) { this.mapper = checkNotNull(mapper); this.client = checkNotNull(client); this.baseUrl = checkNotNull(baseUrl); } /** * Login to the Keywhiz server. * * Future requests made using this client instance will be authenticated. * @param username login username * @param password login password * @throws IOException if a network IO error occurs */ public void login(String username, char[] password) throws IOException { httpPost(baseUrl.resolve("/admin/login"), LoginRequest.from(username, password)); } public List<Group> allGroups() throws IOException { String response = httpGet(baseUrl.resolve("/admin/groups/")); return mapper.readValue(response, new TypeReference<List<Group>>() {}); } public GroupDetailResponse createGroup(String name, String description, ImmutableMap<String, String> metadata) throws IOException { checkArgument(!name.isEmpty()); String response = httpPost(baseUrl.resolve("/admin/groups"), new CreateGroupRequest(name, description, metadata)); return mapper.readValue(response, GroupDetailResponse.class); } public GroupDetailResponse groupDetailsForId(long groupId) throws IOException { String response = httpGet(baseUrl.resolve(format("/admin/groups/%d", groupId))); return mapper.readValue(response, GroupDetailResponse.class); } public void deleteGroupWithId(long groupId) throws IOException { httpDelete(baseUrl.resolve(format("/admin/groups/%d", groupId))); } public List<SanitizedSecret> allSecrets() throws IOException { String response = httpGet(baseUrl.resolve("/admin/secrets?nameOnly=1")); return mapper.readValue(response, new TypeReference<List<SanitizedSecret>>() {}); } public List<SanitizedSecret> allSecretsBatched(int idx, int num, boolean newestFirst) throws IOException { String response = httpGet(baseUrl.resolve(String.format("/admin/secrets?idx=%d&num=%d&newestFirst=%s", idx, num, newestFirst))); return mapper.readValue(response, new TypeReference<List<SanitizedSecret>>() {}); } public SecretDetailResponse createSecret(String name, String description, byte[] content, ImmutableMap<String, String> metadata, long expiry) throws IOException { checkArgument(!name.isEmpty()); checkArgument(content.length > 0, "Content must not be empty"); String b64Content = Base64.getEncoder().encodeToString(content); CreateSecretRequest request = new CreateSecretRequest(name, description, b64Content, metadata, expiry); String response = httpPost(baseUrl.resolve("/admin/secrets"), request); return mapper.readValue(response, SecretDetailResponse.class); } public SecretDetailResponse updateSecret(String name, boolean descriptionPresent, String description, boolean contentPresent, byte[] content, boolean metadataPresent, ImmutableMap<String, String> metadata, boolean expiryPresent, long expiry) throws IOException { checkArgument(!name.isEmpty()); String b64Content = Base64.getEncoder().encodeToString(content); PartialUpdateSecretRequestV2 request = PartialUpdateSecretRequestV2.builder() .descriptionPresent(descriptionPresent) .description(description) .contentPresent(contentPresent) .content(b64Content) .metadataPresent(metadataPresent) .metadata(metadata) .expiryPresent(expiryPresent) .expiry(expiry) .build(); String response = httpPost(baseUrl.resolve(format("/admin/secrets/%s/partialupdate", name)), request); return mapper.readValue(response, SecretDetailResponse.class); } public SecretDetailResponse secretDetailsForId(long secretId) throws IOException { String response = httpGet(baseUrl.resolve(format("/admin/secrets/%d", secretId))); return mapper.readValue(response, SecretDetailResponse.class); } public List<SanitizedSecret> listSecretVersions(String name, int idx, int numVersions) throws IOException { String response = httpGet(baseUrl.resolve(format("/admin/secrets/versions/%s?versionIdx=%d&numVersions=%d", name, idx, numVersions))); return mapper.readValue(response, new TypeReference<List<SanitizedSecret>>() {}); } public SecretDetailResponse rollbackSecret (String name, long version) throws IOException { String response = httpPost(baseUrl.resolve(format("/admin/secrets/rollback/%s/%d", name, version)), null); return mapper.readValue(response, SecretDetailResponse.class); } public void deleteSecretWithId(long secretId) throws IOException { httpDelete(baseUrl.resolve(format("/admin/secrets/%d", secretId))); } public List<Client> allClients() throws IOException { String httpResponse = httpGet(baseUrl.resolve("/admin/clients/")); return mapper.readValue(httpResponse, new TypeReference<List<Client>>() {}); } public ClientDetailResponse createClient(String name) throws IOException { checkArgument(!name.isEmpty()); String response = httpPost(baseUrl.resolve("/admin/clients"), new CreateClientRequest(name)); return mapper.readValue(response, ClientDetailResponse.class); } public ClientDetailResponse clientDetailsForId(long clientId) throws IOException { String response = httpGet(baseUrl.resolve(format("/admin/clients/%d", clientId))); return mapper.readValue(response, ClientDetailResponse.class); } public void deleteClientWithId(long clientId) throws IOException { httpDelete(baseUrl.resolve(format("/admin/clients/%d", clientId))); } public void enrollClientInGroupByIds(long clientId, long groupId) throws IOException { httpPut(baseUrl.resolve(format("/admin/memberships/clients/%d/groups/%d", clientId, groupId))); } public void evictClientFromGroupByIds(long clientId, long groupId) throws IOException { httpDelete(baseUrl.resolve(format("/admin/memberships/clients/%d/groups/%d", clientId, groupId))); } public void grantSecretToGroupByIds(long secretId, long groupId) throws IOException { httpPut(baseUrl.resolve(format("/admin/memberships/secrets/%d/groups/%d", secretId, groupId))); } public void revokeSecretFromGroupByIds(long secretId, long groupId) throws IOException { httpDelete(baseUrl.resolve(format("/admin/memberships/secrets/%d/groups/%d", secretId, groupId))); } public Client getClientByName(String name) throws IOException { checkArgument(!name.isEmpty()); String response = httpGet(baseUrl.resolve("/admin/clients").newBuilder() .addQueryParameter("name", name) .build()); return mapper.readValue(response, Client.class); } public Group getGroupByName(String name) throws IOException { checkArgument(!name.isEmpty()); String response = httpGet(baseUrl.resolve("/admin/groups").newBuilder() .addQueryParameter("name", name) .build()); return mapper.readValue(response, Group.class); } public SanitizedSecret getSanitizedSecretByName(String name) throws IOException { checkArgument(!name.isEmpty()); String response = httpGet(baseUrl.resolve("/admin/secrets").newBuilder().addQueryParameter("name", name) .build()); return mapper.readValue(response, SanitizedSecret.class); } public boolean isLoggedIn() throws IOException{ HttpUrl url = baseUrl.resolve("/admin/me"); Call call = client.newCall(new Request.Builder().get().url(url).build()); return call.execute().code() != HttpStatus.SC_UNAUTHORIZED; } /** * Maps some of the common HTTP errors to the corresponding exceptions. */ private void throwOnCommonError(int status) throws IOException { switch (status) { case HttpStatus.SC_BAD_REQUEST: throw new MalformedRequestException(); case HttpStatus.SC_UNSUPPORTED_MEDIA_TYPE: throw new UnsupportedMediaTypeException(); case HttpStatus.SC_NOT_FOUND: throw new NotFoundException(); case HttpStatus.SC_UNAUTHORIZED: throw new UnauthorizedException(); case HttpStatus.SC_FORBIDDEN: throw new ForbiddenException(); case HttpStatus.SC_CONFLICT: throw new ConflictException(); case HttpStatus.SC_UNPROCESSABLE_ENTITY: throw new ValidationException(); } if (status >= 400) { throw new IOException("Unexpected status code on response: " + status); } } private String makeCall(Request request) throws IOException { Response response = client.newCall(request).execute(); try { throwOnCommonError(response.code()); } catch (IOException e) { response.body().close(); throw e; } return response.body().string(); } private String httpGet(HttpUrl url) throws IOException { Request request = new Request.Builder() .url(url) .get() .build(); return makeCall(request); } private String httpPost(HttpUrl url, Object content) throws IOException { RequestBody body = RequestBody.create(JSON, mapper.writeValueAsString(content)); Request request = new Request.Builder() .url(url) .post(body) .addHeader(HttpHeaders.CONTENT_TYPE, JSON.toString()) .build(); return makeCall(request); } private String httpPut(HttpUrl url) throws IOException { Request request = new Request.Builder() .url(url) .put(RequestBody.create(MediaType.parse("text/plain"), "")) .build(); return makeCall(request); } private String httpDelete(HttpUrl url) throws IOException { Request request = new Request.Builder() .url(url) .delete() .build(); return makeCall(request); } }