diff --git a/clouddriver-api/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionRepository.java b/clouddriver-api/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionRepository.java new file mode 100644 index 00000000000..79cd396f873 --- /dev/null +++ b/clouddriver-api/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionRepository.java @@ -0,0 +1,140 @@ +/* + * Copyright 2021 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.clouddriver.security; + +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.kork.annotations.Alpha; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.List; +import javax.annotation.Nullable; + +/** Provides CRUD persistence operations for account {@link CredentialsDefinition} instances. */ +@Alpha +@NonnullByDefault +public interface AccountDefinitionRepository { + /** + * Looks up an account definition using the account name. + * + * @param name account name to look up + * @return the configured account definition or null if none is found + */ + @Nullable + CredentialsDefinition getByName(String name); + + /** + * Lists account definitions for a given account type. This API allows for infinite scrolling + * style pagination using the {@code limit} and {@code startingAccountName} parameters. + * + * @param typeName account type to search for (the value of the @JsonTypeName annotation on the + * corresponding CredentialsDefinition class + * @param limit max number of entries to return + * @param startingAccountName where to begin results list if specified or start at the beginning + * if null + * @return list of stored account definitions matching the given account type name (sorted + * alphabetically) + */ + List listByType( + String typeName, int limit, @Nullable String startingAccountName); + + /** + * Lists account definitions for a given account type. Account types correspond to the value in + * the {@code @JsonTypeName} annotation present on the corresponding {@code CredentialsDefinition} + * class. + * + * @param typeName account type to search for + * @return list of all stored account definitions matching the given account type name + */ + List listByType(String typeName); + + /** + * Creates a new account definition using the provided data. Secrets should use {@code + * SecretEngine} encrypted URIs (e.g., {@code + * encrypted:secrets-manager!r:us-west-2!s:my-account-credentials}) when the underlying storage + * provider does not support row-level encryption or equivalent security features. Encrypted URIs + * will only be decrypted when loading account definitions, not when storing them. Note that + * account definitions correspond to the JSON representation of the underlying {@link + * CredentialsDefinition} object along with a JSON type discriminator field with the key + * {@code @type} and value of the corresponding {@code @JsonTypeName} annotation. + * + * @param definition account definition to store as a new account + */ + void create(CredentialsDefinition definition); + + /** + * Updates an existing account definition using the provided data. See details in {@link + * #create(CredentialsDefinition)} for details on the format. + * + * @param definition updated account definition to replace an existing account + * @see #create(CredentialsDefinition) + */ + void update(CredentialsDefinition definition); + + /** + * Deletes an account by name. + * + * @param name name of account to delete + */ + void delete(String name); + + /** + * Looks up the revision history of an account given its name. Keys correspond to ascending + * version numbers for each account definition update. + * + * @param name account name to look up history for + * @return history of account updates for the given account name + */ + List revisionHistory(String name); + + /** + * Provides metadata for an account definition revision when making updates to an account via + * {@link AccountDefinitionRepository} APIs. + */ + class Revision { + private final int version; + private final long timestamp; + private final @Nullable CredentialsDefinition account; + + /** Constructs a revision entry with a version and account definition. */ + public Revision(int version, long timestamp, @Nullable CredentialsDefinition account) { + this.version = version; + this.timestamp = timestamp; + this.account = account; + } + + /** Returns the version number of this revision. Versions start at 1 and increase from there. */ + public int getVersion() { + return version; + } + + /** + * Returns the timestamp (in millis since the epoch) corresponding to when this revision was + * made. + */ + public long getTimestamp() { + return timestamp; + } + + /** + * Returns the account definition used in this revision. Returns {@code null} when this revision + * corresponds to a deletion. + */ + @Nullable + public CredentialsDefinition getAccount() { + return account; + } + } +} diff --git a/clouddriver-core/clouddriver-core.gradle b/clouddriver-core/clouddriver-core.gradle index ff297537775..09083477b38 100644 --- a/clouddriver-core/clouddriver-core.gradle +++ b/clouddriver-core/clouddriver-core.gradle @@ -31,6 +31,7 @@ dependencies { implementation "io.spinnaker.kork:kork-web" implementation "io.spinnaker.kork:kork-annotations" implementation "io.spinnaker.kork:kork-moniker" + implementation "io.spinnaker.kork:kork-secrets" implementation "com.squareup.okhttp:okhttp" implementation "com.squareup.okhttp:okhttp-apache" implementation "com.squareup.okhttp:okhttp-urlconnection" @@ -58,4 +59,5 @@ dependencies { testImplementation "org.junit.jupiter:junit-jupiter-engine" testImplementation "org.junit.jupiter:junit-jupiter-params" testImplementation "org.mockito:mockito-core" + testImplementation "org.springframework.boot:spring-boot-starter-test" } diff --git a/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/config/AccountDefinitionConfiguration.java b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/config/AccountDefinitionConfiguration.java new file mode 100644 index 00000000000..ce941f77103 --- /dev/null +++ b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/config/AccountDefinitionConfiguration.java @@ -0,0 +1,148 @@ +/* + * Copyright 2021 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.clouddriver.config; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.deser.std.StringDeserializer; +import com.netflix.spinnaker.clouddriver.jackson.AccountDefinitionModule; +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionMapper; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.kork.secrets.EncryptedSecret; +import com.netflix.spinnaker.kork.secrets.SecretManager; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import lombok.Data; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ResourceLoader; +import org.springframework.core.type.filter.AssignableTypeFilter; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import org.springframework.util.ClassUtils; + +/** + * Provides configuration settings related to managing account credential definitions at runtime. + * + * @see Properties + */ +@Configuration +@EnableConfigurationProperties(AccountDefinitionConfiguration.Properties.class) +@Log4j2 +public class AccountDefinitionConfiguration { + + /** + * Creates a mapper that can convert between JSON and {@link CredentialsDefinition} classes that + * are annotated with {@link JsonTypeName}. Account definition classes are scanned in {@code + * com.netflix.spinnaker.clouddriver} and any additional packages configured in {@link + * Properties#setAdditionalScanPackages(List)}. Only eligible account definition classes are used + * with a fresh {@link com.fasterxml.jackson.databind.ObjectMapper} configured with a custom + * string deserializer to transparently load and decrypt {@link EncryptedSecret} strings. These + * encrypted secrets must reference a configured {@link + * com.netflix.spinnaker.kork.secrets.SecretEngine}. + * + * @see com.netflix.spinnaker.kork.jackson.ObjectMapperSubtypeConfigurer + */ + @Bean + public AccountDefinitionMapper accountDefinitionMapper( + Jackson2ObjectMapperBuilder mapperBuilder, + SecretManager secretManager, + AccountDefinitionModule accountDefinitionModule) { + mapperBuilder.deserializers(createSecretDecryptingDeserializer(secretManager)); + var mapper = mapperBuilder.build(); + return new AccountDefinitionMapper(mapper, accountDefinitionModule.getTypeMap()); + } + + @Bean + public AccountDefinitionModule accountDefinitionModule( + ResourceLoader loader, Properties properties) { + return new AccountDefinitionModule(findAccountDefinitionTypes(loader, properties)); + } + + private static StringDeserializer createSecretDecryptingDeserializer( + SecretManager secretManager) { + return new StringDeserializer() { + @Override + public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + var string = super.deserialize(p, ctxt); + if (EncryptedSecret.isEncryptedSecret(string)) { + return EncryptedSecret.isEncryptedFile(string) + ? secretManager.decryptAsFile(string).toString() + : secretManager.decrypt(string); + } else { + return string; + } + } + }; + } + + @SuppressWarnings("unchecked") + private static Class[] findAccountDefinitionTypes( + ResourceLoader loader, Properties properties) { + var provider = new ClassPathScanningCandidateComponentProvider(false); + provider.setResourceLoader(loader); + provider.addIncludeFilter(new AssignableTypeFilter(CredentialsDefinition.class)); + List scanPackages = new ArrayList<>(properties.additionalScanPackages); + scanPackages.add(0, "com.netflix.spinnaker.clouddriver"); + return scanPackages.stream() + .flatMap(packageName -> provider.findCandidateComponents(packageName).stream()) + .map(BeanDefinition::getBeanClassName) + .filter(Objects::nonNull) + .map(AccountDefinitionConfiguration::loadCredentialsDefinitionType) + .filter(type -> type.isAnnotationPresent(JsonTypeName.class)) + .toArray(Class[]::new); + } + + private static Class loadCredentialsDefinitionType( + String className) { + try { + return ClassUtils.forName(className, null).asSubclass(CredentialsDefinition.class); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + String.format("Unable to load CredentialsDefinition type `%s`", className), e); + } + } + + @ConfigurationProperties("account.storage") + @ConditionalOnProperty("account.storage.enabled") + @Data + public static class Properties { + /** + * Indicates whether to enable durable storage for account definitions. When enabled with an + * implementation of {@link + * com.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository}, account definitions + * can be stored and retrieved by a durable storage provider. + */ + private boolean enabled; + + /** + * Additional packages to scan for {@link + * com.netflix.spinnaker.credentials.definition.CredentialsDefinition} implementation classes + * that may be annotated with {@link JsonTypeName} to participate in the account management + * system. These packages are in addition to the default scan package from within Clouddriver. + */ + private List additionalScanPackages = List.of(); + } +} diff --git a/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/AccountDefinitionModule.java b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/AccountDefinitionModule.java new file mode 100644 index 00000000000..d505b269393 --- /dev/null +++ b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/AccountDefinitionModule.java @@ -0,0 +1,54 @@ +/* + * Copyright 2021 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.clouddriver.jackson; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.netflix.spinnaker.clouddriver.jackson.mixins.CredentialsDefinitionMixin; +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionMapper; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import java.util.HashMap; +import java.util.Map; + +/** + * Jackson module to register {@link CredentialsDefinition} type discriminators for the provided + * account definition types. + */ +public class AccountDefinitionModule extends SimpleModule { + + private final Class[] accountDefinitionTypes; + + public AccountDefinitionModule(Class[] accountDefinitionTypes) { + super("Clouddriver Account Definition API"); + this.accountDefinitionTypes = accountDefinitionTypes; + } + + @Override + public void setupModule(SetupContext context) { + super.setupModule(context); + context.setMixInAnnotations(CredentialsDefinition.class, CredentialsDefinitionMixin.class); + context.registerSubtypes(accountDefinitionTypes); + } + + public Map> getTypeMap() { + var typeMap = + new HashMap>(accountDefinitionTypes.length); + for (Class type : accountDefinitionTypes) { + typeMap.put(AccountDefinitionMapper.getJsonTypeName(type), type); + } + return typeMap; + } +} diff --git a/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/mixins/CredentialsDefinitionMixin.java b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/mixins/CredentialsDefinitionMixin.java new file mode 100644 index 00000000000..e8961902d6c --- /dev/null +++ b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/mixins/CredentialsDefinitionMixin.java @@ -0,0 +1,30 @@ +/* + * Copyright 2021 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.clouddriver.jackson.mixins; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Jackson mixin to add a polymorphic type name value. When a {@link + * com.netflix.spinnaker.credentials.definition.CredentialsDefinition} implementation class is + * annotated with {@link com.fasterxml.jackson.annotation.JsonTypeName}, then the value of that + * annotation is used as the {@code @type} property value when marshalling and unmarshalling + * CredentialsDefinition classes. It is recommended that the corresponding cloud provider name for + * the credentials be used here. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME) +public interface CredentialsDefinitionMixin {} diff --git a/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapper.java b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapper.java new file mode 100644 index 00000000000..02a4548c5a1 --- /dev/null +++ b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapper.java @@ -0,0 +1,60 @@ +/* + * Copyright 2021 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.clouddriver.security; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.Map; +import java.util.Objects; + +/** + * Maps account definitions to and from strings. Only {@link CredentialsDefinition} classes + * annotated with a {@link JsonTypeName} will be considered. + */ +@NonnullByDefault +public class AccountDefinitionMapper { + + /** + * Returns the JSON type discriminator for a given class. Types are defined via the {@link + * JsonTypeName} annotation. + */ + public static String getJsonTypeName(Class clazz) { + var jsonTypeName = clazz.getAnnotation(JsonTypeName.class); + return Objects.requireNonNull(jsonTypeName, "No @JsonTypeName for " + clazz).value(); + } + + private final ObjectMapper objectMapper; + private final Map> typeMap; + + public AccountDefinitionMapper( + ObjectMapper objectMapper, Map> typeMap) { + this.objectMapper = objectMapper; + this.typeMap = typeMap; + } + + public String convertToString(CredentialsDefinition definition) throws JsonProcessingException { + return objectMapper.writeValueAsString(definition); + } + + public CredentialsDefinition convertFromString(String string, String type) + throws JsonProcessingException { + return objectMapper.readValue(string, typeMap.get(type)); + } +} diff --git a/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionSource.java b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionSource.java new file mode 100644 index 00000000000..5d31ffc5909 --- /dev/null +++ b/clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionSource.java @@ -0,0 +1,88 @@ +/* + * Copyright 2021 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.clouddriver.security; + +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinitionSource; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Provides a full list of CredentialsDefinition account instances for a given credentials type. + * Given an {@link AccountDefinitionRepository} bean and an optional list of {@code + * CredentialsDefinitionSource} beans for a given account type {@code T}, this class combines the + * lists from all the given credentials definition sources. When no {@code + * CredentialsDefinitionSource} beans are available for a given account type, then a default + * source should be specified to wrap any existing Spring configuration beans that provide the same. + * + * @param account credentials definition type + */ +@NonnullByDefault +public class AccountDefinitionSource + implements CredentialsDefinitionSource { + + private static final Logger LOGGER = LogManager.getLogger(); + private final List> sources; + + /** + * Constructs an account-based {@code CredentialsDefinitionSource} using the provided + * repository, account type, and additional sources for accounts of the same type. + * + * @param repository the backing repository for managing account definitions at runtime + * @param type the account type supported by this source (must be annotated with {@link + * JsonTypeName}) + * @param additionalSources the list of other credential definition sources to list accounts from + */ + public AccountDefinitionSource( + AccountDefinitionRepository repository, + Class type, + List> additionalSources) { + var typeName = AccountDefinitionMapper.getJsonTypeName(type); + List> sources = new ArrayList<>(additionalSources.size() + 1); + sources.add( + () -> + repository.listByType(typeName).stream().map(type::cast).collect(Collectors.toList())); + sources.addAll(additionalSources); + this.sources = List.copyOf(sources); + } + + @Override + public List getCredentialsDefinitions() { + Set seenAccountNames = new HashSet<>(); + return sources.stream() + .flatMap(source -> source.getCredentialsDefinitions().stream()) + .filter( + definition -> { + var name = definition.getName(); + if (seenAccountNames.add(name)) { + return true; + } else { + LOGGER.warn( + "Duplicate account name detected ({}). Skipping this definition.", name); + return false; + } + }) + .collect(Collectors.toList()); + } +} diff --git a/clouddriver-core/src/test/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapperTest.java b/clouddriver-core/src/test/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapperTest.java new file mode 100644 index 00000000000..3d8451f51ab --- /dev/null +++ b/clouddriver-core/src/test/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapperTest.java @@ -0,0 +1,66 @@ +/* + * Copyright 2021 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.clouddriver.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.netflix.spinnaker.clouddriver.config.AccountDefinitionConfiguration; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.fiat.model.Authorization; +import io.spinnaker.test.security.TestAccount; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.test.context.TestPropertySource; + +@SpringBootTest(classes = AccountDefinitionConfiguration.class) +@ImportAutoConfiguration(JacksonAutoConfiguration.class) +@TestPropertySource( + properties = "account.storage.additionalScanPackages = io.spinnaker.test.security") +@ComponentScan("com.netflix.spinnaker.kork.secrets") +class AccountDefinitionMapperTest { + + @Autowired AccountDefinitionMapper mapper; + + @Test + void canConvertAdditionalAccountTypes() throws JsonProcessingException { + var account = new TestAccount(); + account.setData("name", "foo"); + account.getPermissions().add(Authorization.READ, List.of("dev", "sre")); + account.getPermissions().add(Authorization.WRITE, "sre"); + account.setData("password", "hunter2"); + assertEquals(account, mapper.convertFromString(mapper.convertToString(account), "test")); + } + + @Test + void canDecryptSecretUris() { + var data = "{\"@type\":\"test\",\"name\":\"bar\",\"password\":\"encrypted:noop!v:hunter2\"}"; + CredentialsDefinition account = + assertDoesNotThrow(() -> mapper.convertFromString(data, "test")); + assertThat(account).isInstanceOf(TestAccount.class); + assertThat(account.getName()).isEqualTo("bar"); + TestAccount testAccount = (TestAccount) account; + assertThat(testAccount.getData().get("password")).isEqualTo("hunter2"); + } +} diff --git a/clouddriver-core/src/test/java/io/spinnaker/test/security/TestAccount.java b/clouddriver-core/src/test/java/io/spinnaker/test/security/TestAccount.java new file mode 100644 index 00000000000..4fdbe672799 --- /dev/null +++ b/clouddriver-core/src/test/java/io/spinnaker/test/security/TestAccount.java @@ -0,0 +1,68 @@ +/* + * Copyright 2021 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 io.spinnaker.test.security; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.fiat.model.resources.Permissions; +import com.netflix.spinnaker.kork.annotations.NonnullByDefault; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +@JsonTypeName("test") +@NonnullByDefault +public class TestAccount implements CredentialsDefinition { + private final Permissions.Builder permissions = new Permissions.Builder(); + private final Map data = new HashMap<>(); + + @Override + @JsonIgnore + public String getName() { + return (String) data.get("name"); + } + + public Permissions.Builder getPermissions() { + return permissions; + } + + @JsonAnyGetter + public Map getData() { + return data; + } + + @JsonAnySetter + public void setData(String key, Object value) { + data.put(key, value); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TestAccount that = (TestAccount) o; + return permissions.equals(that.permissions) && data.equals(that.data); + } + + @Override + public int hashCode() { + return Objects.hash(permissions, data); + } +} diff --git a/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/config/KubernetesAccountProperties.java b/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/config/KubernetesAccountProperties.java index 99750830807..07a86f89344 100644 --- a/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/config/KubernetesAccountProperties.java +++ b/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/clouddriver/kubernetes/config/KubernetesAccountProperties.java @@ -17,7 +17,9 @@ package com.netflix.spinnaker.clouddriver.kubernetes.config; +import com.fasterxml.jackson.annotation.JsonTypeName; import com.google.common.base.Strings; +import com.netflix.spinnaker.clouddriver.kubernetes.KubernetesCloudProvider; import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; import com.netflix.spinnaker.fiat.model.resources.Permissions; import java.util.ArrayList; @@ -50,6 +52,7 @@ public class KubernetesAccountProperties { private static final int DEFAULT_CACHE_THREADS = 1; @Data + @JsonTypeName(KubernetesCloudProvider.ID) public static class ManagedAccount implements CredentialsDefinition { private String name; private String environment; diff --git a/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/config/KubernetesAccountDefinitionSourceConfiguration.java b/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/config/KubernetesAccountDefinitionSourceConfiguration.java new file mode 100644 index 00000000000..62d66a98f8e --- /dev/null +++ b/clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/config/KubernetesAccountDefinitionSourceConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright 2021 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.config; + +import com.netflix.spinnaker.clouddriver.kubernetes.config.KubernetesAccountProperties; +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository; +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionSource; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinitionSource; +import java.util.List; +import java.util.Optional; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +@ConditionalOnProperty("account.storage.enabled") +public class KubernetesAccountDefinitionSourceConfiguration { + @Bean + @Primary + public CredentialsDefinitionSource + kubernetesAccountSource( + AccountDefinitionRepository repository, + Optional>> + additionalSources, + KubernetesAccountProperties accountProperties) { + return new AccountDefinitionSource<>( + repository, + KubernetesAccountProperties.ManagedAccount.class, + additionalSources.orElseGet(() -> List.of(accountProperties::getAccounts))); + } +} diff --git a/clouddriver-sql/clouddriver-sql.gradle b/clouddriver-sql/clouddriver-sql.gradle index 74f2693d112..60b6d830c8c 100644 --- a/clouddriver-sql/clouddriver-sql.gradle +++ b/clouddriver-sql/clouddriver-sql.gradle @@ -24,6 +24,7 @@ dependencies { implementation project(":clouddriver-event") implementation "io.spinnaker.kork:kork-core" + implementation "io.spinnaker.kork:kork-secrets" implementation "io.spinnaker.kork:kork-sql" implementation "io.spinnaker.kork:kork-telemetry" implementation "de.huxhorn.sulky:de.huxhorn.sulky.ulid" diff --git a/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/clouddriver/sql/security/SqlAccountDefinitionRepository.kt b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/clouddriver/sql/security/SqlAccountDefinitionRepository.kt new file mode 100644 index 00000000000..068fbe7db25 --- /dev/null +++ b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/clouddriver/sql/security/SqlAccountDefinitionRepository.kt @@ -0,0 +1,188 @@ +/* + * Copyright 2021 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.clouddriver.sql.security + +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionMapper +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository +import com.netflix.spinnaker.clouddriver.sql.read +import com.netflix.spinnaker.clouddriver.sql.transactional +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition +import com.netflix.spinnaker.kork.sql.routing.withPool +import com.netflix.spinnaker.security.AuthenticatedRequest +import org.jooq.DSLContext +import org.jooq.JSON +import org.jooq.Record1 +import org.jooq.Select +import org.jooq.impl.DSL.field +import org.jooq.impl.DSL.table +import java.time.Clock + +class SqlAccountDefinitionRepository( + private val jooq: DSLContext, + private val mapper: AccountDefinitionMapper, + private val clock: Clock, + private val poolName: String +) : AccountDefinitionRepository { + + override fun getByName(name: String): CredentialsDefinition? = + withPool(poolName) { + jooq.read { ctx -> + ctx.select(bodyColumn, typeColumn) + .from(accountsTable) + .where(idColumn.eq(name)) + .fetchOne { (json, type) -> + mapper.convertFromString(json.data(), type) + } + } + } + + override fun listByType( + typeName: String, + limit: Int, + startingAccountName: String? + ): MutableList = + withPool(poolName) { + jooq.read { ctx -> + val conditions = mutableListOf(typeColumn.eq(typeName)) + startingAccountName?.let { conditions += idColumn.ge(it) } + ctx.select(bodyColumn) + .from(accountsTable) + .where(conditions) + .orderBy(idColumn) + .limit(limit) + .fetch { (json) -> + mapper.convertFromString(json.data(), typeName) + } + } + } + + override fun listByType(typeName: String): MutableList = + withPool(poolName) { + jooq.read { ctx -> + ctx.select(bodyColumn) + .from(accountsTable) + .where(typeColumn.eq(typeName)) + .fetch { (json) -> + mapper.convertFromString(json.data(), typeName) + } + } + } + + override fun create(definition: CredentialsDefinition) { + withPool(poolName) { + jooq.transactional { ctx -> + val timestamp = clock.millis() + val user = AuthenticatedRequest.getSpinnakerUser().orElse("anonymous") + val body = JSON.valueOf(mapper.convertToString(definition)) + val typeName = AccountDefinitionMapper.getJsonTypeName(definition.javaClass) + ctx.insertInto(accountsTable) + .set(idColumn, definition.name) + .set(typeColumn, typeName) + .set(bodyColumn, body) + .set(createdColumn, timestamp) + .set(lastModifiedColumn, timestamp) + .set(modifiedByColumn, user) + .execute() + ctx.insertInto(accountHistoryTable) + .set(idColumn, definition.name) + .set(typeColumn, typeName) + .set(bodyColumn, body) + .set(lastModifiedColumn, timestamp) + .set(versionColumn, 1) + .execute() + } + } + } + + override fun update(definition: CredentialsDefinition) { + withPool(poolName) { + jooq.transactional { ctx -> + val timestamp = clock.millis() + val user = AuthenticatedRequest.getSpinnakerUser().orElse("anonymous") + val body = JSON.valueOf(mapper.convertToString(definition)) + ctx.update(accountsTable) + .set(bodyColumn, body) + .set(lastModifiedColumn, timestamp) + .set(modifiedByColumn, user) + .where(idColumn.eq(definition.name)) + .execute() + ctx.insertInto(accountHistoryTable) + .set(idColumn, definition.name) + .set(typeColumn, AccountDefinitionMapper.getJsonTypeName(definition.javaClass)) + .set(bodyColumn, body) + .set(lastModifiedColumn, timestamp) + .set(versionColumn, findLatestVersion(definition.name)) + .execute() + } + } + } + + override fun delete(name: String) { + withPool(poolName) { + jooq.transactional { ctx -> + ctx.insertInto(accountHistoryTable) + .set(idColumn, name) + .set(deletedColumn, true) + .set(lastModifiedColumn, clock.millis()) + .set(versionColumn, findLatestVersion(name)) + .execute() + ctx.deleteFrom(accountsTable) + .where(idColumn.eq(name)) + .execute() + } + } + } + + private fun findLatestVersion(name: String): Select> = + withPool(poolName) { + jooq.read { ctx -> + ctx.select(versionColumn + 1) + .from(accountHistoryTable) + .where(idColumn.eq(name)) + .orderBy(versionColumn.desc()) + .limit(1) + } + } + + override fun revisionHistory(name: String): MutableList = + withPool(poolName) { + jooq.read { ctx -> + ctx.select(bodyColumn, typeColumn, versionColumn, lastModifiedColumn) + .from(accountHistoryTable) + .where(idColumn.eq(name)) + .orderBy(versionColumn.desc()) + .fetch { (body, type, version, timestamp) -> AccountDefinitionRepository.Revision( + version, + timestamp, + body?.let { mapper.convertFromString(it.data(), type) } + ) } + } + } + + companion object { + private val accountsTable = table("accounts") + private val accountHistoryTable = table("accounts_history") + private val idColumn = field("id", String::class.java) + private val bodyColumn = field("body", JSON::class.java) + private val typeColumn = field("type", String::class.java) + private val deletedColumn = field("is_deleted", Boolean::class.java) + private val createdColumn = field("created_at", Long::class.java) + private val lastModifiedColumn = field("last_modified_at", Long::class.java) + private val modifiedByColumn = field("last_modified_by", String::class.java) + private val versionColumn = field("version", Int::class.java) + } +} diff --git a/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/ConnectionPools.kt b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/ConnectionPools.kt index 906c3de2225..6267cd94ef2 100644 --- a/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/ConnectionPools.kt +++ b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/ConnectionPools.kt @@ -21,5 +21,6 @@ enum class ConnectionPools( TASKS("tasks"), CACHE_WRITER("cacheWriter"), CACHE_READER("cacheReader"), - EVENTS("events") + EVENTS("events"), + ACCOUNTS("accounts"), } diff --git a/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConfiguration.kt b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConfiguration.kt index 9ed742d2fdd..88426f715b9 100644 --- a/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConfiguration.kt +++ b/clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/config/SqlConfiguration.kt @@ -19,11 +19,14 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.netflix.spectator.api.Registry import com.netflix.spinnaker.clouddriver.data.task.TaskRepository import com.netflix.spinnaker.clouddriver.event.persistence.EventRepository +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionMapper +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository import com.netflix.spinnaker.clouddriver.sql.SqlProvider import com.netflix.spinnaker.clouddriver.sql.SqlTaskCleanupAgent import com.netflix.spinnaker.clouddriver.sql.SqlTaskRepository import com.netflix.spinnaker.clouddriver.sql.event.SqlEventCleanupAgent import com.netflix.spinnaker.clouddriver.sql.event.SqlEventRepository +import com.netflix.spinnaker.clouddriver.sql.security.SqlAccountDefinitionRepository import com.netflix.spinnaker.kork.dynamicconfig.DynamicConfigService import com.netflix.spinnaker.kork.jackson.ObjectMapperSubtypeConfigurer import com.netflix.spinnaker.kork.jackson.ObjectMapperSubtypeConfigurer.SubtypeLocator @@ -31,7 +34,6 @@ import com.netflix.spinnaker.kork.sql.config.DefaultSqlConfiguration import com.netflix.spinnaker.kork.sql.config.SqlProperties import com.netflix.spinnaker.kork.telemetry.InstrumentedProxy import com.netflix.spinnaker.kork.version.ServiceVersion -import java.time.Clock import org.jooq.DSLContext import org.springframework.beans.factory.annotation.Value import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression @@ -41,6 +43,7 @@ import org.springframework.context.ApplicationEventPublisher import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Import +import java.time.Clock @Configuration @ConditionalOnProperty("sql.enabled") @@ -123,4 +126,13 @@ class SqlConfiguration { ): SqlEventCleanupAgent { return SqlEventCleanupAgent(jooq, registry, properties, dynamicConfigService) } + + @Bean + @ConditionalOnProperty("account.storage.enabled") + fun sqlAccountDefinitionRepository( + jooq: DSLContext, + clock: Clock, + mapper: AccountDefinitionMapper + ): AccountDefinitionRepository = SqlAccountDefinitionRepository(jooq, mapper, clock, ConnectionPools.ACCOUNTS.value) + } diff --git a/clouddriver-sql/src/main/resources/db/changelog-master.yml b/clouddriver-sql/src/main/resources/db/changelog-master.yml index d3ea782c0a3..f2f411fc488 100644 --- a/clouddriver-sql/src/main/resources/db/changelog-master.yml +++ b/clouddriver-sql/src/main/resources/db/changelog-master.yml @@ -20,3 +20,6 @@ databaseChangeLog: - include: file: changelog/20210311-caching-replicas.yml relativeToChangelogFile: true +- include: + file: changelog/20210927-accounts.yml + relativeToChangelogFile: true diff --git a/clouddriver-sql/src/main/resources/db/changelog/20210927-accounts.yml b/clouddriver-sql/src/main/resources/db/changelog/20210927-accounts.yml new file mode 100644 index 00000000000..ec1b95cdcb0 --- /dev/null +++ b/clouddriver-sql/src/main/resources/db/changelog/20210927-accounts.yml @@ -0,0 +1,132 @@ +databaseChangeLog: +- changeSet: + id: create-accounts-table + author: msicker + changes: + - createTable: + tableName: accounts + columns: + - column: + name: id + type: varchar(255) + constraints: + primaryKey: true + nullable: false + - column: + name: type + type: varchar(50) + constraints: + nullable: false + - column: + name: body + type: json + constraints: + nullable: false + - column: + name: created_at + type: bigint + constraints: + nullable: false + - column: + name: last_modified_at + type: bigint + constraints: + nullable: false + - column: + name: last_modified_by + type: varchar(255) + constraints: + nullable: false + - modifySql: + dbms: mysql + append: + value: " engine innodb DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci" + - modifySql: + dbms: postgresql + replace: + replace: json + with: jsonb + rollback: + - dropTable: + tableName: accounts + +- changeSet: + id: create-accounts-table-index + author: jcavanagh + changes: + - createIndex: + indexName: accounts_type_index + tableName: accounts + columns: + - column: + name: id + - column: + name: type + - createIndex: + indexName: accounts_timestamp_index + tableName: accounts + columns: + - column: + name: id + - column: + name: type + - column: + name: created_at + - column: + name: last_modified_at + rollback: + - dropTable: + tableName: accounts +- changeSet: + id: create-accounts-history-table + author: msicker + changes: + - createTable: + tableName: accounts_history + columns: + - column: + name: id + type: varchar(255) + constraints: + primaryKey: true + nullable: false + - column: + name: type + type: varchar(50) + constraints: + nullable: true + - column: + name: body + type: json + constraints: + nullable: true + - column: + name: last_modified_at + type: bigint + constraints: + nullable: false + - column: + name: version + type: int + constraints: + primaryKey: true + nullable: false + descending: true + - column: + name: is_deleted + type: boolean + defaultValueBoolean: false + constraints: + nullable: false + - modifySql: + dbms: mysql + append: + value: " engine innodb DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci" + - modifySql: + dbms: postgresql + replace: + replace: json + with: jsonb + rollback: + - dropTable: + tableName: accounts_history diff --git a/clouddriver-web/src/main/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.groovy b/clouddriver-web/src/main/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.groovy deleted file mode 100644 index eaa0859ff01..00000000000 --- a/clouddriver-web/src/main/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.groovy +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2015 Netflix, 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.clouddriver.controllers - -import com.fasterxml.jackson.databind.ObjectMapper -import com.netflix.spinnaker.clouddriver.configuration.CredentialsConfiguration -import com.netflix.spinnaker.clouddriver.security.AccountCredentials -import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider -import com.netflix.spinnaker.kork.web.exceptions.NotFoundException -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.MessageSource -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RequestMethod -import org.springframework.web.bind.annotation.RequestParam -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("/credentials") -class CredentialsController { - - @Autowired - CredentialsConfiguration credentialsConfiguration - - @Autowired - ObjectMapper objectMapper - - @Autowired - AccountCredentialsProvider accountCredentialsProvider - - @Autowired - MessageSource messageSource - - @RequestMapping(method = RequestMethod.GET) - List list(@RequestParam(value = "expand", required = false) boolean expand) { - accountCredentialsProvider.all.collect { render(expand, it) } - } - - @RequestMapping(value = "/{name:.+}", method = RequestMethod.GET) - Map getAccount(@PathVariable("name") String name) { - def accountDetail = render(true, accountCredentialsProvider.getCredentials(name)) - if (!accountDetail) { - throw new NotFoundException("Account does not exist (name: ${name})") - } - - return accountDetail - } - - Map render(boolean includeDetail, AccountCredentials accountCredentials) { - if (accountCredentials == null) { - return null - } - Map cred = objectMapper.convertValue(accountCredentials, Map) - if (!includeDetail) { - cred.keySet().retainAll(['name', - 'environment', - 'accountType', - 'cloudProvider', - 'requiredGroupMembership', - 'permissions', - 'accountId']) - } - - cred.type = accountCredentials.cloudProvider - cred.challengeDestructiveActions = credentialsConfiguration.challengeDestructiveActionsEnvironments.contains(accountCredentials.environment) - cred.primaryAccount = credentialsConfiguration.primaryAccountTypes.contains(accountCredentials.accountType) - - return cred - } -} diff --git a/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.java b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.java new file mode 100644 index 00000000000..c82b20c15a2 --- /dev/null +++ b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.java @@ -0,0 +1,175 @@ +/* + * Copyright 2021 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.clouddriver.controllers; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.netflix.spinnaker.clouddriver.configuration.CredentialsConfiguration; +import com.netflix.spinnaker.clouddriver.security.AccountCredentials; +import com.netflix.spinnaker.clouddriver.security.AccountCredentialsProvider; +import com.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import com.netflix.spinnaker.kork.annotations.Alpha; +import com.netflix.spinnaker.kork.exceptions.ConfigurationException; +import com.netflix.spinnaker.kork.web.exceptions.NotFoundException; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.stream.Collectors; +import javax.annotation.CheckForNull; +import org.springframework.security.access.prepost.PostFilter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/credentials") +public class CredentialsController { + private final AccountDefinitionRepository repository; + private final CredentialsConfiguration credentialsConfiguration; + private final ObjectMapper objectMapper; + private final AccountCredentialsProvider accountCredentialsProvider; + + public CredentialsController( + Optional repository, + CredentialsConfiguration credentialsConfiguration, + ObjectMapper objectMapper, + AccountCredentialsProvider accountCredentialsProvider) { + this.repository = repository.orElse(null); + this.credentialsConfiguration = credentialsConfiguration; + this.objectMapper = objectMapper; + this.accountCredentialsProvider = accountCredentialsProvider; + } + + @GetMapping + public List> listAccountCredentials(@RequestParam Optional expand) { + boolean shouldExpand = expand.orElse(false); + return accountCredentialsProvider.getAll().stream() + .map(accountCredentials -> renderAccountCredentials(accountCredentials, shouldExpand)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + } + + @GetMapping("/{accountName}") + public Map getAccountCredentialsDetails(@PathVariable String accountName) { + var accountDetail = + renderAccountCredentials(accountCredentialsProvider.getCredentials(accountName), true); + if (accountDetail == null) { + throw new NotFoundException(String.format("Account does not exist (name: %s)", accountName)); + } + return accountDetail; + } + + @CheckForNull + private Map renderAccountCredentials( + AccountCredentials credentials, boolean expand) { + if (credentials == null) { + return null; + } + var cred = objectMapper.convertValue(credentials, new TypeReference>() {}); + if (!expand) { + cred.keySet() + .retainAll( + List.of( + "name", + "environment", + "accountType", + "cloudProvider", + "requiredGroupMembership", + "permissions", + "accountId")); + } + cred.put("type", credentials.getCloudProvider()); + cred.put( + "challengeDestructiveActions", + credentialsConfiguration + .getChallengeDestructiveActionsEnvironments() + .contains(credentials.getEnvironment())); + cred.put( + "primaryAccount", + credentialsConfiguration.getPrimaryAccountTypes().contains(credentials.getAccountType())); + return cred; + } + + @GetMapping("/type/{accountType}") + // ACCOUNT/WRITE permissions are required to view details of an account; + // ACCOUNT/READ permissions are only sufficient to view resources that use that account + // such as load balancers, security groups, clusters, etc. + @PostFilter("hasPermission(filterObject.name, 'ACCOUNT', 'WRITE')") + @Alpha + public List listAccountsByType( + @PathVariable String accountType, + @RequestParam OptionalInt limit, + @RequestParam Optional startingAccountName) { + validateAccountStorageEnabled(); + return repository.listByType(accountType, limit.orElse(100), startingAccountName.orElse(null)); + } + + @PostMapping + @PreAuthorize("isAuthenticated()") + @Alpha + public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition definition) { + validateAccountStorageEnabled(); + repository.create(definition); + return definition; + } + + @PutMapping + @PreAuthorize("hasPermission(#definition.name, 'ACCOUNT', 'WRITE')") + @Alpha + public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition definition) { + validateAccountStorageEnabled(); + repository.update(definition); + return definition; + } + + @DeleteMapping("/{accountName}") + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") + @Alpha + public void deleteAccount(@PathVariable String accountName) { + validateAccountStorageEnabled(); + repository.delete(accountName); + } + + @GetMapping("/{accountName}/history") + // as with listing accounts, details of the history of an account are restricted to users + // with ACCOUNT/WRITE permissions; ACCOUNT/READ permissions are related to viewing resources + // that use that account such as clusters, server groups, load balancers, and server groups + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") + @Alpha + public List getAccountHistory( + @PathVariable String accountName) { + validateAccountStorageEnabled(); + return repository.revisionHistory(accountName); + } + + private void validateAccountStorageEnabled() { + if (repository == null) { + throw new ConfigurationException( + "Cannot use AccountDefinitionRepository endpoints without enabling an AccountDefinitionRepository bean"); + } + } +} diff --git a/clouddriver-web/src/test/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsControllerSpec.groovy b/clouddriver-web/src/test/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsControllerSpec.groovy index adc4c659072..d892f73efa8 100644 --- a/clouddriver-web/src/test/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsControllerSpec.groovy +++ b/clouddriver-web/src/test/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsControllerSpec.groovy @@ -44,7 +44,7 @@ class CredentialsControllerSpec extends Specification { def credsRepo = new MapBackedAccountCredentialsRepository() def credsProvider = new DefaultAccountCredentialsProvider(credsRepo) credsRepo.save("test", new TestNamedAccountCredentials()) - def mvc = MockMvcBuilders.standaloneSetup(new CredentialsController(accountCredentialsProvider: credsProvider, objectMapper: objectMapper, credentialsConfiguration: new CredentialsConfiguration())).build() + def mvc = MockMvcBuilders.standaloneSetup(new CredentialsController(Optional.empty(), new CredentialsConfiguration(), objectMapper, credsProvider)).build() when: def result = mvc.perform(MockMvcRequestBuilders.get("/credentials").accept(MediaType.APPLICATION_JSON)).andReturn()