Skip to content

Commit

Permalink
Merge branch 'main' into task/env-file-handling
Browse files Browse the repository at this point in the history
  • Loading branch information
jorg3lopez authored Oct 26, 2023
2 parents b4b1a8e + 6195a7d commit 969727f
Show file tree
Hide file tree
Showing 21 changed files with 508 additions and 150 deletions.
17 changes: 17 additions & 0 deletions .github/ISSUE_TEMPLATE/spike.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
name: Spike
about: Research related task, when all that is needed is information gathering.
title: ''
labels: spike
assignees: ''

---

## Spike Goal
Goal for the Spike

## Timebox
Time allocated (2 FTE days)

## Notes
- *Optional: Any reference material or thoughts we may need for later reference*
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ repos:
- id: check-symlinks
- id: check-yaml
- id: detect-private-key
exclude: (mock_credentials|.+\/JjwtEngine.java|.+\/resources\/(organization-report-stream-public-key.pem|trusted-intermediary-private-key-local.pem))
exclude: (mock_credentials|.+\/JjwtEngine.java|.+\/resources\/(organization-report-stream-public-key-local.pem|trusted-intermediary-private-key-local.pem))
- id: end-of-file-fixer
- id: no-commit-to-branch
- id: trailing-whitespace
Expand Down
33 changes: 33 additions & 0 deletions adr/014-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 14. Keys

Date: 2023-10-23

## Decision

### Internal Keys

These keys are used for the intermediary's internal use. These keys will follow the following naming paradigm...

`trusted-intermediary-<key type>-key-<environment>`.

For example, `trusted-intermediary-private-key-staging` or `trusted-intermediary-public-key-prod`.

### Organization Keys

These are keys for external orgs to authenticate with us. Currently, report stream is the only organization we have. The pattern for the name of these keys is

`organization-<org name>-<key type>-key-<environment>`.

For example, `organization-report-stream-public-key-staging`

## Status

Accepted.

## Context

This naming convention applies to all locations where our keys are stored. Previously, we didn't have a consistent naming convention across all our locations which caused confusion on which key was to be used in which context and environment.

### Related Issues

- #584
57 changes: 57 additions & 0 deletions adr/015-project-structure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 15. Project Structure

Date: 2023-10-23

## Decision

The overall project will consist of 4+ subprojects.

- `app` - The entry point, where our HTTP or calling library is used, and plugins are initialized.
- `shared` - A shared project that is imported to every plugin and contains all the helper classes that may be used by
a plugin.
- `etor` - The ETOR plugin. In the future there may be more plugins that will be new sub-projects in of themselves (or
completely different projects and repositories).
- `e2e` - Our end-to-end tests.

## Status

Accepted.

## Context

### `app` Subproject

The `app` subproject contains the entry point into the code. This may be the `main` method or a special method if we're
plugging into a cloud service's function compute service. This means that it is directly connected to the HTTP library
or whatever library we use that handles the incoming requests. For example, Javalin or the Azure SDK.

Second, `app` bootstraps the plugins that are available and hooks them into the aforementioned HTTP/request library.

Lastly, `app` converts any live request into the form that the plugins accept.

### `shared` Subproject

The `shared` subproject specifies the interface for a plugin. Any plugin written needs to import this subproject, so it
can adhere to the plugin interface.

Second, `shared` contains all the shared helper classes that may be used by a plugin. Perhaps most importantly is
the `ApplicationContext`. Other examples include the logger, JSON handling, and an HTTP client. `shared` does _not_
contain anything specific to a plugin.

### `etor` Subproject

`etor` is a plugin subproject. As mentioned previously, `etor` depends on the `shared` subproject, so it can adhere to
the plugin interface. This subproject contains everything specific to the ETOR usecase such as parsing orders,
converting orders, sending orders, and handling results.

### `e2e` Subproject

The `e2e` subproject is all about end-to-end testing. It tests the external interface of the application instead of
testing individual classes and methods.

This subproject must not depend on any of the other subprojects. It is kept isolated on purpose so that the tests can't
be "poisoned" by the implementation.

### Related Issues

