From 9bd86bd23c623c82fd1f7a818946f905bd1d9317 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Thu, 28 Oct 2021 16:51:23 -0500 Subject: [PATCH 1/5] feat(core): Add experimental account storage API This adds an experimental API for storing and loading account credentials definitions from an external durable store such as a SQL database. Secrets can be referenced through the existing Kork SecretEngine API which will be fetched on demand. Initial support is for Kubernetes accounts given the lack of existing Kubernetes cluster federation standards compared to other cloud providers, though this API is made generic to allow for other cloud provider APIs to participate in this system. --- .../security/AccountDefinitionRepository.java | 140 +++++++++++++ clouddriver-core/clouddriver-core.gradle | 2 + .../AccountDefinitionConfiguration.java | 148 ++++++++++++++ .../jackson/AccountDefinitionModule.java | 54 +++++ .../mixins/CredentialsDefinitionMixin.java | 30 +++ .../security/AccountDefinitionMapper.java | 60 ++++++ .../security/AccountDefinitionSource.java | 88 ++++++++ .../security/AccountDefinitionMapperTest.java | 66 ++++++ .../spinnaker/test/security/TestAccount.java | 68 +++++++ .../config/KubernetesAccountProperties.java | 3 + ...sAccountDefinitionSourceConfiguration.java | 46 +++++ clouddriver-sql/clouddriver-sql.gradle | 1 + .../SqlAccountDefinitionRepository.kt | 188 ++++++++++++++++++ .../spinnaker/config/ConnectionPools.kt | 3 +- .../spinnaker/config/SqlConfiguration.kt | 14 +- .../main/resources/db/changelog-master.yml | 3 + .../db/changelog/20210927-accounts.yml | 132 ++++++++++++ .../controllers/AccountController.java | 80 ++++++++ 18 files changed, 1124 insertions(+), 2 deletions(-) create mode 100644 clouddriver-api/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionRepository.java create mode 100644 clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/config/AccountDefinitionConfiguration.java create mode 100644 clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/AccountDefinitionModule.java create mode 100644 clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/jackson/mixins/CredentialsDefinitionMixin.java create mode 100644 clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapper.java create mode 100644 clouddriver-core/src/main/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionSource.java create mode 100644 clouddriver-core/src/test/java/com/netflix/spinnaker/clouddriver/security/AccountDefinitionMapperTest.java create mode 100644 clouddriver-core/src/test/java/io/spinnaker/test/security/TestAccount.java create mode 100644 clouddriver-kubernetes/src/main/java/com/netflix/spinnaker/config/KubernetesAccountDefinitionSourceConfiguration.java create mode 100644 clouddriver-sql/src/main/kotlin/com/netflix/spinnaker/clouddriver/sql/security/SqlAccountDefinitionRepository.kt create mode 100644 clouddriver-sql/src/main/resources/db/changelog/20210927-accounts.yml create mode 100644 clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java 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/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java new file mode 100644 index 00000000000..716e20cf4e3 --- /dev/null +++ b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java @@ -0,0 +1,80 @@ +/* + * 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.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository; +import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; +import java.util.List; +import java.util.Optional; +import java.util.OptionalInt; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController("/accounts") +@ConditionalOnProperty("account.storage.enabled") +public class AccountController { + private final AccountDefinitionRepository repository; + + public AccountController(AccountDefinitionRepository repository) { + this.repository = repository; + } + + @GetMapping + @PostFilter("hasPermission(filterObject.name, 'ACCOUNT', 'READ')") + public List listAccountsByType( + @RequestParam String accountType, + @RequestParam OptionalInt limit, + @RequestParam Optional startingAccountName) { + return repository.listByType(accountType, limit.orElse(100), startingAccountName.orElse(null)); + } + + @PostMapping + @PreAuthorize("isAuthenticated()") + public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition definition) { + repository.create(definition); + return definition; + } + + @PutMapping + @PreAuthorize("hasPermission(#definition.name, 'ACCOUNT', 'WRITE')") + public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition definition) { + repository.update(definition); + return definition; + } + + @DeleteMapping("/{accountName}") + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") + public void deleteAccount(@PathVariable String accountName) { + repository.delete(accountName); + } + + @GetMapping("/{accountName}/history") + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'READ')") + public List getAccountHistory( + @PathVariable String accountName) { + return repository.revisionHistory(accountName); + } +} From e04f34e176115f3605542724dc3140961cdf2a21 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Fri, 7 Jan 2022 15:35:40 -0600 Subject: [PATCH 2/5] Combine AccountController with CredentialsController --- .../controllers/CredentialsController.groovy | 84 --------- .../controllers/AccountController.java | 80 --------- .../controllers/CredentialsController.java | 161 ++++++++++++++++++ .../CredentialsControllerSpec.groovy | 2 +- 4 files changed, 162 insertions(+), 165 deletions(-) delete mode 100644 clouddriver-web/src/main/groovy/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.groovy delete mode 100644 clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java create mode 100644 clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.java 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/AccountController.java b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java deleted file mode 100644 index 716e20cf4e3..00000000000 --- a/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/AccountController.java +++ /dev/null @@ -1,80 +0,0 @@ -/* - * 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.netflix.spinnaker.clouddriver.security.AccountDefinitionRepository; -import com.netflix.spinnaker.credentials.definition.CredentialsDefinition; -import java.util.List; -import java.util.Optional; -import java.util.OptionalInt; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController("/accounts") -@ConditionalOnProperty("account.storage.enabled") -public class AccountController { - private final AccountDefinitionRepository repository; - - public AccountController(AccountDefinitionRepository repository) { - this.repository = repository; - } - - @GetMapping - @PostFilter("hasPermission(filterObject.name, 'ACCOUNT', 'READ')") - public List listAccountsByType( - @RequestParam String accountType, - @RequestParam OptionalInt limit, - @RequestParam Optional startingAccountName) { - return repository.listByType(accountType, limit.orElse(100), startingAccountName.orElse(null)); - } - - @PostMapping - @PreAuthorize("isAuthenticated()") - public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition definition) { - repository.create(definition); - return definition; - } - - @PutMapping - @PreAuthorize("hasPermission(#definition.name, 'ACCOUNT', 'WRITE')") - public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition definition) { - repository.update(definition); - return definition; - } - - @DeleteMapping("/{accountName}") - @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") - public void deleteAccount(@PathVariable String accountName) { - repository.delete(accountName); - } - - @GetMapping("/{accountName}/history") - @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'READ')") - public List getAccountHistory( - @PathVariable String accountName) { - return repository.revisionHistory(accountName); - } -} 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..fa7a8842aba --- /dev/null +++ b/clouddriver-web/src/main/java/com/netflix/spinnaker/clouddriver/controllers/CredentialsController.java @@ -0,0 +1,161 @@ +/* + * 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.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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController("/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}") + @PostFilter("hasPermission(filterObject.name, 'ACCOUNT', 'READ')") + 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()") + public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition definition) { + validateAccountStorageEnabled(); + repository.create(definition); + return definition; + } + + @PutMapping + @PreAuthorize("hasPermission(#definition.name, 'ACCOUNT', 'WRITE')") + public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition definition) { + validateAccountStorageEnabled(); + repository.update(definition); + return definition; + } + + @DeleteMapping("/{accountName}") + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") + public void deleteAccount(@PathVariable String accountName) { + validateAccountStorageEnabled(); + repository.delete(accountName); + } + + @GetMapping("/{accountName}/history") + @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'READ')") + 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() From 15bb7ae4cff3d196c0b25e401466182d8fc8c1ca Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Fri, 7 Jan 2022 16:27:13 -0600 Subject: [PATCH 3/5] Fix account permissions and add comments --- .../clouddriver/controllers/CredentialsController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) 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 index fa7a8842aba..64153257e3c 100644 --- 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 @@ -112,7 +112,10 @@ private Map renderAccountCredentials( } @GetMapping("/type/{accountType}") - @PostFilter("hasPermission(filterObject.name, 'ACCOUNT', 'READ')") + // 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')") public List listAccountsByType( @PathVariable String accountType, @RequestParam OptionalInt limit, @@ -145,7 +148,10 @@ public void deleteAccount(@PathVariable String accountName) { } @GetMapping("/{accountName}/history") - @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'READ')") + // 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')") public List getAccountHistory( @PathVariable String accountName) { validateAccountStorageEnabled(); From 7d1c7eec0823044d9e5d2ee8b820c24ba0a21ae5 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Fri, 7 Jan 2022 16:31:37 -0600 Subject: [PATCH 4/5] Add alpha annotations --- .../clouddriver/controllers/CredentialsController.java | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 64153257e3c..e1ae2b089c2 100644 --- 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 @@ -23,6 +23,7 @@ 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; @@ -116,6 +117,7 @@ private Map renderAccountCredentials( // 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, @@ -126,6 +128,7 @@ public List listAccountsByType( @PostMapping @PreAuthorize("isAuthenticated()") + @Alpha public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition definition) { validateAccountStorageEnabled(); repository.create(definition); @@ -134,6 +137,7 @@ public CredentialsDefinition createAccount(@RequestBody CredentialsDefinition de @PutMapping @PreAuthorize("hasPermission(#definition.name, 'ACCOUNT', 'WRITE')") + @Alpha public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition definition) { validateAccountStorageEnabled(); repository.update(definition); @@ -142,6 +146,7 @@ public CredentialsDefinition updateAccount(@RequestBody CredentialsDefinition de @DeleteMapping("/{accountName}") @PreAuthorize("hasPermission(#accountName, 'ACCOUNT', 'WRITE')") + @Alpha public void deleteAccount(@PathVariable String accountName) { validateAccountStorageEnabled(); repository.delete(accountName); @@ -152,6 +157,7 @@ public void deleteAccount(@PathVariable String accountName) { // 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(); From d0455acb0d38930d09dbabf618bbfa927eb8a606 Mon Sep 17 00:00:00 2001 From: Matt Sicker Date: Fri, 7 Jan 2022 17:08:09 -0600 Subject: [PATCH 5/5] Use correct annotations for rest APIs --- .../clouddriver/controllers/CredentialsController.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 index e1ae2b089c2..c82b20c15a2 100644 --- 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 @@ -41,10 +41,12 @@ 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("/credentials") +@RestController +@RequestMapping("/credentials") public class CredentialsController { private final AccountDefinitionRepository repository; private final CredentialsConfiguration credentialsConfiguration;