Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[ID-1117] Add fence_account_key and distributed_lock tables to db #174

Merged
merged 14 commits into from
Mar 6, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package bio.terra.externalcreds.dataAccess;

import bio.terra.externalcreds.models.DistributedLock;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import java.util.Optional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.support.DataAccessUtils;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
@Slf4j
public class DistributedLockDAO {

private static final RowMapper<DistributedLock> DISTRIBUTED_LOCK_ROW_MAPPER =
((rs, rowNum) ->
new DistributedLock.Builder()
.lockName(rs.getString("lock_name"))
.userId(rs.getString("user_id"))
.expiresAt(rs.getTimestamp("expires_at"))
.build());

final NamedParameterJdbcTemplate jdbcTemplate;

public DistributedLockDAO(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

/**
* @param lockName The name of the lock, e.g {provider}-createKey
* @param userId The Sam user id
* @return Optional<DistributedLock> An optional containing the distributed lock with this
* lockName and userId (or empty)
*/
@WithSpan
public Optional<DistributedLock> getDistributedLock(String lockName, String userId) {
var namedParameters =
new MapSqlParameterSource().addValue("lockName", lockName).addValue("userId", userId);
var query = "SELECT * FROM distributed_lock WHERE lock_name = :lockName AND user_id = :userId";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is nitpicky but I think it is best to name each column in a DAO query.

return Optional.ofNullable(
DataAccessUtils.singleResult(
jdbcTemplate.query(query, namedParameters, DISTRIBUTED_LOCK_ROW_MAPPER)));
}

/**
* @param distributedLock The DistributedLock to insert
* @return distributedLock The DistributedLock that was inserted
*/
@WithSpan
public DistributedLock upsertDistributedLock(DistributedLock distributedLock) {
var query =
"INSERT INTO distributed_lock (lock_name, user_id, expires_at)"
+ " VALUES (:lockName, :userId, :expiresAt)";

var namedParameters =
new MapSqlParameterSource()
.addValue("lockName", distributedLock.getLockName())
.addValue("userId", distributedLock.getUserId())
.addValue("expiresAt", distributedLock.getExpiresAt());

jdbcTemplate.update(query, namedParameters);
return distributedLock;
}

/**
* @param lockName The name of the lock, e.g {provider}-createKey
* @param userId The Sam user id
* @return boolean whether a distributed lock was found and deleted
*/
@WithSpan
public boolean deleteDistributedLock(String lockName, String userId) {
var query = "DELETE FROM distributed_lock WHERE lock_name = :lockName AND user_id = :userId";
var namedParameters =
new MapSqlParameterSource().addValue("lockName", lockName).addValue("userId", userId);

return jdbcTemplate.update(query, namedParameters) > 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package bio.terra.externalcreds.dataAccess;

import bio.terra.externalcreds.models.FenceAccountKey;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import java.nio.charset.StandardCharsets;
import java.util.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.support.DataAccessUtils;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Repository;

@Repository
@Slf4j
public class FenceAccountKeyDAO {

private static final RowMapper<FenceAccountKey> FENCE_ACCOUNT_KEY_ROW_MAPPER =
((rs, rowNum) ->
new FenceAccountKey.Builder()
.id(rs.getInt("id"))
.linkedAccountId(rs.getInt("linked_account_id"))
.keyJson(base64Decode(rs.getString("key_json")))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, I looked at the Bond code, and its storing the keys as raw json instead of base64. I thought it about it a little bit more, and storing the keys as JSON or JSONB might actually be better, since we could then query into the keys to find info. That may be useful for debugging or analysis.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies for the whiplash...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No worries! That's a good point about search-ability

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

im confused i thought it was storing the keys as b64?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bond stores them as plain text.

.expiresAt(rs.getTimestamp("expires_at"))
.build());

final NamedParameterJdbcTemplate jdbcTemplate;

public FenceAccountKeyDAO(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

@WithSpan
public Optional<FenceAccountKey> getFenceAccountKey(String userId, String providerName) {
var namedParameters =
new MapSqlParameterSource("userId", userId).addValue("providerName", providerName);
var query =
"SELECT p.* FROM fence_account_key p"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above I would name each column instead of using *.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also nitpicky but I would use something more descriptive than 'p' for fence_account_key's alias, maybe 'fence' or 'fak'. Just to make the query easier to read.

+ " INNER JOIN linked_account la ON la.id = p.linked_account_id"
+ " WHERE la.user_id = :userId"
+ " AND la.provider_name = :providerName";
return Optional.ofNullable(
DataAccessUtils.singleResult(
jdbcTemplate.query(query, namedParameters, FENCE_ACCOUNT_KEY_ROW_MAPPER)));
}

@WithSpan
public FenceAccountKey insertFenceAccountKey(FenceAccountKey fenceAccountKey) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we rename this upsertFenceAccountKey and do the same ON CONFLICT logic?

Copy link
Contributor Author

@samanehsan samanehsan Mar 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it just when there is a conflict on the id and linked_account_id?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I had to add a unique constraint on the linked_account_id in order to use is as the conflict criteria

var query =
"INSERT INTO fence_account_key (linked_account_id, key_json, expires_at)"
+ " VALUES (:linkedAccountId, :keyJson, :expiresAt)"
+ " RETURNING id";

var namedParameters =
new MapSqlParameterSource()
.addValue("linkedAccountId", fenceAccountKey.getLinkedAccountId())
.addValue("keyJson", base64Encode(fenceAccountKey.getKeyJson()))
.addValue("expiresAt", fenceAccountKey.getExpiresAt());

// generatedKeyHolder will hold the id returned by the query as specified by the RETURNING
// clause
var generatedKeyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(query, namedParameters, generatedKeyHolder);

return fenceAccountKey.withId(Objects.requireNonNull(generatedKeyHolder.getKey()).intValue());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love that you can get the id back from the insert query, there are limitations on this in scala libs that I have used.

}

/**
* @param linkedAccountId id of the linked account
* @return boolean whether a fence account key was deleted
*/
@WithSpan
public boolean deleteFenceAccountKey(int linkedAccountId) {
var namedParameters = new MapSqlParameterSource("linkedAccountId", linkedAccountId);
var query = "DELETE FROM fence_account_key WHERE linked_account_id = :linkedAccountId";
return jdbcTemplate.update(query, namedParameters) > 0;
}

private static String base64Encode(String keyJson) {
return Base64.getEncoder().encodeToString(keyJson.getBytes(StandardCharsets.UTF_8));
}

private static String base64Decode(String encodedKeyJson) {
return new String(Base64.getDecoder().decode(encodedKeyJson), StandardCharsets.UTF_8);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package bio.terra.externalcreds.models;

import java.sql.Timestamp;
import org.immutables.value.Value;

@Value.Immutable
public interface DistributedLock extends WithDistributedLock {
String getLockName();

String getUserId();

Timestamp getExpiresAt();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably use java.time.Instant here instead of the SQL Timestamp.


class Builder extends ImmutableDistributedLock.Builder {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package bio.terra.externalcreds.models;

import java.sql.Timestamp;
import java.util.Optional;
import org.immutables.value.Value;

@Value.Immutable
public interface FenceAccountKey extends WithFenceAccountKey {
Optional<Integer> getId();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI this is optional because when we pass a FenceAccountKey object to insertFenceAccountKey, it won't have an id at that point.


Integer getLinkedAccountId();

String getKeyJson();

Timestamp getExpiresAt();

class Builder extends ImmutableFenceAccountKey.Builder {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
databaseChangeLog:
- changeSet:
id: "add_fence_account_key_and_dist_lock_table"
author: sehsan
changes:
- createTable:
tableName: fence_account_key
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also need a foreign key to the user's linked_account record, so that we can retrieve a user's fence account key by user_id and provider (similar to ga4gh_passport). Does that make sense @tlangs?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does make sense!

columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: linked_account_id
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! Can we make this cascade on delete? That way, if someone deletes their linked account, the key will automatically get deleted too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I assumed that was the default behavior 😅 I'll double check!

type: int
constraints:
nullable: false
references: linked_account(id)
foreignKeyName: fk_linked_account_id
- column:
name: key_json
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: maybe this should be called key_json_base64?

type: text
constraints:
nullable: false
- column:
name: expires_at
type: timestamp
constraints:
nullable: false
- createTable:
tableName: distributed_lock
columns:
- column:
name: lock_name
type: text
constraints:
primaryKey: true
primaryKeyName: pk_dist_lock
- column:
name: user_id
type: text
constraints:
primaryKey: true
primaryKeyName: pk_dist_lock
- column:
name: expires_at
type: timestamp
constraints:
nullable: false
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ databaseChangeLog:
- include:
file: changesets/20220412_add_last_encrypt_timestamp_to_ssh_key_pair_table.yaml
relativeToChangelogFile: true
- include:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a merge conflict here. Make sure this changeset comes after the just-merged changeset.

file: changesets/20240228_add_fence_account_key_and_dist_lock_table.yaml
relativeToChangelogFile: true
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import bio.terra.externalcreds.config.ProviderProperties;
import bio.terra.externalcreds.generated.model.Provider;
import bio.terra.externalcreds.models.FenceAccountKey;
import bio.terra.externalcreds.models.GA4GHPassport;
import bio.terra.externalcreds.models.GA4GHVisa;
import bio.terra.externalcreds.models.LinkedAccount;
Expand Down Expand Up @@ -56,6 +57,14 @@ public static GA4GHVisa createRandomVisa() {
.build();
}

public static FenceAccountKey createRandomFenceAccountKey() {
return new FenceAccountKey.Builder()
.linkedAccountId(1)
.keyJson(UUID.randomUUID().toString())
.expiresAt(getRandomTimestamp())
.build();
}

public static ProviderProperties createRandomProvider() {
try {
return ProviderProperties.create()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package bio.terra.externalcreds.dataAccess;

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

import bio.terra.externalcreds.BaseTest;
import bio.terra.externalcreds.TestUtils;
import bio.terra.externalcreds.models.DistributedLock;
import java.util.Optional;
import java.util.UUID;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;

class DistributedLockDAOTest extends BaseTest {

@Autowired private DistributedLockDAO distributedLockDAO;
private final String testLockName = "provider-createKey";
private final DistributedLock testDistributedLock =
new DistributedLock.Builder()
.lockName(testLockName)
.userId(UUID.randomUUID().toString())
.expiresAt(TestUtils.getRandomTimestamp())
.build();

@Test
void testGetMissingDistributedLock() {
var shouldBeEmpty = distributedLockDAO.getDistributedLock(testLockName, "nonexistent_user_id");
assertEmpty(shouldBeEmpty);
}

@Nested
class CreateDistributedLock {

@Test
void testCreateAndGetDistributedLock() {
DistributedLock savedLock = distributedLockDAO.upsertDistributedLock(testDistributedLock);
assertEquals(testDistributedLock, savedLock);
var loadedDistributedLock =
distributedLockDAO.getDistributedLock(savedLock.getLockName(), savedLock.getUserId());
assertEquals(Optional.of(savedLock), loadedDistributedLock);
}
}

@Nested
class DeleteDistributedLock {

@Test
void testDeleteDistributedLock() {
DistributedLock savedLock = distributedLockDAO.upsertDistributedLock(testDistributedLock);
assertPresent(
distributedLockDAO.getDistributedLock(savedLock.getLockName(), savedLock.getUserId()));
assertTrue(
distributedLockDAO.deleteDistributedLock(savedLock.getLockName(), savedLock.getUserId()));
assertEmpty(
distributedLockDAO.getDistributedLock(savedLock.getLockName(), savedLock.getUserId()));
}

@Test
void testDeleteNonexistentDistributedLock() {

assertFalse(distributedLockDAO.deleteDistributedLock(testLockName, "nonexistent_user_id"));
}
}
}
Loading
Loading