Skip to content

Commit

Permalink
[Accounts] Add support for composite accounts
Browse files Browse the repository at this point in the history
  • Loading branch information
avano committed Oct 21, 2022
1 parent 4795cbd commit d9debe8
Show file tree
Hide file tree
Showing 48 changed files with 480 additions and 230 deletions.
76 changes: 75 additions & 1 deletion system-x/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,78 @@ implement `Account#id(String id)` method in your account, where the id matches t
or `jira`)
and your account fields must have the same name as the fields in the yaml file.

To obtain a new instance of the account with populated attributes, use `Accounts.get(YourAccount.class)`
To obtain a new instance of the account with populated attributes, use `AccountFactory.create(YourAccount.class)`

#### Composite account

You can also create an account instance by parsing multiple entries from credentials file that allows for having credential extension mechanism
of some sort. To enable this functionality, when creating a new instance, all parent classes are automatically checked for the presence of `WithId`
interface - in that case the parent ids are also used to populate the account instance.

Consider following classes and credentials file:

```java
public class ParentAccount implements Account, WithId {
private String parentKey;

@Override
public String credentialsId() {
return "parent";
}
}

public class ChildAccount extends ParentAccount {
private String childKey;

@Override
public String credentialsId() {
return "child";
}
}
```

```yaml
services:
parent:
credentials:
parentKey: parentValue
child:
credentials:
childKey: childValue
```
When instantiating the `ChildAccount` with `AccountFactory.create(ChildAccount.class)` the account is created by creating the instance from the
`parent` credentials and then the credentials with id `child` are merged into the existing object resulting in the account having both values
populated.

In case of composite accounts at least one of the given credentials id must always exist.

Consider previous set of account classes and a following credentials file:

```yaml
services:
child:
credentials:
parentKey: parentValue
childKey: childValue
```

In this case there is no credentials entry with `parent` id, but the `child` entry contains all necessary values for the account.

### Parsing accounts from HashiCorp Vault

