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