_None_.
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ public class ReportStreamOrderSender implements OrderSender {
.map(urlPrefix -> urlPrefix.replace("https://", "").replace("http://", ""))
.orElse("");

private static final String OUR_PRIVATE_KEY_ID =
"trusted-intermediary-private-key-" + ApplicationContext.getEnvironment();

private static final String CLIENT_NAME = "flexion.etor-service-sender";
private static final Map<String, String> RS_AUTH_API_HEADERS =
Map.of("Content-Type", "application/x-www-form-urlencoded");

private String rsTokenCache;

Expand Down Expand Up @@ -131,42 +136,53 @@ protected String sendRequestBody(@Nonnull String json, @Nonnull String bearerTok
protected String requestToken() throws UnableToSendOrderException {
logger.logInfo("Requesting token from ReportStream");

String senderToken = null;
String token = "";
String body;
Map<String, String> headers = Map.of("Content-Type", "application/x-www-form-urlencoded");
String ourPrivateKey;
String token;

try {
senderToken =
ourPrivateKey = retrievePrivateKey();
String senderToken =
jwt.generateToken(
CLIENT_NAME,
CLIENT_NAME,
CLIENT_NAME,
RS_DOMAIN_NAME,
300,
retrievePrivateKey());
body = composeRequestBody(senderToken);
String rsResponse = client.post(RS_AUTH_API_URL, headers, body);
ourPrivateKey);
String body = composeRequestBody(senderToken);
String rsResponse = client.post(RS_AUTH_API_URL, RS_AUTH_API_HEADERS, body);
token = extractToken(rsResponse);
} catch (Exception e) {
throw new UnableToSendOrderException(
"Error getting the API token from ReportStream", e);
}

// only cache our private key if we successfully authenticate to RS
cacheOurPrivateKeyIfNotCachedAlready(ourPrivateKey);

return token;
}

protected String retrievePrivateKey() throws SecretRetrievalException {
var senderPrivateKey =
"trusted-intermediary-private-key-" + ApplicationContext.getEnvironment();
String key = this.keyCache.get(senderPrivateKey);
String key = keyCache.get(OUR_PRIVATE_KEY_ID);
if (key != null) {
return key;
}

key = secrets.getKey(senderPrivateKey);
this.keyCache.put(senderPrivateKey, key);
key = secrets.getKey(OUR_PRIVATE_KEY_ID);

return key;
}

void cacheOurPrivateKeyIfNotCachedAlready(String privateKey) {
String key = keyCache.get(OUR_PRIVATE_KEY_ID);
if (key != null) {
return;
}

keyCache.put(OUR_PRIVATE_KEY_ID, privateKey);
}

protected String extractToken(String responseBody) throws FormatterProcessingException {
var value =
formatter.convertJsonToObject(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,100 @@ class ReportStreamOrderSenderTest extends Specification {
TestApplicationContext.register(Secrets, mockSecrets)
TestApplicationContext.register(Cache, mockCache)
TestApplicationContext.injectRegisteredImplementations()

when:
mockSecrets.getKey(_ as String) >> "Fake Azure Key"
def actual = ReportStreamOrderSender.getInstance().requestToken()

then:
1 * mockAuthEngine.generateToken(_ as String, _ as String, _ as String, _ as String, 300, _ as String) >> "sender fake token"
1 * mockClient.post(_ as String, _ as Map<String, String>, _ as String) >> """{"access_token":"${expected}", "token_type":"bearer"}"""
actual == expected
}

def "requestToken saves our private key only after successful call to RS"() {
given:
def mockSecrets = Mock(Secrets)
def mockCache = Mock(Cache)
def mockFormatter = Mock(Formatter)

def fakeOurPrivateKey = "DogCow" // pragma: allowlist secret
mockSecrets.getKey(_ as String) >> fakeOurPrivateKey
mockFormatter.convertJsonToObject(_ , _) >> [access_token: "Moof!"]

TestApplicationContext.register(AuthEngine, Mock(AuthEngine))
TestApplicationContext.register(HttpClient, Mock(HttpClient))
TestApplicationContext.register(Formatter, mockFormatter)
TestApplicationContext.register(Secrets, mockSecrets)
TestApplicationContext.register(Cache, mockCache)

TestApplicationContext.injectRegisteredImplementations()

when:
ReportStreamOrderSender.getInstance().requestToken()

then:
1 * mockCache.put(_ as String, fakeOurPrivateKey)
}

def "requestToken doesn't cache our private key if RS auth call fails"() {
given:
def mockClient = Mock(HttpClient)
def mockCache = Mock(Cache)
def mockFormatter = Mock(Formatter)

mockClient.post(_, _, _) >> { throw new HttpClientException("Fake failure", new NullPointerException()) }

mockFormatter.convertJsonToObject(_ , _) >> [access_token: "Moof!"]

TestApplicationContext.register(AuthEngine, Mock(AuthEngine))
TestApplicationContext.register(HttpClient, mockClient)
TestApplicationContext.register(Formatter, mockFormatter)
TestApplicationContext.register(Secrets, Mock(Secrets))
TestApplicationContext.register(Cache, mockCache)

TestApplicationContext.injectRegisteredImplementations()

when:
ReportStreamOrderSender.getInstance().requestToken()

then:
thrown(UnableToSendOrderException)
0 * mockCache.put(_ , _)
}

def "cacheOurPrivateKeyIfNotCachedAlready doesn't cache when the key is already is cached"() {
given:
def mockCache = Mock(Cache)
mockCache.get(_ as String) >> "DogCow private key"

TestApplicationContext.register(Cache, mockCache)

TestApplicationContext.injectRegisteredImplementations()

when:
ReportStreamOrderSender.getInstance().cacheOurPrivateKeyIfNotCachedAlready("Moof!")

then:
0 * mockCache.put(_, _)
}

def "cacheOurPrivateKeyIfNotCachedAlready caches when the key isn't cached"() {
given:
def mockCache = Mock(Cache)
mockCache.get(_ as String) >> null

TestApplicationContext.register(Cache, mockCache)

TestApplicationContext.injectRegisteredImplementations()

when:
ReportStreamOrderSender.getInstance().cacheOurPrivateKeyIfNotCachedAlready("Moof!")

then:
1 * mockCache.put(_, _)
}

def "extractToken works"() {
given:
TestApplicationContext.register(Formatter, Jackson.getInstance())
Expand Down Expand Up @@ -193,19 +278,16 @@ class ReportStreamOrderSenderTest extends Specification {
given:
def mockSecret = Mock(Secrets)
def expected = "New Fake Azure Key"
def keyCache = KeyCache.getInstance()
def key = "trusted-intermediary-private-key-local"
mockSecret.getKey(_ as String) >> expected
TestApplicationContext.register(Secrets, mockSecret)
TestApplicationContext.register(Cache, keyCache)
TestApplicationContext.register(Cache, KeyCache.getInstance())
TestApplicationContext.injectRegisteredImplementations()
def rsOrderSender = ReportStreamOrderSender.getInstance()
when:
def actual = rsOrderSender.retrievePrivateKey()

then:
actual == expected
keyCache.get(key) == expected
}

def "retrievePrivateKey works when cache is not empty" () {
Expand Down
Loading

0 comments on commit 969727f

Please sign in to comment.