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,82 @@
package bio.terra.externalcreds.dataAccess;

import bio.terra.externalcreds.models.DistributedLock;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import java.sql.Timestamp;
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").toInstant())
.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 lock_name, user_id, expires_at FROM distributed_lock WHERE lock_name = :lockName AND user_id = :userId";
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 insertDistributedLock(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", Timestamp.from(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,86 @@
package bio.terra.externalcreds.dataAccess;

import bio.terra.externalcreds.generated.model.Provider;
import bio.terra.externalcreds.models.FenceAccountKey;
import io.opentelemetry.instrumentation.annotations.WithSpan;
import java.sql.Timestamp;
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(rs.getString("key_json"))
.expiresAt(rs.getTimestamp("expires_at").toInstant())
.build());

final NamedParameterJdbcTemplate jdbcTemplate;

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

@WithSpan
public Optional<FenceAccountKey> getFenceAccountKey(String userId, Provider provider) {
var namedParameters =
new MapSqlParameterSource()
.addValue("userId", userId)
.addValue("provider", provider.name());
var query =
"SELECT fence.id, fence.linked_account_id, fence.key_json, fence.expires_at FROM fence_account_key fence"
+ " INNER JOIN linked_account la ON la.id = fence.linked_account_id"
+ " WHERE la.user_id = :userId"
+ " AND la.provider = :provider::provider_enum";
return Optional.ofNullable(
DataAccessUtils.singleResult(
jdbcTemplate.query(query, namedParameters, FENCE_ACCOUNT_KEY_ROW_MAPPER)));
}

@WithSpan
public FenceAccountKey upsertFenceAccountKey(FenceAccountKey fenceAccountKey) {
var query =
"INSERT INTO fence_account_key (linked_account_id, key_json, expires_at)"
+ " VALUES (:linkedAccountId, :keyJson::jsonb, :expiresAt)"
+ " ON CONFLICT (linked_account_id) DO UPDATE SET"
+ " linked_account_id = excluded.linked_account_id,"
+ " key_json = excluded.key_json::jsonb,"
+ " expires_at = excluded.expires_at"
+ " RETURNING id";

var namedParameters =
new MapSqlParameterSource()
.addValue("linkedAccountId", fenceAccountKey.getLinkedAccountId())
.addValue("keyJson", fenceAccountKey.getKeyJson())
.addValue("expiresAt", Timestamp.from(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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package bio.terra.externalcreds.models;

import java.time.Instant;
import org.immutables.value.Value;

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

String getUserId();

Instant getExpiresAt();

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.time.Instant;
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();

Instant getExpiresAt();

class Builder extends ImmutableFenceAccountKey.Builder {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
databaseChangeLog:
- changeSet:
id: "add_fence_account_key_and_dist_lock_table"
author: sehsan
changes:
- createTable:
tableName: fence_account_key
columns:
- column:
name: id
type: int
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: linked_account_id
type: int
constraints:
nullable: false
unique: true
references: linked_account(id)
foreignKeyName: fk_linked_account_id
deleteCascade: true
- column:
name: key_json
type: jsonb
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 @@ -32,4 +32,6 @@ databaseChangeLog:
- 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/20240304_provider_name_to_provider.yaml
relativeToChangelogFile: true

- include:
file: changesets/20240305_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 @@ -60,6 +61,14 @@ public static GA4GHVisa createRandomVisa() {
.build();
}

public static FenceAccountKey createRandomFenceAccountKey() {
return new FenceAccountKey.Builder()
.linkedAccountId(1)
.keyJson("{\"key\": \"value\"}")
.expiresAt(getRandomTimestamp().toInstant())
.build();
}

public static ProviderProperties createRandomProvider() {
try {
return ProviderProperties.create()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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;
import org.springframework.dao.DuplicateKeyException;

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().toInstant())
.build();

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

@Nested
class CreateDistributedLock {

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

@Test
void testCreateDuplicateDistributedLockConflictError() {
DistributedLock savedLock = distributedLockDAO.insertDistributedLock(testDistributedLock);
assertEquals(testDistributedLock, savedLock);
assertThrows(
DuplicateKeyException.class,
() -> distributedLockDAO.insertDistributedLock(testDistributedLock));
}
}

@Nested
class DeleteDistributedLock {

@Test
void testDeleteDistributedLock() {
DistributedLock savedLock = distributedLockDAO.insertDistributedLock(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