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