Instead of using credentials yaml file you can also use a [HashiCorp Vault](https://www.vaultproject.io/) instance to holds your credentials.
The format of the credentials need to follow the same structure as the yaml file - you need to to store a secret named `credentials` under the id used
in the account configuration

In this case you need to specify the following properties:

```bash
test.credentials.use.vault=true
test.credentials.vault.address=https://<vault.address>
# Use either token or role configuration
test.credentials.vault.token=<token>
test.credentials.vault.role=<role>
# Pattern passed to String.format() where %s is the "credentialsId" value for the account
test.credentials.vault.path.pattern=/path/to/services/%s/credentials
```
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@

import static org.junit.jupiter.api.Assertions.fail;

import software.tnb.common.account.loader.CredentialsLoader;
import software.tnb.common.account.loader.VaultCredentialsLoader;
import software.tnb.common.account.loader.YamlCredentialsLoader;
import software.tnb.common.config.TestConfiguration;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Function;

public final class Accounts {
private static final Logger LOG = LoggerFactory.getLogger(Accounts.class);
public final class AccountFactory {
private static final Logger LOG = LoggerFactory.getLogger(AccountFactory.class);

private static CredentialsLoader loader;

private Accounts() {
private AccountFactory() {
}

/**
Expand All @@ -24,29 +30,18 @@ private Accounts() {
* @param <T> return type
* @return new instance of given class
*/
public static <T extends Account> T get(Class<T> accountClass) {
Function<WithId, String> getId = WithId::getId;
T instance;
try {
instance = accountClass.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Unable to create instance of " + accountClass.getName() + " class: ", e);
}
public static <T extends Account> T create(Class<T> accountClass) {
T instance = createInstance(accountClass);
if (instance instanceof WithId) {
LOG.debug("Loading {} account", accountClass.getSimpleName());
String credentialsId = getId.apply((WithId) instance);
if (loader == null) {
try {
createLoader();
} catch (Exception e) {
fail("Could not load credentials", e);
}
}
T account = loader.get(credentialsId, accountClass);
if (account == null) {
throw new IllegalArgumentException("Credentials with id " + credentialsId + " not found in credentials.yaml file");
}
return account;
return loader.get(getCredentialsIds(instance), accountClass);
} else {
LOG.debug("Initialization of {}. No credentials loading needed.", accountClass.getSimpleName());
return instance;
Expand Down Expand Up @@ -76,4 +71,28 @@ private static void createLoader() throws Exception {
loader = new YamlCredentialsLoader(TestConfiguration.credentialsFile());
}
}

private static <T extends Account> List<String> getCredentialsIds(T instance) {
Function<WithId, String> withId = WithId::getId;

List<String> ids = new ArrayList<>();

Class<?> current = instance.getClass();
while (current != null) {
if (WithId.class.isAssignableFrom(current)) {
ids.add(withId.apply((WithId) createInstance(current)));
}
current = current.getSuperclass();
}
Collections.reverse(ids);
return ids;
}

private static <T> T createInstance(Class<T> clazz) {
try {
return clazz.getDeclaredConstructor().newInstance();
} catch (Exception e) {
throw new RuntimeException("Unable to create instance of " + clazz.getName() + " class: ", e);
}
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package software.tnb.common.account.loader;

import software.tnb.common.account.Account;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.util.List;

public abstract class CredentialsLoader {
private static final Logger LOG = LoggerFactory.getLogger(CredentialsLoader.class);

protected final ObjectMapper mapper;

public abstract Object loadCredentials(String credentialsId);

public abstract String toJson(Object credentials);

public CredentialsLoader() {
mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}

public <T extends Account> T get(List<String> credentialsIds, Class<T> accountClass) {
T account = null;
try {
for (String id : credentialsIds) {
Object credentials = loadCredentials(id);
if (credentials != null) {
if (account == null) {
LOG.trace("Creating {} instance from credentials {}", accountClass.getSimpleName(), id);
account = mapper.readValue(toJson(credentials), accountClass);
} else {
LOG.trace("Updating {} instance with credentials {}", accountClass.getSimpleName(), id);
mapper.readerForUpdating(account).readValue(toJson(credentials));
}
} else {
LOG.trace("Account with id {} not found in credentials", id);
}
}
} catch (Exception e) {
throw new RuntimeException("Couldnt get credentials from ids: " + String.join(",", credentialsIds), e);
}

if (account == null) {
throw new IllegalArgumentException(String.format("Unable to create %s instance from credentials [%s]"
+ " - no credentials with given ids found", accountClass.getSimpleName(), String.join(",", credentialsIds)));
}

return account;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package software.tnb.common.account;
package software.tnb.common.account.loader;

import org.junit.jupiter.api.function.ThrowingSupplier;

Expand All @@ -7,14 +7,10 @@
import com.bettercloud.vault.VaultException;
import com.bettercloud.vault.json.JsonObject;
import com.bettercloud.vault.response.AuthResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

public class VaultCredentialsLoader implements CredentialsLoader {
import com.bettercloud.vault.response.LogicalResponse;

public class VaultCredentialsLoader extends CredentialsLoader {
private final Vault vault;
private final ObjectMapper mapper;
private final String pathPattern;
private final VaultConfig config;
private ThrowingSupplier<AuthResponse> authSupplier;
Expand All @@ -27,21 +23,16 @@ private VaultCredentialsLoader(String address, String pathPattern) throws VaultE
vault = new Vault(config);

this.pathPattern = pathPattern;

mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}

public VaultCredentialsLoader(String address, String pathPattern, String ghToken) throws VaultException {
this(address, pathPattern);
authSupplier = () -> vault.auth().loginByGithub(ghToken);
refreshAuthToken();
}

public VaultCredentialsLoader(String address, String pathPattern, String roleId, String secretId) throws VaultException {
this(address, pathPattern);
authSupplier = () -> vault.auth().loginByAppRole(roleId, secretId);
refreshAuthToken();
}

private void refreshAuthToken() {
Expand All @@ -53,22 +44,29 @@ private void refreshAuthToken() {
}

@Override
public <T extends Account> T get(String credentialsId, Class<T> accountClass) {
try {
JsonObject account = get(String.format(pathPattern, credentialsId));
if (account == null) {
refreshAuthToken();
account = get(String.format(pathPattern, credentialsId));
}
return mapper.readValue(account.toString(), accountClass);
} catch (VaultException | JsonProcessingException e) {
throw new RuntimeException("Couldnt get credentials from vault: " + credentialsId, e);
}
public Object loadCredentials(String credentialsId) {
refreshAuthToken();
return get(String.format(pathPattern, credentialsId));
}

public JsonObject get(String path) throws VaultException {
return vault.logical()
.read(path)
.getDataObject();
@Override
public String toJson(Object credentials) {
return credentials.toString();
}

private JsonObject get(String path) {
final LogicalResponse response;
try {
response = vault.logical().read(path);
} catch (VaultException e) {
throw new RuntimeException("Unable to read credentials from vault", e);
}
if (response.getRestResponse().getStatus() == 200) {
return response.getDataObject();
} else if (response.getRestResponse().getStatus() == 404) {
return null;
} else {
throw new RuntimeException("Unable to get credentials from vault, response code: " + response.getRestResponse().getStatus());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,36 +1,38 @@
package software.tnb.common.account;
package software.tnb.common.account.loader;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.yaml.snakeyaml.Yaml;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.core.JsonProcessingException;

import java.io.FileInputStream;
import java.nio.file.Paths;
import java.util.Map;

public class YamlCredentialsLoader implements CredentialsLoader {
public class YamlCredentialsLoader extends CredentialsLoader {
private static final Logger LOG = LoggerFactory.getLogger(YamlCredentialsLoader.class);

private final Map<String, Map<String, Object>> credentials;
private final ObjectMapper mapper;

public YamlCredentialsLoader(String credentialsFile) throws Exception {
try (FileInputStream fs = new FileInputStream(Paths.get(credentialsFile).toAbsolutePath().toString())) {
LOG.info("Loading credentials file from {}", Paths.get(credentialsFile).toAbsolutePath());
credentials = (Map) ((Map) new Yaml().load(fs)).get("services");
mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
}
}

@Override
public <T extends Account> T get(String credentialsId, Class<T> accountClass) {
if (!credentials.containsKey(credentialsId)) {
return null;
public Object loadCredentials(String credentialsId) {
return credentials.getOrDefault(credentialsId, Map.of()).getOrDefault("credentials", null);
}

@Override
public String toJson(Object credentials) {
try {
return mapper.writeValueAsString(credentials);
} catch (JsonProcessingException e) {
throw new RuntimeException("Unable to convert credentials to json", e);
}
return accountClass.cast(mapper.convertValue(credentials.get(credentialsId).get("credentials"), accountClass));
}
}
Loading

0 comments on commit d9debe8

Please sign in to comment.