From 209e0b2d37ded6df2f5923caf10c110b8815750a Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 27 Sep 2017 15:23:46 +0200 Subject: [PATCH] Add support for policies. We now support policy management via Vault's policy endpoint to enumerate policy names, read, write and delete policies. Policy parsing support is limited to JSON as there is no Java HCL parser. Closes gh-10. --- spring-vault-core/pom.xml | 7 + .../vault/core/VaultSysOperations.java | 47 ++ .../vault/core/VaultSysTemplate.java | 76 ++ .../springframework/vault/support/Policy.java | 758 ++++++++++++++++++ .../support/VaultCertificateRequest.java | 2 + .../VaultSysTemplateIntegrationTests.java | 73 ++ .../support/PolicySerializationUnitTests.java | 146 ++++ .../src/test/resources/policy.json | 40 + src/main/asciidoc/new-features.adoc | 1 + 9 files changed, 1150 insertions(+) create mode 100644 spring-vault-core/src/main/java/org/springframework/vault/support/Policy.java create mode 100644 spring-vault-core/src/test/java/org/springframework/vault/support/PolicySerializationUnitTests.java create mode 100644 spring-vault-core/src/test/resources/policy.json diff --git a/spring-vault-core/pom.xml b/spring-vault-core/pom.xml index cbcebaaf..69cc7c7e 100644 --- a/spring-vault-core/pom.xml +++ b/spring-vault-core/pom.xml @@ -165,6 +165,13 @@ test + + org.skyscreamer + jsonassert + 1.5.0 + test + + diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java index 553f4154..361c6ba7 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysOperations.java @@ -15,9 +15,12 @@ */ package org.springframework.vault.core; +import java.util.List; import java.util.Map; +import org.springframework.lang.Nullable; import org.springframework.vault.VaultException; +import org.springframework.vault.support.Policy; import org.springframework.vault.support.VaultHealth; import org.springframework.vault.support.VaultInitializationRequest; import org.springframework.vault.support.VaultInitializationResponse; @@ -126,6 +129,50 @@ public interface VaultSysOperations { */ void authUnmount(String path) throws VaultException; + /** + * Lists policy names stored in Vault. + * + * @return policy names. + * @since 2.0 + * @see GET + * /sys/policy/ + */ + List getPolicyNames() throws VaultException; + + /** + * Read a {@link Policy} by its {@literal name}. Policies are either represented as + * HCL (HashiCorp configuration language) or JSON. Retrieving policies is only + * possible if the policy is represented as JSON. + * + * @return the {@link Policy} or {@literal null}, if the policy was not found. + * @since 2.0 + * @throws UnsupportedOperationException if the policy is represented as HCL. + * @see GET + * /sys/policy/{name} + */ + @Nullable + Policy getPolicy(String name) throws VaultException; + + /** + * Create or update a {@link Policy}. + * + * @param name the policy name, must not be {@literal null} or empty. + * @since 2.0 + * @see PUT + * /sys/policy/{name} + */ + void createOrUpdatePolicy(String name, Policy policy) throws VaultException; + + /** + * Delete a {@link Policy} by its {@literal name}. + * + * @param name the policy name, must not be {@literal null} or empty. + * @since 2.0 + * @see DELETE + * /sys/policy/{name} + */ + void deletePolicy(String name) throws VaultException; + /** * Return the health status of Vault. * diff --git a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java index 276b7350..5c1d0e67 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/core/VaultSysTemplate.java @@ -15,6 +15,7 @@ */ package org.springframework.vault.core; +import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -31,15 +32,19 @@ import lombok.Data; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.util.Assert; +import org.springframework.util.StringUtils; import org.springframework.vault.VaultException; import org.springframework.vault.client.VaultResponses; +import org.springframework.vault.support.Policy; import org.springframework.vault.support.VaultHealth; import org.springframework.vault.support.VaultInitializationRequest; import org.springframework.vault.support.VaultInitializationResponse; import org.springframework.vault.support.VaultMount; +import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultResponseSupport; import org.springframework.vault.support.VaultToken; import org.springframework.vault.support.VaultUnsealStatus; @@ -64,6 +69,8 @@ public class VaultSysTemplate implements VaultSysOperations { private static final Health HEALTH = new Health(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final VaultOperations vaultOperations; /** @@ -194,6 +201,75 @@ public class VaultSysTemplate implements VaultSysOperations { vaultOperations.delete(String.format("sys/auth/%s", path)); } + @Override + @SuppressWarnings("unchecked") + public List getPolicyNames() throws VaultException { + return requireResponse((List) vaultOperations.read("sys/policy") + .getRequiredData().get("policies")); + } + + @Nullable + @Override + public Policy getPolicy(String name) throws VaultException { + + Assert.hasText(name, "Name must not be null or empty"); + + return vaultOperations.doWithSession(restOperations -> { + + ResponseEntity response = restOperations.getForEntity( + "sys/policy/{name}", VaultResponse.class, name); + + if (response.getStatusCode() == HttpStatus.NOT_FOUND) { + return null; + } + + String rules = (String) response.getBody().getRequiredData().get("rules"); + + if (StringUtils.isEmpty(rules)) { + return Policy.empty(); + } + + if (rules.trim().startsWith("{")) { + return VaultResponses.unwrap(rules, Policy.class); + } + + throw new UnsupportedOperationException("Cannot parse policy in HCL format"); + }); + } + + @Override + public void createOrUpdatePolicy(String name, Policy policy) throws VaultException { + + Assert.hasText(name, "Name must not be null or empty"); + Assert.notNull(policy, "Policy must not be null"); + + String rules; + + try { + rules = OBJECT_MAPPER.writeValueAsString(policy); + } + catch (IOException e) { + throw new VaultException("Cannot serialize policy to JSON", e); + } + + vaultOperations.doWithSession(restOperations -> { + + restOperations.exchange("sys/policy/{name}", HttpMethod.PUT, + new HttpEntity<>(Collections.singletonMap("rules", rules)), + VaultResponse.class, name); + + return null; + }); + } + + @Override + public void deletePolicy(String name) throws VaultException { + + Assert.hasText(name, "Name must not be null or empty"); + + vaultOperations.delete(String.format("sys/policy/%s", name)); + } + @Override public VaultHealth health() { return requireResponse(vaultOperations.doWithVault(HEALTH)); diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/Policy.java b/spring-vault-core/src/main/java/org/springframework/vault/support/Policy.java new file mode 100644 index 00000000..2620454b --- /dev/null +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/Policy.java @@ -0,0 +1,758 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 org.springframework.vault.support; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.type.TypeFactory; +import com.fasterxml.jackson.databind.util.Converter; +import lombok.EqualsAndHashCode; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; +import org.springframework.vault.support.Policy.PolicyDeserializer; +import org.springframework.vault.support.Policy.PolicySerializer; + +/** + * Value object representing a Vault policy associated with {@link Rule}s. Instances of + * {@link Policy} support JSON serialization and deserialization using Jackson. + * + * @author Mark Paluch + * @since 2.0 + * @see Rule + * @see com.fasterxml.jackson.databind.ObjectMapper + */ +@JsonSerialize(using = PolicySerializer.class) +@JsonDeserialize(using = PolicyDeserializer.class) +@EqualsAndHashCode +public class Policy { + + private static final Policy EMPTY = new Policy(Collections.emptySet()); + + private final Set rules; + + private Policy(Set rules) { + this.rules = rules; + } + + /** + * Create an empty {@link Policy} without rules. + * + * @return an empty {@link Policy}. + */ + public static Policy empty() { + return EMPTY; + } + + /** + * Create a {@link Policy} from one or more {@code rules}. + * + * @param rules must not be {@literal null}. + * @return the {@link Policy} object containing {@code rules}. + */ + public static Policy of(Rule... rules) { + + Assert.notNull(rules, "Rules must not be null"); + Assert.noNullElements(rules, "Rules must not contain null elements"); + + return new Policy(new LinkedHashSet<>(Arrays.asList(rules))); + } + + /** + * Create a {@link Policy} from one or more {@code rules}. + * + * @param rules must not be {@literal null}. + * @return the {@link Policy} object containing {@code rules}. + */ + public static Policy of(Set rules) { + + Assert.notNull(rules, "Rules must not be null"); + + return new Policy(new LinkedHashSet<>(rules)); + } + + /** + * Create a new {@link Policy} object containing all configured rules and add the + * given {@link Rule} to the new policy object. If the given {@link Rule} matches an + * existing rule path, the exiting rule will be overridden by the new rule object. + * + * @param rule must not be {@literal null}. + * @return the new {@link Policy} object containing all configured rules and the given + * {@link Rule}. + */ + public Policy with(Rule rule) { + + Assert.notNull(rule, "Rule must not be null"); + + Set rules = new LinkedHashSet<>(this.rules.size() + 1); + rules.addAll(this.rules); + rules.add(rule); + + return new Policy(rules); + } + + public Set getRules() { + return rules; + } + + /** + * Lookup a {@link Rule} by its path. Returns {@literal null} if the rule was not + * found. + * + * @param path must not be {@literal null}. + * @return the {@link Rule} or {@literal null}, if not found. + */ + @Nullable + public Rule getRule(String path) { + + Assert.notNull(path, "Path must not be null"); + + for (Rule rule : rules) { + if (rule.getPath().equals(path)) { + return rule; + } + } + + return null; + } + + /** + * Value object representing a rule for a certain path. Rule equality is considered by + * comparing only the path segment to guarante uniqueness within a {@link Set}. + * + * @author Mark Paluch + */ + @EqualsAndHashCode(of = "path") + @JsonInclude(Include.NON_EMPTY) + public static class Rule { + + /** + * Path or path with asterisk to which this rule applies to. + */ + @JsonIgnore + private final String path; + + /** + * One or more capabilities which provide fine-grained control over permitted (or + * denied) operations. + */ + @JsonSerialize(contentConverter = CapabilityToStringConverter.class) + @JsonDeserialize(contentConverter = StringToCapabilityConverter.class) + private final List capabilities; + + /** + * The minimum allowed TTL that clients can specify for a wrapped response. In + * practice, setting a minimum TTL of one second effectively makes response + * wrapping mandatory for a particular path. + */ + @JsonProperty("min_wrapping_ttl") + @JsonSerialize(converter = DurationToStringConverter.class) + @Nullable + private final Duration minWrappingTtl; + + /** + * The maximum allowed TTL that clients can specify for a wrapped response. + */ + @JsonProperty("max_wrapping_ttl") + @JsonSerialize(converter = DurationToStringConverter.class) + @Nullable + private final Duration maxWrappingTtl; + + /** + * Whitelists a list of keys and values that are permitted on the given path. + * Setting a parameter with a value of a populated list allows the parameter to + * contain only those values. + */ + @JsonProperty("allowed_parameters") + private final Map> allowedParameters; + + /** + * Blacklists a list of parameter and values. Any values specified here take + * precedence over {@link #allowedParameters}. Setting a parameter with a value of + * a populated list denies any parameter containing those values. Setting to + * {@literal *} will deny any parameter. + */ + @JsonProperty("denied_parameters") + private final Map> deniedParameters; + + @JsonCreator + private Rule( + @JsonProperty("capabilities") List capabilities, + @JsonProperty("min_wrapping_ttl") @JsonDeserialize(converter = StringToDurationConverter.class) Duration minWrappingTtl, + @JsonProperty("max_wrapping_ttl") @JsonDeserialize(converter = StringToDurationConverter.class) Duration maxWrappingTtl, + @JsonProperty("allowed_parameters") Map> allowedParameters, + @JsonProperty("denied_parameters") Map> deniedParameters) { + + this.path = ""; + this.capabilities = capabilities; + this.minWrappingTtl = minWrappingTtl; + this.maxWrappingTtl = maxWrappingTtl; + this.allowedParameters = allowedParameters; + this.deniedParameters = deniedParameters; + } + + private Rule(String path, List capabilities, + @Nullable Duration minWrappingTtl, @Nullable Duration maxWrappingTtl, + Map> allowedParameters, + Map> deniedParameters) { + + this.path = path; + this.capabilities = capabilities; + this.minWrappingTtl = minWrappingTtl; + this.maxWrappingTtl = maxWrappingTtl; + this.allowedParameters = allowedParameters; + this.deniedParameters = deniedParameters; + } + + private Rule withPath(String path) { + return new Rule(path, capabilities, minWrappingTtl, maxWrappingTtl, + allowedParameters, deniedParameters); + } + + public String getPath() { + return path; + } + + public List getCapabilities() { + return capabilities; + } + + @Nullable + public Duration getMinWrappingTtl() { + return minWrappingTtl; + } + + @Nullable + public Duration getMaxWrappingTtl() { + return maxWrappingTtl; + } + + public Map> getAllowedParameters() { + return allowedParameters; + } + + public Map> getDeniedParameters() { + return deniedParameters; + } + + /** + * Create a new builder for {@link Rule}. + * + * @return a new {@link RuleBuilder}. + */ + public static RuleBuilder builder() { + return new RuleBuilder(); + } + + /** + * Builder for a {@link Rule}. + */ + public static class RuleBuilder { + + private @Nullable String path; + + private Set capabilities = new LinkedHashSet<>(); + + @Nullable + private Duration minWrappingTtl; + + @Nullable + private Duration maxWrappingTtl; + + private Map> allowedParameters = new LinkedHashMap>(); + + private Map> deniedParameters = new LinkedHashMap>();; + + /** + * Associate a {@code path} with the rule. + * + * @param path must not be {@literal null} or empty. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder path(String path) { + + Assert.hasText(path, "Path must not be empty"); + + this.path = path; + return this; + } + + /** + * Configure a {@link Capability} for the rule. Capabilities are added when + * calling this method and do not replace already configured capabilities. + * + * @param capability must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder capability(Capability capability) { + + Assert.notNull(capability, "Capability must not be null"); + + this.capabilities.add(capability); + return this; + } + + /** + * Configure capabilities. apabilities are added when calling this method and + * do not replace already configured capabilities. + * + * @param capabilities must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder capabilities(Capability... capabilities) { + + Assert.notNull(capabilities, "Capabilities must not be null"); + Assert.noNullElements(capabilities, + "Capabilities must not contain null elements"); + + return capabilities(Arrays.asList(capabilities)); + } + + /** + * Configure capabilities represented as {@link String} literals. This method + * resolves capabilities using {@link BuiltinCapabilities}. Capabilities are + * added when calling this method and do not replace already configured + * capabilities. + * + * @param capabilities must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + * @throws IllegalArgumentException if the capability cannot be resolved to a + * built-in {@link Capability}. + */ + public RuleBuilder capabilities(String... capabilities) { + + Assert.notNull(capabilities, "Capabilities must not be null"); + Assert.noNullElements(capabilities, + "Capabilities must not contain null elements"); + + List mapped = Arrays + .stream(capabilities) + .map(value -> { + + Capability capability = BuiltinCapabilities.find(value); + + if (capability == null) { + throw new IllegalArgumentException("Cannot resolve " + + value + " to a capability"); + } + return capability; + }).collect(Collectors.toList()); + + return capabilities(mapped); + } + + private RuleBuilder capabilities(Iterable capabilities) { + + for (Capability capability : capabilities) { + this.capabilities.add(capability); + } + + return this; + } + + /** + * Configure a min TTL for response wrapping. + * + * @param ttl must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder minWrappingTtl(Duration ttl) { + + Assert.notNull(ttl, "TTL must not be null"); + + this.minWrappingTtl = ttl; + return this; + } + + /** + * Configure a max TTL for response wrapping. + * + * @param ttl must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder maxWrappingTtl(Duration ttl) { + + Assert.notNull(ttl, "TTL must not be null"); + + this.maxWrappingTtl = ttl; + return this; + } + + /** + * Configure allowed parameter values given {@code name} and {@code values}. + * Allowing parameter values replaces previously configured allowed parameter + * values. Empty {@code values} allow all values for the given parameter + * {@code name}. + * + * @param name must not be {@literal null} or empty. + * @param values must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder allowedParameter(String name, String... values) { + + Assert.hasText(name, "Allowed parameter name must not be empty"); + Assert.notNull(values, "Values must not be null"); + + this.allowedParameters.put(name, Arrays.asList(values)); + + return this; + } + + /** + * Configure denied parameter values given {@code name} and {@code values}. + * Denying parameter values replaces previously configured denied parameter + * values. Empty {@code values} deny parameter usage. + * + * @param name must not be {@literal null} or empty. + * @param values must not be {@literal null}. + * @return {@code this} {@link RuleBuilder}. + */ + public RuleBuilder deniedParameter(String name, String... values) { + + Assert.hasText(name, "Denied parameter name must not be empty"); + Assert.notNull(values, "Values must not be null"); + + this.deniedParameters.put(name, Arrays.asList(values)); + + return this; + } + + /** + * Build the {@link Rule} object. Requires a configured {@link #path(String)} + * and at least one {@link #capability(Capability)}. + * + * @return the new {@link Rule} object. + */ + public Rule build() { + + Assert.state(StringUtils.hasText(path), "Path must not be empty"); + Assert.state(!capabilities.isEmpty(), + "Rule must define one or more capabilities"); + + List capabilities; + switch (this.capabilities.size()) { + case 0: + capabilities = Collections.emptyList(); + break; + case 1: + capabilities = Collections.singletonList(this.capabilities.iterator() + .next()); + break; + default: + capabilities = Collections.unmodifiableList(new ArrayList<>( + this.capabilities)); + } + + return new Rule(path, capabilities, minWrappingTtl, maxWrappingTtl, + createMap(this.allowedParameters), + createMap(this.deniedParameters)); + } + + private Map> createMap(Map> source) { + + if (source.isEmpty()) { + return Collections.emptyMap(); + } + + return Collections.unmodifiableMap(new LinkedHashMap<>(source)); + } + } + } + + /** + * Capability interface representing capability literals. + */ + public interface Capability { + + /** + * @return the capability literal. + */ + String name(); + } + + /** + * Built-in Vault capabilities. + */ + public enum BuiltinCapabilities implements Capability { + + /** + * Allows creating data at the given path. Very few parts of Vault distinguish + * between create and update, so most operations require both create and update + * capabilities. + */ + CREATE, + + /** + * Allows reading the data at the given path. + */ + READ, + + /** + * Allows change the data at the given path. In most parts of Vault, this + * implicitly includes the ability to create the initial value at the path. + */ + UPDATE, + + /** + * Deprecated: Previous capability literal before it was split into + * {@link #CREATE} and {@link #UPDATE}. + */ + WRITE, + + /** + * Allows deleting the data at the given path. + */ + DELETE, + + /** + * Allows listing values at the given path. Note that the keys returned by a list + * operation are not filtered by policies. Do not encode sensitive information in + * key names. Not all backends support listing. + */ + LIST, + + /** + * Allows access to paths that are root-protected. Tokens are not permitted to + * interact with these paths unless they are have the sudo capability (in addition + * to the other necessary capabilities for performing an operation against that + * path, such as read or delete). + */ + SUDO, + + /** + * Disallows access. This always takes precedence regardless of any other defined + * capabilities, including {@link #SUDO}. + */ + DENY; + + /** + * Find a {@link Capability} by its name. The name is compared case-insensitive. + * + * @param value must not be {@literal null}. + * @return the {@link Capability} or {@literal null}, if not found. + */ + @Nullable + public static Capability find(String value) { + + for (BuiltinCapabilities cap : values()) { + if (cap.name().equalsIgnoreCase(value)) { + return cap; + } + } + + return null; + } + } + + static class PolicySerializer extends JsonSerializer { + + @Override + public void serialize(Policy value, JsonGenerator gen, + SerializerProvider serializers) throws IOException { + + gen.writeStartObject(); + + gen.writeFieldName("path"); + gen.writeStartObject(); + + for (Rule rule : value.getRules()) { + gen.writeObjectField(rule.path, rule); + } + + gen.writeEndObject(); + gen.writeEndObject(); + + } + } + + static class PolicyDeserializer extends JsonDeserializer { + + @Override + public Policy deserialize(JsonParser p, DeserializationContext ctxt) + throws IOException { + + Assert.isTrue(p.getCurrentToken() == JsonToken.START_OBJECT, + "Expected START_OBJECT, got: " + p.getCurrentToken()); + + String fieldName = p.nextFieldName(); + + Set rules = new LinkedHashSet<>(); + + if ("path".equals(fieldName)) { + + p.nextToken(); + Assert.isTrue(p.getCurrentToken() == JsonToken.START_OBJECT, + "Expected START_OBJECT, got: " + p.getCurrentToken()); + + p.nextToken(); + + while (p.currentToken() == JsonToken.FIELD_NAME) { + + String path = p.getCurrentName(); + p.nextToken(); + + Assert.isTrue(p.getCurrentToken() == JsonToken.START_OBJECT, + "Expected START_OBJECT, got: " + p.getCurrentToken()); + + Rule rule = p.getCodec().readValue(p, Rule.class); + rules.add(rule.withPath(path)); + + JsonToken jsonToken = p.nextToken(); + if (jsonToken == JsonToken.END_OBJECT) { + break; + } + } + + Assert.isTrue(p.getCurrentToken() == JsonToken.END_OBJECT, + "Expected END_OBJECT, got: " + p.getCurrentToken()); + p.nextToken(); + } + + Assert.isTrue(p.getCurrentToken() == JsonToken.END_OBJECT, + "Expected END_OBJECT, got: " + p.getCurrentToken()); + return Policy.of(rules); + } + } + + static class CapabilityToStringConverter implements Converter { + + @Override + public String convert(Capability value) { + return value.name().toLowerCase(); + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(Capability.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + } + + static class StringToCapabilityConverter implements Converter { + + @Override + public Capability convert(String value) { + + Capability capability = BuiltinCapabilities.find(value); + + return capability != null ? capability : () -> value; + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(Capability.class); + } + } + + static class DurationToStringConverter implements Converter { + + @Override + public String convert(Duration value) { + return "" + value.getSeconds(); + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(Duration.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + } + + static class StringToDurationConverter implements Converter { + + static Pattern SECONDS = Pattern.compile("(\\d+)s"); + static Pattern MINUTES = Pattern.compile("(\\d+)m"); + static Pattern HOURS = Pattern.compile("(\\d+)h"); + + @Override + public Duration convert(String value) { + + try { + return Duration.ofSeconds(Long.parseLong(value)); + } + catch (NumberFormatException e) { + + Matcher matcher = SECONDS.matcher(value); + if (matcher.matches()) { + return Duration.ofSeconds(Long.parseLong(matcher.group(1))); + } + + matcher = MINUTES.matcher(value); + if (matcher.matches()) { + return Duration.ofMinutes(Long.parseLong(matcher.group(1))); + } + + matcher = HOURS.matcher(value); + if (matcher.matches()) { + return Duration.ofHours(Long.parseLong(matcher.group(1))); + } + + throw new IllegalArgumentException("Unsupported duration value: " + value); + } + } + + @Override + public JavaType getInputType(TypeFactory typeFactory) { + return typeFactory.constructType(String.class); + } + + @Override + public JavaType getOutputType(TypeFactory typeFactory) { + return typeFactory.constructType(Capability.class); + } + } +} diff --git a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java index 9e1f4096..2460f82a 100644 --- a/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java +++ b/spring-vault-core/src/main/java/org/springframework/vault/support/VaultCertificateRequest.java @@ -111,7 +111,9 @@ public class VaultCertificateRequest { @Nullable private String commonName; + private List altNames = new ArrayList<>(); + private List ipSubjectAltNames = new ArrayList<>(); @Nullable diff --git a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateIntegrationTests.java b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateIntegrationTests.java index dfb2258d..ae6c60af 100644 --- a/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateIntegrationTests.java +++ b/spring-vault-core/src/test/java/org/springframework/vault/core/VaultSysTemplateIntegrationTests.java @@ -15,8 +15,10 @@ */ package org.springframework.vault.core; +import java.time.Duration; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Map; import org.junit.Before; @@ -26,11 +28,17 @@ import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.vault.support.Policy; import org.springframework.vault.support.VaultMount; import org.springframework.vault.support.VaultUnsealStatus; +import org.springframework.vault.support.Policy.Rule; import org.springframework.vault.util.IntegrationTestSupport; +import org.springframework.vault.util.Version; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assume.assumeTrue; +import static org.springframework.vault.support.Policy.BuiltinCapabilities.READ; +import static org.springframework.vault.support.Policy.BuiltinCapabilities.UPDATE; /** * Integration tests for {@link VaultSysTemplate} through {@link VaultSysOperations}. @@ -44,10 +52,15 @@ public class VaultSysTemplateIntegrationTests extends IntegrationTestSupport { @Autowired private VaultOperations vaultOperations; + private Version vaultVersion; + private VaultSysOperations adminOperations; @Before public void before() throws Exception { + + vaultVersion = prepare().getVersion(); + adminOperations = vaultOperations.opsForSys(); } @@ -120,6 +133,65 @@ public class VaultSysTemplateIntegrationTests extends IntegrationTestSupport { assertThat(secret.getType()).isEqualTo("userpass"); } + @Test + public void shouldEnumeratePolicyNames() { + + assumeTrue(vaultVersion.isGreaterThanOrEqualTo(Version.parse("0.6.1"))); + + List policyNames = adminOperations.getPolicyNames(); + + assertThat(policyNames).contains("root", "default"); + } + + @Test + public void shouldReadRootPolicy() { + + assumeTrue(vaultVersion.isGreaterThanOrEqualTo(Version.parse("0.6.1"))); + + Policy root = adminOperations.getPolicy("root"); + + assertThat(root).isEqualTo(Policy.empty()); + } + + @Test(expected = UnsupportedOperationException.class) + public void shouldReadDefaultPolicy() { + + assumeTrue(vaultVersion.isGreaterThanOrEqualTo(Version.parse("0.6.1"))); + + adminOperations.getPolicy("default"); + } + + @Test + public void shouldCreatePolicy() { + + assumeTrue(vaultVersion.isGreaterThanOrEqualTo(Version.parse("0.7.0"))); + + Rule rule = Rule.builder().path("foo").capabilities(READ, UPDATE) + .minWrappingTtl(Duration.ofSeconds(100)) + .maxWrappingTtl(Duration.ofHours(2)).build(); + + adminOperations.createOrUpdatePolicy("foo", Policy.of(rule)); + + assertThat(adminOperations.getPolicyNames()).contains("foo"); + + Policy loaded = adminOperations.getPolicy("foo"); + assertThat(loaded.getRules()).contains(rule); + } + + @Test + public void shouldDeletePolicy() { + + assumeTrue(vaultVersion.isGreaterThanOrEqualTo(Version.parse("0.6.0"))); + + Rule rule = Rule.builder().path("foo").capabilities(READ).build(); + + adminOperations.createOrUpdatePolicy("foo", Policy.of(rule)); + + adminOperations.deletePolicy("foo"); + + assertThat(adminOperations.getPolicyNames()).doesNotContain("foo"); + } + @Test public void isInitializedShouldReturnTrue() { assertThat(adminOperations.isInitialized()).isTrue(); @@ -129,6 +201,7 @@ public class VaultSysTemplateIntegrationTests extends IntegrationTestSupport { public void getUnsealStatusShouldReturnStatus() { VaultUnsealStatus unsealStatus = adminOperations.getUnsealStatus(); + assertThat(unsealStatus.isSealed()).isFalse(); assertThat(unsealStatus.getProgress()).isEqualTo(0); } diff --git a/spring-vault-core/src/test/java/org/springframework/vault/support/PolicySerializationUnitTests.java b/spring-vault-core/src/test/java/org/springframework/vault/support/PolicySerializationUnitTests.java new file mode 100644 index 00000000..517597ae --- /dev/null +++ b/spring-vault-core/src/test/java/org/springframework/vault/support/PolicySerializationUnitTests.java @@ -0,0 +1,146 @@ +/* + * Copyright 2017 the original author or authors. + * + * 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 org.springframework.vault.support; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Test; +import org.skyscreamer.jsonassert.JSONAssert; +import org.skyscreamer.jsonassert.JSONCompareMode; + +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; +import org.springframework.vault.support.Policy.Rule; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link Policy} JSON serialization/deserialization. + * + * @author Mark Paluch + */ +public class PolicySerializationUnitTests { + + ObjectMapper objectMapper = new ObjectMapper(); + + @Test + public void shouldSerialize() throws Exception { + + Rule rule = Rule.builder().path("secret/*") + .capabilities("create", "read", "update") + .allowedParameter("ttl", "1h", "2h").deniedParameter("password").build(); + + Rule another = Rule.builder().path("secret/foo") + .capabilities("create", "read", "update", "delete", "list") + .minWrappingTtl(Duration.ofMinutes(1)) + .maxWrappingTtl(Duration.ofHours(1)).allowedParameter("ttl", "1h", "2h") + .deniedParameter("password").build(); + + Policy policy = Policy.of(rule, another); + + try (InputStream is = new ClassPathResource("policy.json").getInputStream()) { + + String expected = StreamUtils.copyToString(is, StandardCharsets.UTF_8); + JSONAssert.assertEquals(expected, objectMapper.writeValueAsString(policy), + JSONCompareMode.STRICT); + } + } + + @Test + public void shouldDeserialize() throws Exception { + + Rule rule = Rule.builder().path("secret/*") + .capabilities("create", "read", "update", "update") + .allowedParameter("ttl", "1h", "2h").deniedParameter("password").build(); + + Rule another = Rule.builder().path("secret/foo") + .capabilities("create", "read", "update", "delete", "list") + .minWrappingTtl(Duration.ofMinutes(1)) + .maxWrappingTtl(Duration.ofHours(1)).allowedParameter("ttl", "1h", "2h") + .allowedParameter("ttl", "1h", "2h").deniedParameter("password").build(); + + Policy expected = Policy.of(rule, another); + + try (InputStream is = new ClassPathResource("policy.json").getInputStream()) { + + Policy actual = objectMapper.readValue(is, Policy.class); + + assertThat(actual.getRules()).hasSameClassAs(expected.getRules()); + + Rule secretAll = actual.getRule("secret/*"); + + assertThat(secretAll.getPath()).isEqualTo(rule.getPath()); + assertThat(secretAll.getCapabilities()).isEqualTo(rule.getCapabilities()); + assertThat(secretAll.getAllowedParameters()).isEqualTo( + rule.getAllowedParameters()); + assertThat(secretAll.getDeniedParameters()).isEqualTo( + rule.getDeniedParameters()); + + Rule secretFoo = actual.getRule("secret/foo"); + + assertThat(secretFoo.getPath()).isEqualTo(another.getPath()); + assertThat(secretFoo.getCapabilities()).isEqualTo(another.getCapabilities()); + assertThat(secretFoo.getMinWrappingTtl()).isEqualTo( + another.getMinWrappingTtl()); + assertThat(secretFoo.getMaxWrappingTtl()).isEqualTo( + another.getMaxWrappingTtl()); + assertThat(secretFoo.getAllowedParameters()).isEqualTo( + another.getAllowedParameters()); + assertThat(secretFoo.getDeniedParameters()).isEqualTo( + another.getDeniedParameters()); + } + } + + @Test + public void shouldDeserializeEmptyPolicy() throws Exception { + + assertThat(objectMapper.readValue("{}", Policy.class)).isEqualTo(Policy.empty()); + } + + @Test + public void shouldRejectUnknownFieldNames() throws Exception { + + assertThatThrownBy( + () -> objectMapper.readValue("{\"foo\":1, \"path\": {} }", Policy.class)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy( + () -> objectMapper.readValue("{\"foo\":\"bar\"}", Policy.class)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + public void shouldDeserializePolicyWithEmptyRules() throws Exception { + + Policy actual = objectMapper.readValue("{ \"path\": {} }", Policy.class); + + assertThat(actual).isEqualTo(Policy.empty()); + } + + @Test + public void shouldDeserializeRuleWithHour() throws Exception { + + Policy actual = objectMapper.readValue( + "{ \"path\": { \"secret\" : {\"min_wrapping_ttl\":\"1h\"} } }", + Policy.class); + + Rule rule = actual.getRule("secret"); + assertThat(rule.getMinWrappingTtl()).isEqualTo(Duration.ofHours(1)); + } +} \ No newline at end of file diff --git a/spring-vault-core/src/test/resources/policy.json b/spring-vault-core/src/test/resources/policy.json new file mode 100644 index 00000000..37a1693a --- /dev/null +++ b/spring-vault-core/src/test/resources/policy.json @@ -0,0 +1,40 @@ +{ + "path": { + "secret/*": { + "capabilities": [ + "create", + "read", + "update" + ], + "allowed_parameters": { + "ttl": [ + "1h", + "2h" + ] + }, + "denied_parameters": { + "password": [] + } + }, + "secret/foo": { + "capabilities": [ + "create", + "read", + "update", + "delete", + "list" + ], + "min_wrapping_ttl": "60", + "max_wrapping_ttl": "3600", + "allowed_parameters": { + "ttl": [ + "1h", + "2h" + ] + }, + "denied_parameters": { + "password": [] + } + } + } +} diff --git a/src/main/asciidoc/new-features.adoc b/src/main/asciidoc/new-features.adoc index a39a6167..9f9155ae 100644 --- a/src/main/asciidoc/new-features.adoc +++ b/src/main/asciidoc/new-features.adoc @@ -8,6 +8,7 @@ * Reactive Vault client via `ReactiveVaultOperations`. * <> based on Spring Data KeyValue. * Transit batch encrypt and decrypt support. +* Policy management for policies stored as JSON. [[new-features.1-0-0]] === What's new in Spring Vault 1.0