diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java index 1eb550342..5cb0e8452 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretEngineRegistry.java @@ -17,6 +17,8 @@ package com.netflix.spinnaker.kork.secrets; import java.util.List; +import java.util.Map; +import java.util.function.Function; import java.util.stream.Collectors; import javax.annotation.Nullable; import lombok.RequiredArgsConstructor; @@ -28,6 +30,13 @@ public class SecretEngineRegistry { private final ObjectProvider secretEngines; + // This is used by the Armory kubesvc plugin + public Map getRegisteredEngines() { + return secretEngines + .orderedStream() + .collect(Collectors.toMap(SecretEngine::identifier, Function.identity())); + } + public List getSecretEngineList() { return secretEngines.orderedStream().collect(Collectors.toList()); } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretError.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretError.java new file mode 100644 index 000000000..a9a5af4f1 --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretError.java @@ -0,0 +1,28 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets; + +import com.netflix.spinnaker.kork.exceptions.HasAdditionalAttributes; + +/** Common interface for exceptions that have a corresponding {@link SecretErrorCode}. */ +public interface SecretError extends HasAdditionalAttributes { + /** Returns the error code constant for this error. */ + String getErrorCode(); + + /** Returns the exception message provided by this error. */ + String getMessage(); +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretErrorCode.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretErrorCode.java new file mode 100644 index 000000000..ee806088c --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretErrorCode.java @@ -0,0 +1,59 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets; + +import lombok.Getter; + +/** Standard error codes and messages for various secret errors. */ +public enum SecretErrorCode implements SecretError { + /** @see com.netflix.spinnaker.kork.secrets.user.InvalidUserSecretReferenceException */ + INVALID_USER_SECRET_URI("user.format.invalid", "Invalid user secret URI format"), + /** @see com.netflix.spinnaker.kork.secrets.user.MissingUserSecretMetadataException */ + MISSING_USER_SECRET_METADATA("user.metadata.missing", "Missing user secret metadata"), + /** @see com.netflix.spinnaker.kork.secrets.user.InvalidUserSecretMetadataException */ + INVALID_USER_SECRET_METADATA("user.metadata.invalid", "Invalid user secret metadata"), + /** @see com.netflix.spinnaker.kork.secrets.user.UnsupportedUserSecretEngineException */ + UNSUPPORTED_USER_SECRET_ENGINE( + "user.engine.unsupported", "SecretEngine does not support user secrets"), + /** @see com.netflix.spinnaker.kork.secrets.user.UnsupportedUserSecretEncodingException */ + UNSUPPORTED_USER_SECRET_ENCODING( + "user.encoding.unsupported", "Unsupported user secret 'encoding'"), + /** @see com.netflix.spinnaker.kork.secrets.user.UnsupportedUserSecretTypeException */ + UNSUPPORTED_USER_SECRET_TYPE("user.type.unsupported", "Unsupported user secret 'type'"), + /** @see com.netflix.spinnaker.kork.secrets.user.InvalidUserSecretDataException */ + INVALID_USER_SECRET_DATA("user.data.invalid", "Invalid user secret data"), + /** @see SecretDecryptionException */ + USER_SECRET_DECRYPTION_FAILURE("user.decrypt.failure", "Unable to decrypt user secret"), + DENIED_ACCESS_TO_USER_SECRET("user.access.deny", "Denied access to user secret"), + MISSING_USER_SECRET_DATA_KEY("user.data.missing", "Missing user secret data for requested key"), + INVALID_EXTERNAL_SECRET_URI("external.format.invalid", "Invalid external secret URI format"), + /** @see SecretDecryptionException */ + EXTERNAL_SECRET_DECRYPTION_FAILURE( + "external.decrypt.failure", "Unable to decrypt external secret"), + DENIED_ACCESS_TO_EXTERNAL_SECRET("external.access.deny", "Denied access to external secret"), + /** @see UnsupportedSecretEngineException */ + UNSUPPORTED_SECRET_ENGINE("engine.unsupported", "Unsupported secret engine identifier"), + ; + + @Getter private final String errorCode; + @Getter private final String message; + + SecretErrorCode(String errorCode, String message) { + this.errorCode = "secrets." + errorCode; + this.message = message; + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretReferenceValidator.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretReferenceValidator.java new file mode 100644 index 000000000..24f8f0873 --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/SecretReferenceValidator.java @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.secrets.user.UserSecret; +import com.netflix.spinnaker.kork.secrets.user.UserSecretReference; +import java.util.Optional; + +/** + * Validation strategy for checking strings for possible secret references and validating that the + * current authenticated user (if available) is allowed to use it. + */ +@NonnullByDefault +public interface SecretReferenceValidator { + /** + * Checks the given string to see if it is a valid {@link UserSecretReference} or {@link + * EncryptedSecret} URI and performs validation on the referenced secret. If the given string does + * not match {@link UserSecretReference#isUserSecret(String)} or {@link + * EncryptedSecret#isEncryptedSecret(String)}, then this will not validate the string any further. + * + * @param secretReference string possibly containing a secret reference URI + * @return an optional containing the {@link SecretError} if invalid or an empty optional + * otherwise + */ + default Optional validate(String secretReference) { + if (UserSecretReference.isUserSecret(secretReference)) { + return validateUserSecretReference(secretReference); + } + if (EncryptedSecret.isEncryptedSecret(secretReference)) { + return validateExternalSecretReference(secretReference); + } + return Optional.empty(); + } + + /** + * Checks the given string to see if it is a valid {@link UserSecretReference} and that its + * referenced {@link UserSecret} is valid. + * + * @param uri secret URI to validate + * @return a specific error code for a validation error or empty + */ + Optional validateUserSecretReference(String uri); + + /** + * Checks the given string to see if it is a valid {@link EncryptedSecret} and that its referenced + * secret data can be fetched. + * + * @param uri external secret URI to validate + * @return a specific error code for a validation error or empty + */ + Optional validateExternalSecretReference(String uri); +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/UnsupportedSecretEngineException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/UnsupportedSecretEngineException.java new file mode 100644 index 000000000..c2e31c58a --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/UnsupportedSecretEngineException.java @@ -0,0 +1,30 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets; + +/** Exception thrown when an unsupported secret engine is called upon for decrypting a secret. */ +public class UnsupportedSecretEngineException extends SecretDecryptionException + implements SecretError { + public UnsupportedSecretEngineException(String engine) { + super(String.format("Unsupported secret engine identifier '%s'", engine)); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.UNSUPPORTED_SECRET_ENGINE.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/DefaultUserSecretSerde.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/DefaultUserSecretSerde.java index db5e82c0c..ae7f9b4ba 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/DefaultUserSecretSerde.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/DefaultUserSecretSerde.java @@ -20,12 +20,9 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; -import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; -import com.netflix.spinnaker.kork.secrets.SecretException; import java.io.IOException; import java.util.Collection; import java.util.Map; -import java.util.Objects; import java.util.TreeMap; /** @@ -62,22 +59,33 @@ public boolean supports(UserSecretMetadata metadata) { @Override public UserSecret deserialize(byte[] encoded, UserSecretMetadata metadata) { - var type = Objects.requireNonNull(userSecretTypes.get(metadata.getType())); - var mapper = Objects.requireNonNull(mappersByEncodingFormat.get(metadata.getEncoding())); + var type = userSecretTypes.get(metadata.getType()); + if (type == null) { + throw new UnsupportedUserSecretTypeException(metadata.getType()); + } + var mapper = mappersByEncodingFormat.get(metadata.getEncoding()); + if (mapper == null) { + throw new UnsupportedUserSecretEncodingException(metadata.getEncoding()); + } + UserSecretData data; try { - return UserSecret.builder().metadata(metadata).data(mapper.readValue(encoded, type)).build(); + data = mapper.readValue(encoded, type); } catch (IOException e) { - throw new SecretDecryptionException(e); + throw new InvalidUserSecretDataException("cannot parse user secret data", e); } + return UserSecret.builder().metadata(metadata).data(data).build(); } @Override public byte[] serialize(UserSecretData secret, UserSecretMetadata metadata) { - var mapper = Objects.requireNonNull(mappersByEncodingFormat.get(metadata.getEncoding())); + var mapper = mappersByEncodingFormat.get(metadata.getEncoding()); + if (mapper == null) { + throw new UnsupportedUserSecretEncodingException(metadata.getEncoding()); + } try { return mapper.writeValueAsBytes(secret); } catch (JsonProcessingException e) { - throw new SecretException(e); + throw new InvalidUserSecretDataException(e.getMessage(), e); } } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretDataException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretDataException.java new file mode 100644 index 000000000..803f6c969 --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretDataException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; + +/** + * Exception thrown when a {@link UserSecretSerde} is unable to deserialize data into {@link + * UserSecretData}. + */ +public class InvalidUserSecretDataException extends SecretDecryptionException + implements SecretError { + public InvalidUserSecretDataException(String message, Throwable cause) { + super(message, cause); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.INVALID_USER_SECRET_DATA.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretMetadataException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretMetadataException.java new file mode 100644 index 000000000..d32b9679f --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretMetadataException.java @@ -0,0 +1,43 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.exceptions.HasAdditionalAttributes; +import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; +import lombok.Getter; + +/** + * Exception thrown when a decrypted {@link UserSecret} has invalid {@link UserSecretMetadata} + * defined. + */ +public class InvalidUserSecretMetadataException extends InvalidSecretFormatException + implements HasAdditionalAttributes, SecretError { + @Getter private final UserSecretReference userSecretReference; + + public InvalidUserSecretMetadataException( + UserSecretReference userSecretReference, Throwable cause) { + super(String.format("User secret %s has invalid metadata", userSecretReference), cause); + this.userSecretReference = userSecretReference; + } + + @Override + public String getErrorCode() { + return SecretErrorCode.INVALID_USER_SECRET_METADATA.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretReferenceException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretReferenceException.java new file mode 100644 index 000000000..c0053421a --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/InvalidUserSecretReferenceException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.exceptions.HasAdditionalAttributes; +import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; +import java.net.URI; +import java.util.HashMap; +import java.util.Map; +import lombok.Getter; + +/** Exception thrown when an invalid {@link UserSecretReference} is attempted to be parsed. */ +@Getter +public class InvalidUserSecretReferenceException extends InvalidSecretFormatException + implements HasAdditionalAttributes, SecretError { + private final Map additionalAttributes = new HashMap<>(); + + public InvalidUserSecretReferenceException(String input, Throwable cause) { + super("Unable to parse input into a URI", cause); + additionalAttributes.put("input", input); + } + + public InvalidUserSecretReferenceException(String message, URI uri) { + super(message); + additionalAttributes.put("input", uri); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.INVALID_USER_SECRET_URI.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/MissingUserSecretMetadataException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/MissingUserSecretMetadataException.java new file mode 100644 index 000000000..5d19b5e0c --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/MissingUserSecretMetadataException.java @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.exceptions.HasAdditionalAttributes; +import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; +import java.util.Map; +import lombok.Getter; + +/** + * Exception thrown when a decrypted {@link UserSecret} does not have any {@link UserSecretMetadata} + * defined. + */ +public class MissingUserSecretMetadataException extends InvalidSecretFormatException + implements HasAdditionalAttributes, SecretError { + @Getter private final UserSecretReference userSecretReference; + + public MissingUserSecretMetadataException(UserSecretReference userSecretReference) { + super(String.format("User secret %s has no metadata defined", userSecretReference)); + this.userSecretReference = userSecretReference; + } + + @Override + public Map getAdditionalAttributes() { + return Map.of("userSecretReference", userSecretReference); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.MISSING_USER_SECRET_METADATA.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretData.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretData.java index 0533b0644..d91d26ef1 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretData.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretData.java @@ -30,4 +30,9 @@ public class StringUserSecretData implements UserSecretData { public String getSecretString(String key) { return data; } + + @Override + public String getSecretString() { + return data; + } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretSerde.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretSerde.java index 0a87db5e3..0dce73bcc 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretSerde.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/StringUserSecretSerde.java @@ -36,6 +36,6 @@ public UserSecret deserialize(byte[] encoded, UserSecretMetadata metadata) { @Override public byte[] serialize(UserSecretData secret, UserSecretMetadata metadata) { - return secret.getSecretString("").getBytes(StandardCharsets.UTF_8); + return secret.getSecretString().getBytes(StandardCharsets.UTF_8); } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEncodingException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEncodingException.java new file mode 100644 index 000000000..7343f5f3e --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEncodingException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; + +/** + * Exception thrown when a {@link UserSecretSerde} encounters an unsupported {@linkplain + * UserSecretMetadata#getEncoding() encoding type}. + */ +public class UnsupportedUserSecretEncodingException extends SecretDecryptionException + implements SecretError { + public UnsupportedUserSecretEncodingException(String encoding) { + super(String.format("Unsupported user secret encoding '%s'", encoding)); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.UNSUPPORTED_USER_SECRET_ENCODING.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEngineException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEngineException.java new file mode 100644 index 000000000..cc7e4ecd2 --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretEngineException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; + +/** + * Exception thrown by a {@link com.netflix.spinnaker.kork.secrets.SecretEngine} when an attempt is + * made to get a {@link UserSecret} while said engine does not support user secrets. + */ +public class UnsupportedUserSecretEngineException extends UnsupportedOperationException + implements SecretError { + public UnsupportedUserSecretEngineException(String engine) { + super(String.format("Unsupported secret engine identifier '%s' for user secrets", engine)); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.UNSUPPORTED_USER_SECRET_ENGINE.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretTypeException.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretTypeException.java new file mode 100644 index 000000000..f45230d3e --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UnsupportedUserSecretTypeException.java @@ -0,0 +1,36 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; +import com.netflix.spinnaker.kork.secrets.SecretError; +import com.netflix.spinnaker.kork.secrets.SecretErrorCode; + +/** + * Exception thrown when a {@link UserSecretSerde} is unable to serialize or deserialize a secret. + */ +public class UnsupportedUserSecretTypeException extends SecretDecryptionException + implements SecretError { + public UnsupportedUserSecretTypeException(String type) { + super(String.format("Unsupported user secret type '%s'", type)); + } + + @Override + public String getErrorCode() { + return SecretErrorCode.UNSUPPORTED_USER_SECRET_TYPE.getErrorCode(); + } +} diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecret.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecret.java index 5515b60bd..159fa9f64 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecret.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecret.java @@ -19,14 +19,12 @@ import com.netflix.spinnaker.kork.annotations.NonnullByDefault; import com.netflix.spinnaker.kork.secrets.SecretEngine; import com.netflix.spinnaker.security.AccessControlled; -import java.util.Collections; -import java.util.Set; -import java.util.stream.Collectors; +import com.netflix.spinnaker.security.SpinnakerAuthorities; +import java.util.List; import lombok.Builder; import lombok.Getter; import lombok.experimental.Delegate; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.AuthorityUtils; /** * User secrets are externally stored secrets with additional user-provided metadata regarding who @@ -51,11 +49,7 @@ public class UserSecret implements AccessControlled { @Override public boolean isAuthorized(Authentication authentication, Object authorization) { - Set userAuthorities = - AuthorityUtils.authorityListToSet(authentication.getAuthorities()); - Set permittedAuthorities = - getRoles().stream().map(role -> "ROLE_" + role).collect(Collectors.toSet()); - return permittedAuthorities.isEmpty() - || !Collections.disjoint(userAuthorities, permittedAuthorities); + List roles = getRoles(); + return roles.isEmpty() || SpinnakerAuthorities.hasAnyRole(authentication, roles); } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretData.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretData.java index 8048812f3..bd020c5b3 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretData.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretData.java @@ -17,9 +17,27 @@ package com.netflix.spinnaker.kork.secrets.user; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.NoSuchElementException; @NonnullByDefault public interface UserSecretData { - /** Gets the value of this secret with the provided key and returns a string encoding of it. */ + /** + * Gets the value of this secret with the provided key and returns a string encoding of it. + * + * @param key the key to look up the secret value for in this data; can be an empty string for + * flat secrets + * @return the secret value encoded as a string + * @throws NoSuchElementException if no secret value exists for the given key + */ String getSecretString(String key); + + /** + * Gets the value of this secret as a single string if the underlying secret data supports it. + * + * @return the secret payload as a string + * @throws UnsupportedOperationException if this secret doesn't support scalar strings + */ + default String getSecretString() { + throw new UnsupportedOperationException(); + } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManager.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManager.java index d94c0113f..52ae77d4d 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManager.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretManager.java @@ -18,9 +18,11 @@ import com.netflix.spinnaker.kork.annotations.NonnullByDefault; import com.netflix.spinnaker.kork.secrets.EncryptedSecret; +import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; import com.netflix.spinnaker.kork.secrets.SecretEngine; import com.netflix.spinnaker.kork.secrets.SecretEngineRegistry; +import com.netflix.spinnaker.kork.secrets.UnsupportedSecretEngineException; import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -42,15 +44,27 @@ public class UserSecretManager { * * @param reference parsed user secret reference to fetch * @return the decrypted user secret + * @throws UnsupportedSecretEngineException if the secret reference does not have a corresponding + * secret engine + * @throws UnsupportedUserSecretEngineException if the secret engine does not support user secrets + * @throws MissingUserSecretMetadataException if the secret is missing its {@link + * UserSecretMetadata} + * @throws InvalidUserSecretMetadataException if the secret has metadata that cannot be parsed + * @throws InvalidSecretFormatException if the secret reference has other validation errors + * @throws SecretDecryptionException if the secret reference cannot be fetched */ public UserSecret getUserSecret(UserSecretReference reference) { String engineIdentifier = reference.getEngineIdentifier(); SecretEngine engine = registry.getEngine(engineIdentifier); if (engine == null) { - throw new SecretDecryptionException("Unknown secret engine identifier: " + engineIdentifier); + throw new UnsupportedSecretEngineException(engineIdentifier); } engine.validate(reference); - return engine.decrypt(reference); + try { + return engine.decrypt(reference); + } catch (UnsupportedOperationException e) { + throw new UnsupportedSecretEngineException(engineIdentifier); + } } /** @@ -59,12 +73,15 @@ public UserSecret getUserSecret(UserSecretReference reference) { * * @param reference parsed external secret reference to fetch * @return the decrypted external secret + * @throws SecretDecryptionException if the external secret does not have a corresponding secret + * engine or cannot be fetched + * @throws InvalidSecretFormatException if the external secret reference is invalid */ public byte[] getExternalSecret(EncryptedSecret reference) { String engineIdentifier = reference.getEngineIdentifier(); SecretEngine engine = registry.getEngine(engineIdentifier); if (engine == null) { - throw new SecretDecryptionException("Unknown secret engine identifier: " + engineIdentifier); + throw new UnsupportedSecretEngineException(engineIdentifier); } engine.validate(reference); return engine.decrypt(reference); @@ -76,6 +93,9 @@ public byte[] getExternalSecret(EncryptedSecret reference) { * * @param reference parsed external secret reference to fetch * @return the decrypted external secret string + * @throws SecretDecryptionException if the external secret does not have a corresponding secret + * engine or cannot be fetched + * @throws InvalidSecretFormatException if the external secret reference is invalid */ public String getExternalSecretString(EncryptedSecret reference) { return new String(getExternalSecret(reference), StandardCharsets.UTF_8); diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretMetadata.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretMetadata.java index 8b506fa2a..97a656511 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretMetadata.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretMetadata.java @@ -31,8 +31,10 @@ public class UserSecretMetadata { /** Returns the type of the user secret. */ @Nonnull private final String type; + /** Returns the encoding of the user secret. */ - @Nullable private final String encoding; + @Nullable @Builder.Default private final String encoding = null; + /** Returns the authorized roles that can use the user secret. */ - @Nonnull private final List roles; + @Nonnull @Builder.Default private final List roles = List.of(); } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReference.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReference.java index 756342a79..37315a98c 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReference.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReference.java @@ -51,7 +51,7 @@ * *

User secrets may contain more than one secret value. The {@code k} parameter may be specified * to select one of the secrets by name which will return a projected version of the secret with the - * selected key. + * selected key. Only one {@code k} parameter should be specified in a secret URI. * * @see UserSecret */ @@ -59,7 +59,7 @@ @ToString @Getter public class UserSecretReference { - private static final Pattern SECRET_URI = Pattern.compile("^secret(File)?://.+"); + private static final Pattern SECRET_URI = Pattern.compile("^secret://.+"); public static final String SECRET_SCHEME = "secret"; @Nonnull private final String engineIdentifier; @@ -67,21 +67,38 @@ public class UserSecretReference { private UserSecretReference(URI uri) { if (!SECRET_SCHEME.equals(uri.getScheme())) { - throw new InvalidSecretFormatException("Only secret:// URIs supported"); + throw new InvalidUserSecretReferenceException("Only secret:// URIs supported", uri); } - engineIdentifier = uri.getAuthority(); - String[] queryKeyValues = uri.getQuery().split("&"); + String authority = uri.getAuthority(); + if (authority == null) { + throw new InvalidUserSecretReferenceException( + "No secret engine defined in user secret URI", uri); + } + engineIdentifier = authority; + String query = uri.getQuery(); + if (query == null) { + throw new InvalidUserSecretReferenceException( + "User secret URI has no query string defined", uri); + } + String[] queryKeyValues = query.split("&"); if (queryKeyValues.length == 0) { - throw new InvalidSecretFormatException( - "Invalid user secret URI has no query parameters defined"); + throw new InvalidUserSecretReferenceException( + "Invalid user secret URI has no query parameters defined", uri); } for (String keyValue : queryKeyValues) { String[] pair = keyValue.split("=", 2); if (pair.length != 2) { - throw new InvalidSecretFormatException( - "Invalid user secret query string; missing parameter value for '" + keyValue + "'"); + throw new InvalidUserSecretReferenceException( + "Invalid user secret query string; missing parameter value for '" + keyValue + "'", + uri); + } + String key = pair[0]; + if (parameters.containsKey(key)) { + throw new InvalidUserSecretReferenceException( + "Invalid user secret query string has duplicate key '" + key + "'", uri); } - parameters.put(pair[0], pair[1]); + String value = pair[1]; + parameters.put(key, value); } } @@ -90,14 +107,14 @@ private UserSecretReference(URI uri) { * * @param input URI data to parse * @return the parsed UserSecretReference - * @throws InvalidSecretFormatException when the URI is invalid + * @throws InvalidUserSecretReferenceException when the URI is invalid */ @Nonnull public static UserSecretReference parse(@Nonnull String input) { try { return new UserSecretReference(new URI(input)); } catch (URISyntaxException e) { - throw new InvalidSecretFormatException(e); + throw new InvalidUserSecretReferenceException(input, e); } } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerde.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerde.java index 9c291a0a6..186a02491 100644 --- a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerde.java +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretSerde.java @@ -17,12 +17,38 @@ package com.netflix.spinnaker.kork.secrets.user; import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; +import com.netflix.spinnaker.kork.secrets.SecretException; @NonnullByDefault public interface UserSecretSerde { + + /** + * Checks if this serde supports user secrets with the given metadata. + * + * @param metadata the user secret metadata to check for support + * @return true if this serde can serialize and deserialize user secrets with the given metadata + */ boolean supports(UserSecretMetadata metadata); + /** + * Deserializes a raw user secret payload with its parsed metadata. + * + * @param encoded the raw user secret data + * @param metadata the parsed user secret metadata corresponding to the given raw secret + * @return the parsed user secret + * @throws SecretDecryptionException if the user secret data cannot be parsed as configured by the + * metadata + */ UserSecret deserialize(byte[] encoded, UserSecretMetadata metadata); + /** + * Serializes a raw user secret to the specified encoding in the given metadata. + * + * @param secret the user secret data + * @param metadata the metadata describing the user secret + * @return the serialized user secret + * @throws SecretException if the user secret cannot be serialized + */ byte[] serialize(UserSecretData secret, UserSecretMetadata metadata); } diff --git a/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretService.java b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretService.java new file mode 100644 index 000000000..8821a354a --- /dev/null +++ b/kork-secrets/src/main/java/com/netflix/spinnaker/kork/secrets/user/UserSecretService.java @@ -0,0 +1,112 @@ +/* + * Copyright 2022 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import com.netflix.spinnaker.kork.secrets.EncryptedSecret; +import com.netflix.spinnaker.kork.secrets.InvalidSecretFormatException; +import com.netflix.spinnaker.kork.secrets.SecretDecryptionException; +import com.netflix.spinnaker.kork.secrets.StandardSecretParameter; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiConsumer; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PostAuthorize; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@NonnullByDefault +public class UserSecretService { + private final UserSecretManager secretManager; + private final Map> resourceIdsWithUserSecrets = + new ConcurrentHashMap<>(); + + @PostAuthorize("hasPermission(returnObject, 'READ')") + public UserSecret getUserSecret(UserSecretReference ref) { + return secretManager.getUserSecret(ref); + } + + /** + * Gets a user secret string value for the given resource id. User secret references are tracked + * through this method to support time-of-use access control checks for resources after they've + * been loaded by the system. + * + * @param ref parsed user secret reference to decrypt + * @param resourceId id of resource referencing the user secret + * @return the contents of the requested user secret string + */ + public String getUserSecretStringForResource(UserSecretReference ref, String resourceId) { + var secret = secretManager.getUserSecret(ref); + resourceIdsWithUserSecrets + .computeIfAbsent(resourceId, ignored -> ConcurrentHashMap.newKeySet()) + .add(ref); + var params = ref.getParameters(); + String key = params.getOrDefault(StandardSecretParameter.KEY.getParameterName(), ""); + try { + return secret.getSecretString(key); + } catch (NoSuchElementException e) { + throw new SecretDecryptionException(e); + } + } + + /** + * Checks whether the provided resource id is being tracked for user secrets. Resources with no + * user secrets or that do not exist should return {@code false}. + */ + public boolean isTrackingUserSecretsForResource(String resourceId) { + return resourceIdsWithUserSecrets.containsKey(resourceId); + } + + /** Performs an action for all tracked user secrets of the provided resource id. */ + public void forEachUserSecretOfResource( + String resourceId, BiConsumer action) { + resourceIdsWithUserSecrets + .getOrDefault(resourceId, Set.of()) + .forEach(ref -> action.accept(ref, secretManager.getUserSecret(ref))); + } + + /** + * Attempts to fetch and decrypt the given parsed external secret reference. If this is unable to + * fetch the secret, then this will throw an exception. + * + * @param reference parsed external secret reference to fetch + * @throws SecretDecryptionException if the external secret does not have a corresponding secret + * engine or cannot be fetched + * @throws InvalidSecretFormatException if the external secret reference is invalid + */ + public void checkExternalSecret(EncryptedSecret reference) { + secretManager.getExternalSecret(reference); + } + + /** + * Fetches and decrypts the given parsed external secret reference encoded as a string. External + * secrets are secrets available through {@link EncryptedSecret} URIs. + * + * @param reference parsed external secret reference to fetch + * @return the decrypted external secret string + * @throws SecretDecryptionException if the external secret does not have a corresponding secret + * engine or cannot be fetched + * @throws InvalidSecretFormatException if the external secret reference is invalid + */ + public String getExternalSecretString(EncryptedSecret reference) { + return secretManager.getExternalSecretString(reference); + } +} diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java index 57eb2cf59..b56a13340 100644 --- a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretReferenceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; @@ -15,6 +16,23 @@ public void invalidSecretURI() { assertFalse(UserSecretReference.isUserSecret("file:///hello")); } + @Test + public void missingQueryStringInSecretURI() { + // ensure we're not throwing a NullPointerException + assertThrows( + InvalidUserSecretReferenceException.class, () -> UserSecretReference.parse("secret://foo")); + assertFalse(UserSecretReference.tryParse("secret://foo").isPresent()); + } + + @Test + public void queryParametersWithoutValuesAreInvalid() { + // ensure we're not throwing a NullPointerException + assertThrows( + InvalidUserSecretReferenceException.class, + () -> UserSecretReference.parse("secret://foo?bar")); + assertFalse(UserSecretReference.tryParse("secret://foo?bar").isPresent()); + } + @Test public void goodSecretURI() { var ref = UserSecretReference.parse("secret://foo?param1=bar1¶m2=baz2"); @@ -35,4 +53,13 @@ public void encodedURIData() { assertEquals("hello world", parameters.get("first")); assertEquals("hello world", parameters.get("second")); } + + @Test + void queryParametersWithDuplicateKeysAreInvalid() { + // instead of silently merging duplicate keys and using the last occurrence, this should throw + // an exception + assertThrows( + InvalidUserSecretReferenceException.class, + () -> UserSecretReference.parse("secret://engine?k=one&k=two&k=three")); + } } diff --git a/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretServiceTest.java b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretServiceTest.java new file mode 100644 index 000000000..d8056b54a --- /dev/null +++ b/kork-secrets/src/test/java/com/netflix/spinnaker/kork/secrets/user/UserSecretServiceTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2023 Apple Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package com.netflix.spinnaker.kork.secrets.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.BDDMockito.given; + +import com.netflix.spinnaker.kork.secrets.SecretEngine; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +@SpringJUnitConfig +class UserSecretServiceTest { + @MockBean SecretEngine mockSecretEngine; + @Autowired UserSecretService userSecretService; + + UserSecret secret = + UserSecret.builder() + .data(new StringUserSecretData("super-secret")) + .metadata(UserSecretMetadata.builder().type("string").build()) + .build(); + + @BeforeEach + void setUp() { + given(mockSecretEngine.identifier()).willReturn("mock"); + } + + @Test + void tracksUserSecret() { + UserSecretReference ref = UserSecretReference.parse("secret://mock?k=v"); + given(mockSecretEngine.decrypt(ref)).willReturn(secret); + + String resourceId = "some-resource-id"; + assertFalse(userSecretService.isTrackingUserSecretsForResource(resourceId)); + String secretString = userSecretService.getUserSecretStringForResource(ref, resourceId); + assertEquals("super-secret", secretString); + assertTrue(userSecretService.isTrackingUserSecretsForResource(resourceId)); + } + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration + static class TestConfig {} +}