diff --git a/src/AwsServiceProvider.php b/src/AwsServiceProvider.php index dbd855d..e5c9edb 100644 --- a/src/AwsServiceProvider.php +++ b/src/AwsServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Support\ServiceProvider; use Aws\SecretsManager\SecretsManagerClient; +use Aws\RedshiftServerless\RedshiftServerlessClient; final class AwsServiceProvider extends ServiceProvider { @@ -18,5 +19,14 @@ public function register() 'region' => $config['region'], ]); }); + + $this->app->singleton(RedshiftServerlessClient::class, function () { + $config = $this->app['config']->get('aws'); + + return new RedshiftServerlessClient([ + 'version' => '2021-04-21', + 'region' => $config['region'], + ]); + }); } -} \ No newline at end of file +} diff --git a/src/RedshiftConnector.php b/src/RedshiftConnector.php index a600dcd..b3bf078 100644 --- a/src/RedshiftConnector.php +++ b/src/RedshiftConnector.php @@ -2,20 +2,39 @@ namespace CustomerGauge\Redshift; -use Exception; +use CustomerGauge\Redshift\Resolvers\PasswordResolver; +use CustomerGauge\Redshift\Resolvers\TemporaryCredentialResolver; use Illuminate\Database\Connectors\PostgresConnector; use PDOException; +use Exception; final class RedshiftConnector extends PostgresConnector { - public function __construct(private PasswordResolver $resolver) - {} + public function __construct( + private PasswordResolver $password, + private TemporaryCredentialResolver $temporary + ) {} public function createConnection($dsn, array $config, array $options) { + // When the `temporary_credential` option is enabled in the database configuration, + // the application connects to Amazon Redshift using short-lived credentials, + // typically obtained through AWS IAM authentication mechanisms. + + $temporaryCredential = $config['redshift']['temporary_credential'] ?? null; + + if (is_array($temporaryCredential)) { + $credentials = $this->temporary->resolve($temporaryCredential); + + $config['username'] = $credentials['username']; + + $config['password'] = $credentials['password']; + } + // If the developer explicitly set the `password` attribute on the database // configuration, we'll go ahead and establish a regular connection. This // is useful for automation tests that bypass the connection process. + if (! empty($config['password'])) { return parent::createConnection($dsn, $config, $options); } @@ -28,9 +47,10 @@ public function createConnection($dsn, array $config, array $options) // The Password Resolver extension will keep a cache of the password. // If Laravel throws an exception because of wrong password, then // we can retry but ask the extension to refresh the cache. + $freshSecret = $attempt > 1; - $config['password'] = $this->resolver->resolve($config['redshift']['secret'], $freshSecret); + $config['password'] = $this->password->resolve($config['redshift']['secret'], $freshSecret); return parent::createConnection($dsn, $config, $options); }; diff --git a/src/PasswordResolver.php b/src/Resolvers/PasswordResolver.php similarity index 97% rename from src/PasswordResolver.php rename to src/Resolvers/PasswordResolver.php index 0c9dca3..5b1ddee 100644 --- a/src/PasswordResolver.php +++ b/src/Resolvers/PasswordResolver.php @@ -1,6 +1,6 @@ validate($config); + + $response = $this->client->getCredentialsAsync($validated)->wait(); + + return [ + 'username' => $response['dbUser'], + 'password' => $response['dbPassword'], + ]; + } + + private function validate(array $config): array + { + if (! isset($config['workgroupName']) || empty($config['workgroupName'])) { + throw new \InvalidArgumentException('The workgroupName must be defined on database.{connection}.redshift.temporary_credential.workgroupName'); + } + + return array_filter($config); + } +} diff --git a/tests/PasswordResolverTest.php b/tests/PasswordResolverTest.php index 3fdfa07..214062a 100644 --- a/tests/PasswordResolverTest.php +++ b/tests/PasswordResolverTest.php @@ -3,7 +3,7 @@ namespace Tests\CustomerGauge\Redshift; use Aws\SecretsManager\SecretsManagerClient; -use CustomerGauge\Redshift\PasswordResolver; +use CustomerGauge\Redshift\Resolvers\PasswordResolver; use Illuminate\Http\Client\Factory; use Illuminate\Http\Client\Response; use PHPUnit\Framework\TestCase; @@ -12,7 +12,7 @@ class PasswordResolverTest extends TestCase { - public function testRetrieveSecretFromCacheServer() + public function test_retrieve_secret_from_cache_server() { $secretName = 'secretName'; $password = 'password'; @@ -38,7 +38,7 @@ public function testRetrieveSecretFromCacheServer() $this->assertEquals($password, $result); } - public function testFallbackToSecretsManagerWhenCacheServerIsDown() + public function test_fallback_to_secrets_manager_when_cache_server_is_down() { $secretName = 'secretName'; $password = 'password'; @@ -59,7 +59,7 @@ public function testFallbackToSecretsManagerWhenCacheServerIsDown() $this->assertEquals($password, $result); } - public function testItDoesNotCallCacheServerWhenFreshIsTrue() + public function test_it_does_not_call_cache_server_when_fresh_is_true() { $secretName = 'secretName'; $password = 'password'; diff --git a/tests/TemporaryCredentialResolverTest.php b/tests/TemporaryCredentialResolverTest.php new file mode 100644 index 0000000..684f78b --- /dev/null +++ b/tests/TemporaryCredentialResolverTest.php @@ -0,0 +1,59 @@ +expectException(\InvalidArgumentException::class); + + $this->expectExceptionMessage('The workgroupName must be defined on database.{connection}.redshift.temporary_credential.workgroupName'); + + $client = $this->createMock(RedshiftServerlessClient::class); + + $resolver = new TemporaryCredentialResolver($client); + + $resolver->resolve([]); + } + + public function test_retrieve_temporary_credentials_for_redshift_serverless() + { + $client = $this->createMock(RedshiftServerlessClient::class); + + $client->expects($this->once()) + ->method('__call') + ->with('getCredentialsAsync', [['workgroupName' => 'test-workgroup']]) + ->willReturn($this->createMockPromise([ + 'dbUser' => 'testUser', + 'dbPassword' => 'testPassword', + ])); + + $resolver = new TemporaryCredentialResolver($client); + + $config = ['workgroupName' => 'test-workgroup', 'durationSeconds' => null]; + + $credentials = $resolver->resolve($config); + + $this->assertEquals('testUser', $credentials['username']); + + $this->assertEquals('testPassword', $credentials['password']); + } + + private function createMockPromise(array $data): Promise + { + $result = new Result($data); + + $promise = new Promise; + + $promise->resolve($result); + + return $promise; + } +}