diff --git a/composer.json b/composer.json index ca5f540..d4601b6 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "aws/aws-sdk-php": "^3.209", "defuse/php-encryption": "^2.3", "google/cloud-kms": "^1.20", - "keboola/azure-key-vault-client": "^3.0", + "keboola/azure-key-vault-client": "^4.0", "keboola/common-exceptions": "^1.2", "vkartaviy/retry": "^0.2" }, diff --git a/src/EncryptorOptions.php b/src/EncryptorOptions.php index 3dbc98b..be7c05a 100644 --- a/src/EncryptorOptions.php +++ b/src/EncryptorOptions.php @@ -24,6 +24,9 @@ class EncryptorOptions private ?string $kmsRole; private int $backoffMaxTries; + /** @var non-empty-string|null */ + private ?string $encryptorId; + /** * @param non-empty-string $stackId * @param non-empty-string|null $kmsKeyId @@ -32,6 +35,7 @@ class EncryptorOptions * @param non-empty-string|null $akvUrl * @param non-empty-string|null $gkmsKeyId * @param int|null $backoffMaxTries + * @param non-empty-string|null $encryptorId */ public function __construct( string $stackId, @@ -41,6 +45,7 @@ public function __construct( ?string $akvUrl = null, ?string $gkmsKeyId = null, ?int $backoffMaxTries = null, + ?string $encryptorId = null, ) { $this->stackId = $stackId; $this->kmsKeyId = $kmsKeyId; @@ -49,6 +54,7 @@ public function __construct( $this->akvUrl = $akvUrl; $this->gkmsKeyId = $gkmsKeyId; $this->backoffMaxTries = $backoffMaxTries ?? self::DEFAULT_BACKOFF_MAX_TRIES; + $this->encryptorId = $encryptorId; $this->validateState(); } @@ -102,6 +108,14 @@ public function getBackoffMaxTries(): int return $this->backoffMaxTries; } + /** + * @return non-empty-string|null + */ + public function getEncryptorId(): ?string + { + return $this->encryptorId; + } + /** * @throws ApplicationException */ diff --git a/src/ObjectEncryptor.php b/src/ObjectEncryptor.php index 2d7443f..d7908ad 100644 --- a/src/ObjectEncryptor.php +++ b/src/ObjectEncryptor.php @@ -35,6 +35,7 @@ use Keboola\ObjectEncryptor\Wrapper\ProjectWideAKVWrapper; use Keboola\ObjectEncryptor\Wrapper\ProjectWideGKMSWrapper; use Keboola\ObjectEncryptor\Wrapper\ProjectWideKMSWrapper; +use Psr\Log\LoggerInterface; use stdClass; use Throwable; @@ -47,7 +48,7 @@ class ObjectEncryptor private ?KmsClient $kmsClient = null; private ?KeyManagementServiceClient $gkmsClient = null; - public function __construct(EncryptorOptions $encryptorOptions) + public function __construct(EncryptorOptions $encryptorOptions, private readonly ?LoggerInterface $logger = null) { $this->encryptorOptions = $encryptorOptions; } @@ -622,14 +623,17 @@ private function getAKVWrappers( ): array { $wrappers = []; $wrapper = new GenericAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrappers[] = $wrapper; if ($this->encryptorOptions->getStackId()) { if ($projectId) { $wrapper = new ProjectWideAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setProjectId($projectId); $wrappers[] = $wrapper; if ($branchType) { $wrapper = new BranchTypeProjectWideAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setProjectId($projectId); $wrapper->setBranchType($branchType); $wrappers[] = $wrapper; @@ -637,21 +641,25 @@ private function getAKVWrappers( } if ($componentId) { $wrapper = new ComponentAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setComponentId($componentId); $wrappers[] = $wrapper; if ($projectId) { $wrapper = new ProjectAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setComponentId($componentId); $wrapper->setProjectId($projectId); $wrappers[] = $wrapper; if ($configurationId) { $wrapper = new ConfigurationAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setComponentId($componentId); $wrapper->setProjectId($projectId); $wrapper->setConfigurationId($configurationId); $wrappers[] = $wrapper; if ($branchType) { $wrapper = new BranchTypeConfigurationAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setComponentId($componentId); $wrapper->setProjectId($projectId); $wrapper->setConfigurationId($configurationId); @@ -661,6 +669,7 @@ private function getAKVWrappers( } if ($branchType) { $wrapper = new BranchTypeProjectAKVWrapper($this->encryptorOptions); + $wrapper->logger = $this->logger; $wrapper->setComponentId($componentId); $wrapper->setProjectId($projectId); $wrapper->setBranchType($branchType); diff --git a/src/ObjectEncryptorFactory.php b/src/ObjectEncryptorFactory.php index b702679..205231f 100644 --- a/src/ObjectEncryptorFactory.php +++ b/src/ObjectEncryptorFactory.php @@ -4,6 +4,8 @@ namespace Keboola\ObjectEncryptor; +use Psr\Log\LoggerInterface; + class ObjectEncryptorFactory { /** @@ -28,10 +30,13 @@ public static function getAwsEncryptor( * @param non-empty-string $keyVaultUrl * @return ObjectEncryptor */ - public static function getAzureEncryptor(string $stackId, string $keyVaultUrl): ObjectEncryptor - { + public static function getAzureEncryptor( + string $stackId, + string $keyVaultUrl, + ?LoggerInterface $logger = null, + ): ObjectEncryptor { $encryptOptions = new EncryptorOptions($stackId, null, null, null, $keyVaultUrl); - return self::getEncryptor($encryptOptions); + return self::getEncryptor($encryptOptions, $logger); } /** @@ -45,8 +50,10 @@ public static function getGcpEncryptor(string $stackId, string $gkmsKeyId): Obje return self::getEncryptor($encryptOptions); } - public static function getEncryptor(EncryptorOptions $encryptorOptions): ObjectEncryptor - { - return new ObjectEncryptor($encryptorOptions); + public static function getEncryptor( + EncryptorOptions $encryptorOptions, + ?LoggerInterface $logger = null, + ): ObjectEncryptor { + return new ObjectEncryptor($encryptorOptions, $logger); } } diff --git a/src/Temporary/CallbackRetryPolicy.php b/src/Temporary/CallbackRetryPolicy.php new file mode 100644 index 0000000..648b2db --- /dev/null +++ b/src/Temporary/CallbackRetryPolicy.php @@ -0,0 +1,33 @@ +shouldRetryCallback = $shouldRetryCallback(...); + } + + public function canRetry(RetryContextInterface $context): bool + { + $e = $context->getLastException(); + + if (($this->shouldRetryCallback)($e, $context) !== true) { + return false; + } + + return parent::canRetry($context); + } +} diff --git a/src/Temporary/TransAuthenticatorFactory.php b/src/Temporary/TransAuthenticatorFactory.php new file mode 100644 index 0000000..ac56a71 --- /dev/null +++ b/src/Temporary/TransAuthenticatorFactory.php @@ -0,0 +1,24 @@ +checkUsability(); + return $authenticator; + } catch (ClientException) { + throw new TransClientNotAvailableException; + } + } +} diff --git a/src/Temporary/TransClient.php b/src/Temporary/TransClient.php new file mode 100644 index 0000000..be7df10 --- /dev/null +++ b/src/Temporary/TransClient.php @@ -0,0 +1,39 @@ +keyVaultURL)) { throw new ApplicationException('Cipher key settings are invalid.'); } + + $this->encryptorId = $encryptorOptions->getEncryptorId(); } public function getClient(): Client @@ -58,9 +71,30 @@ public function getClient(): Client return $this->client; } - private function getRetryProxy(): RetryProxy + public function getTransClient(): ?TransClient + { + if ($this->transClient === null) { + try { + $this->transClient = new TransClient( + new GuzzleClientFactory(new NullLogger()), + $this->encryptorId, + ); + } catch (TransClientNotAvailableException) { + $this->transClient = false; + } + } + + return $this->transClient ?: null; + } + + private static function getTransStackId(): ?string { - $retryPolicy = new SimpleRetryPolicy(3); + return (string) getenv(self::TRANS_STACK_ID_ENV) ?: null; + } + + private function getRetryProxy(?RetryPolicyInterface $retryPolicy = null): RetryProxy + { + $retryPolicy ??= new SimpleRetryPolicy(3); $backOffPolicy = new ExponentialBackOffPolicy(1000); return new RetryProxy($retryPolicy, $backOffPolicy); } @@ -148,8 +182,36 @@ public function decrypt(string $encryptedData): string ) { throw new UserException('Deciphering failed.'); } + + $metadata = $this->metadata; + $doBackfill = false; + + // try retrieve secret from trans AKV + if ($this->getTransClient() !== null) { + // do not retry if trans AKV response is 404 + $retryDecider = fn($e) => !$e instanceof ClientException || $e->getCode() !== 404; + $retryPolicy = new CallbackRetryPolicy($retryDecider); + try { + $decryptedContext = $this->getRetryProxy($retryPolicy)->call(function () use ($encrypted) { + return $this->getTransClient() + ?->getSecret($encrypted[self::SECRET_NAME]) + ->getValue(); + }); + if ($decryptedContext !== null && isset($this->metadata['stackId']) && self::getTransStackId()) { + $metadata['stackId'] = self::getTransStackId(); + } + } catch (Throwable $e) { + if ($e instanceof ClientException && $e->getCode() === 404) { + $doBackfill = true; + } else { + throw new ApplicationException('Deciphering failed.', $e->getCode(), $e); + } + } + } + try { - $decryptedContext = $this->getRetryProxy()->call(function () use ($encrypted) { + // retrieve only if not found at trans AKV + $decryptedContext ??= $this->getRetryProxy()->call(function () use ($encrypted) { return $this->getClient() ->getSecret($encrypted[self::SECRET_NAME]) ->getValue(); @@ -165,12 +227,43 @@ public function decrypt(string $encryptedData): string } catch (Throwable $e) { throw new ApplicationException('Deciphering failed.', $e->getCode(), $e); } - $this->verifyMetadata($decryptedContext[self::METADATA_INDEX], $this->metadata); + $this->verifyMetadata($decryptedContext[self::METADATA_INDEX], $metadata); try { $key = Key::loadFromAsciiSafeString($decryptedContext[self::KEY_INDEX]); return Crypto::decrypt($encrypted[self::PAYLOAD_INDEX], $key, true); } catch (Throwable $e) { + $doBackfill = false; throw new ApplicationException('Deciphering failed.', $e->getCode(), $e); + } finally { + if ($doBackfill && !self::getTransStackId()) { + $doBackfill = false; + $this->logger?->error(sprintf('Env %s not set.', self::TRANS_STACK_ID_ENV)); + } + if ($doBackfill) { + if (isset($this->metadata['stackId'])) { + $decryptedContext[self::METADATA_INDEX]['stackId'] = self::getTransStackId(); + } + try { + $this->getRetryProxy()->call(function () use ($encrypted, $decryptedContext) { + $context = $this->encode($decryptedContext); + $this->getTransClient()?->setSecret( + new SetSecretRequest($context, new SecretAttributes()), + $encrypted[self::SECRET_NAME], + ); + }); + $this->logger?->info('Secret "{secretName}" migrated in {stackId} AKV.', [ + 'secretName' => $encrypted[self::SECRET_NAME], + 'stackId' => self::getTransStackId(), + ]); + } catch (Throwable $e) { + // intentionally suppress all errors to prevent decrypt() from failing + $this->logger?->error('Migration of secret "{secretName}" in {stackId} AKV failed.', [ + 'secretName' => $encrypted[self::SECRET_NAME], + 'stackId' => self::getTransStackId(), + 'exception' => $e, + ]); + } + } } } diff --git a/tests/EncryptorOptionsTest.php b/tests/EncryptorOptionsTest.php index 721fde2..0b755a3 100644 --- a/tests/EncryptorOptionsTest.php +++ b/tests/EncryptorOptionsTest.php @@ -20,6 +20,7 @@ public function testAccessors(): void akvUrl: 'akv-url', gkmsKeyId: 'gkms-key-id', backoffMaxTries: 1, + encryptorId: 'internal', ); self::assertSame('my-stack', $options->getStackId()); self::assertSame('my-kms-id', $options->getKmsKeyId()); @@ -28,6 +29,7 @@ public function testAccessors(): void self::assertSame('akv-url', $options->getAkvUrl()); self::assertSame('gkms-key-id', $options->getGkmsKeyId()); self::assertSame(1, $options->getBackoffMaxTries()); + self::assertSame('internal', $options->getEncryptorId()); } public function testConstructEmptyConfig(): void diff --git a/tests/Temporary/AKVWrappersWithTransClientTest.php b/tests/Temporary/AKVWrappersWithTransClientTest.php new file mode 100644 index 0000000..2bfe0f0 --- /dev/null +++ b/tests/Temporary/AKVWrappersWithTransClientTest.php @@ -0,0 +1,558 @@ + [ + 'wrapperClass' => GenericAKVWrapper::class, + 'metadata' => [], + ]; + + yield ComponentAKVWrapper::class => [ + 'wrapperClass' => ComponentAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'componentId' => 'component-id', + ], + ]; + + yield ProjectAKVWrapper::class => [ + 'wrapperClass' => ProjectAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'componentId' => 'component-id', + 'projectId' => 'project-id', + ], + ]; + + yield ConfigurationAKVWrapper::class => [ + 'wrapperClass' => ConfigurationAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'componentId' => 'component-id', + 'projectId' => 'project-id', + 'configurationId' => 'config-id', + ], + ]; + + yield ProjectWideAKVWrapper::class => [ + 'wrapperClass' => ProjectWideAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'projectId' => 'project-id', + ], + ]; + + yield BranchTypeProjectAKVWrapper::class => [ + 'wrapperClass' => BranchTypeProjectAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'componentId' => 'component-id', + 'projectId' => 'project-id', + 'branchType' => 'branch-type', + ], + ]; + + yield BranchTypeConfigurationAKVWrapper::class => [ + 'wrapperClass' => BranchTypeConfigurationAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'componentId' => 'component-id', + 'projectId' => 'project-id', + 'configurationId' => 'config-id', + 'branchType' => 'branch-type', + ], + ]; + + yield BranchTypeProjectWideAKVWrapper::class => [ + 'wrapperClass' => BranchTypeProjectWideAKVWrapper::class, + 'metadata' => [ + 'stackId' => 'some-stack', + 'projectId' => 'project-id', + 'branchType' => 'branch-type', + ], + ]; + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testWrappersDoNotHaveTransClientInitializedWhenTransEnvsMissing( + string $wrapperClass, + ): void { + $encryptorOptions = new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ); + + $wrapper = new $wrapperClass($encryptorOptions); + + self::assertNull($wrapper->getTransClient()); + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testWrappersHaveTransClientInitialized( + string $wrapperClass, + ): void { + putenv('TRANS_AZURE_TENANT_ID=tenant-id'); + putenv('TRANS_AZURE_CLIENT_ID=client-id'); + putenv('TRANS_AZURE_CLIENT_SECRET=client-secret'); + putenv('TRANS_AZURE_KEY_VAULT_URL=https://vault-url'); + + $encryptorOptions = new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ); + + $wrapper = new $wrapperClass($encryptorOptions); + + $transClient = $wrapper->getTransClient(); + self::assertInstanceOf(TransClient::class, $transClient); + + // ensure getter returns a single instance of the TransClient + self::assertSame($transClient, $wrapper->getTransClient()); + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testWrappersHaveTransClientWhenEncryptorIdMatches( + string $wrapperClass, + ): void { + putenv('TRANS_AZURE_TENANT_ID=tenant-id'); + putenv('TRANS_AZURE_CLIENT_ID=client-id'); + putenv('TRANS_AZURE_CLIENT_SECRET=client-secret'); + putenv('TRANS_AZURE_KEY_VAULT_URL='); + putenv('TRANS_AZURE_KEY_VAULT_URL_EXTRA_BRATWURST=https://german-vault-url'); + + $encryptorOptions = new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + encryptorId: 'extra-bratwurst', + ); + + $wrapper = new $wrapperClass($encryptorOptions); + + $transClient = $wrapper->getTransClient(); + self::assertInstanceOf(TransClient::class, $transClient); + + // ensure getter returns a single instance of the TransClient + self::assertSame($transClient, $wrapper->getTransClient()); + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testWrappersDoNotHaveTransClientWhenEncryptorIdMismatches( + string $wrapperClass, + ): void { + putenv('TRANS_AZURE_TENANT_ID=tenant-id'); + putenv('TRANS_AZURE_CLIENT_ID=client-id'); + putenv('TRANS_AZURE_CLIENT_SECRET=client-secret'); + putenv('TRANS_AZURE_KEY_VAULT_URL='); + putenv('TRANS_AZURE_KEY_VAULT_URL_EXTRA_BRATWURST=https://german-vault-url'); + + // null encryptorId + $wrapper = new $wrapperClass(new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + encryptorId: null, + )); + self::assertNull($wrapper->getTransClient()); + + // encryptorId does not match env suffix ('extra-sausage' vs. _EXTRA_BRATWURST) + $wrapper = new $wrapperClass(new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + encryptorId: 'extra-sausage', + )); + self::assertNull($wrapper->getTransClient()); + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testDecryptWithBackfillWhenSecretNotFoundInTransVault( + string $wrapperClass, + array $metadata, + ): void { + putenv('TRANS_ENCRYPTOR_STACK_ID=trans-stack'); + + $key = Key::createNewRandomKey(); + + $mockClient = $this->createMock(Client::class); + $mockClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willReturn(new SecretBundle([ + 'id' => 'secret-id', + 'value' => self::encode([ + 0 => $metadata, + 1 => $key->saveToAsciiSafeString(), + ]), + 'attributes' => [], + ])); + + $mockTransClient = $this->createMock(TransClient::class); + $mockTransClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willThrowException(new ClientException('not found', 404)); + $mockTransClient->expects(self::once()) + ->method('setSecret') + ->with( + self::callback(function ($arg) use ($key, $metadata) { + self::assertInstanceOf(SetSecretRequest::class, $arg); + + if (array_key_exists('stackId', $metadata)) { + $metadata['stackId'] = 'trans-stack'; + } + $encodedKey = self::encode([ + 0 => $metadata, + 1 => $key->saveToAsciiSafeString(), + ]); + + self::assertSame($encodedKey, $arg->getArray()['value']); + return true; + }), + 'secret-name', + ); + + $logger = new TestLogger(); + + /** @var GenericAKVWrapper|MockObject $mockWrapper */ + $mockWrapper = $this->getMockBuilder($wrapperClass) + ->setConstructorArgs([ + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + ]) + ->onlyMethods(['getClient', 'getTransClient']) + ->getMock(); + $mockWrapper->logger = $logger; + foreach ($metadata as $metaKey => $metaValue) { + $mockWrapper->setMetadataValue($metaKey, $metaValue); + } + $mockWrapper->expects(self::once()) + ->method('getClient') + ->willReturn($mockClient); + $mockWrapper->expects(self::atLeast(3)) + ->method('getTransClient') + ->willReturn($mockTransClient); + + $encryptedSecret = self::encode([ + 2 => Crypto::encrypt('something very secret', $key, true), + 3 => 'secret-name', + 4 => 'secret-version', + ]); + + $secret = $mockWrapper->decrypt($encryptedSecret); + + self::assertSame('something very secret', $secret); + self::assertTrue($logger->hasInfo([ + 'message' => 'Secret "{secretName}" migrated in {stackId} AKV.', + 'context' => [ + 'secretName' => 'secret-name', + 'stackId' => 'trans-stack', + ], + ])); + } + + /** + * @dataProvider provideAKVWrappers + * @param class-string $wrapperClass + */ + public function testDecryptOmitsPrimaryVaultWhenSecretFoundInTransVault( + string $wrapperClass, + array $metadata, + ): void { + $key = Key::createNewRandomKey(); + + $mockTransClient = $this->createMock(TransClient::class); + $mockTransClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willReturn(new SecretBundle([ + 'id' => 'secret-id', + 'value' => self::encode([ + 0 => $metadata, + 1 => $key->saveToAsciiSafeString(), + ]), + 'attributes' => [], + ])); + $mockTransClient->expects(self::never())->method('setSecret'); + + /** @var GenericAKVWrapper|MockObject $mockWrapper */ + $mockWrapper = $this->getMockBuilder($wrapperClass) + ->setConstructorArgs([ + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + ]) + ->onlyMethods(['getClient', 'getTransClient']) + ->getMock(); + foreach ($metadata as $metaKey => $metaValue) { + $mockWrapper->setMetadataValue($metaKey, $metaValue); + } + $mockWrapper->expects(self::never())->method('getClient'); + $mockWrapper->expects(self::exactly(2)) + ->method('getTransClient') + ->willReturn($mockTransClient); + + $encryptedSecret = self::encode([ + 2 => Crypto::encrypt('something very secret', $key, true), + 3 => 'secret-name', + 4 => 'secret-version', + ]); + + $secret = $mockWrapper->decrypt($encryptedSecret); + + self::assertSame('something very secret', $secret); + } + + public function testTransVaultGetSecretFails(): void + { + $key = Key::createNewRandomKey(); + + $mockTransClient = $this->createMock(TransClient::class); + $mockTransClient->expects(self::exactly(3)) + ->method('getSecret') + ->with('secret-name') + ->willThrowException(new ClientException('something failed', 500)); + $mockTransClient->expects(self::never())->method('setSecret'); + + /** @var GenericAKVWrapper|MockObject $mockWrapper */ + $mockWrapper = $this->getMockBuilder(GenericAKVWrapper::class) + ->setConstructorArgs([ + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + ]) + ->onlyMethods(['getClient', 'getTransClient']) + ->getMock(); + $mockWrapper->expects(self::never())->method('getClient'); + $mockWrapper->expects(self::exactly(4)) + ->method('getTransClient') + ->willReturn($mockTransClient); + + $encryptedSecret = self::encode([ + 2 => Crypto::encrypt('something very secret', $key, true), + 3 => 'secret-name', + 4 => 'secret-version', + ]); + + $this->expectException(ApplicationException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Deciphering failed.'); + + $mockWrapper->decrypt($encryptedSecret); + } + + public function testLogErrorWhenTransVaultSetSecretFails(): void + { + putenv('TRANS_ENCRYPTOR_STACK_ID=trans-stack'); + + $key = Key::createNewRandomKey(); + + $mockClient = $this->createMock(Client::class); + $mockClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willReturn(new SecretBundle([ + 'id' => 'secret-id', + 'value' => self::encode([ + 0 => [], + 1 => $key->saveToAsciiSafeString(), + ]), + 'attributes' => [], + ])); + + $mockTransClient = $this->createMock(TransClient::class); + $mockTransClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willThrowException(new ClientException('not found', 404)); + + $setSecretException = new ClientException('something failed', 500); + $mockTransClient->expects(self::exactly(3)) + ->method('setSecret') + ->willThrowException($setSecretException); + + $logger = new TestLogger(); + + /** @var GenericAKVWrapper|MockObject $mockWrapper */ + $mockWrapper = $this->getMockBuilder(GenericAKVWrapper::class) + ->setConstructorArgs([ + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + ]) + ->onlyMethods(['getClient', 'getTransClient']) + ->getMock(); + $mockWrapper->logger = $logger; + $mockWrapper->expects(self::once()) + ->method('getClient') + ->willReturn($mockClient); + $mockWrapper->expects(self::exactly(5)) + ->method('getTransClient') + ->willReturn($mockTransClient); + + $encryptedSecret = self::encode([ + 2 => Crypto::encrypt('something very secret', $key, true), + 3 => 'secret-name', + 4 => 'secret-version', + ]); + + $secret = $mockWrapper->decrypt($encryptedSecret); + + self::assertSame('something very secret', $secret); + self::assertTrue($logger->hasError([ + 'message' => 'Migration of secret "{secretName}" in {stackId} AKV failed.', + 'context' => [ + 'secretName' => 'secret-name', + 'stackId' => 'trans-stack', + 'exception' => $setSecretException, + ], + ])); + } + + public function testSkipBackfillWhenTransStackIdEnvNotSet(): void + { + putenv('TRANS_ENCRYPTOR_STACK_ID='); + + $key = Key::createNewRandomKey(); + + $mockClient = $this->createMock(Client::class); + $mockClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willReturn(new SecretBundle([ + 'id' => 'secret-id', + 'value' => self::encode([ + 0 => [], + 1 => $key->saveToAsciiSafeString(), + ]), + 'attributes' => [], + ])); + + $mockTransClient = $this->createMock(TransClient::class); + $mockTransClient->expects(self::once()) + ->method('getSecret') + ->with('secret-name') + ->willThrowException(new ClientException('not found', 404)); + $mockTransClient->expects(self::never())->method('setSecret'); + + $logger = new TestLogger(); + + /** @var GenericAKVWrapper|MockObject $mockWrapper */ + $mockWrapper = $this->getMockBuilder(GenericAKVWrapper::class) + ->setConstructorArgs([ + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + ]) + ->onlyMethods(['getClient', 'getTransClient']) + ->getMock(); + $mockWrapper->logger = $logger; + $mockWrapper->expects(self::once()) + ->method('getClient') + ->willReturn($mockClient); + $mockWrapper->expects(self::exactly(2)) + ->method('getTransClient') + ->willReturn($mockTransClient); + + $encryptedSecret = self::encode([ + 2 => Crypto::encrypt('something very secret', $key, true), + 3 => 'secret-name', + 4 => 'secret-version', + ]); + + $secret = $mockWrapper->decrypt($encryptedSecret); + + self::assertSame('something very secret', $secret); + self::assertTrue($logger->hasError('Env TRANS_ENCRYPTOR_STACK_ID not set.')); + } + + public function testObjectEncryptorFactoryInjectsLoggerInAKVWrappers(): void + { + $logger = new TestLogger(); + + $encryptor = ObjectEncryptorFactory::getEncryptor( + new EncryptorOptions( + stackId: 'some-stack', + akvUrl: 'some-url', + ), + $logger, + ); + + $reflection = new ReflectionClass(ObjectEncryptor::class); + $method = $reflection->getMethod('getWrappers'); + $method->setAccessible(true); + + /** @var array $wrappers */ + $wrappers = $method->invokeArgs($encryptor, ['component-id', 'project-id', 'config-id', 'branch-type']); + + self::assertIsArray($wrappers); + self::assertCount(8, $wrappers); + + foreach ($wrappers as $wrapper) { + self::assertSame($logger, $wrapper->logger); + } + } + + private static function encode(mixed $data): string + { + return base64_encode((string) gzcompress(serialize($data))); + } +} diff --git a/tests/Temporary/TransAuthenticatorFactoryTest.php b/tests/Temporary/TransAuthenticatorFactoryTest.php new file mode 100644 index 0000000..f66ddd5 --- /dev/null +++ b/tests/Temporary/TransAuthenticatorFactoryTest.php @@ -0,0 +1,89 @@ +getAuthenticator( + new GuzzleClientFactory(new NullLogger()), + 'https://vault.azure.net', + ); + self::assertTrue(true); + } catch (TransClientNotAvailableException) { + self::fail('Test should not have thrown an exception'); + } + } + + public static function provideInvalidTransEnvs(): iterable + { + yield 'missing all' => [ + [], + ]; + + yield 'missing TRANS_AZURE_TENANT_ID' => [ + [ + 'TRANS_AZURE_TENANT_ID' => '', + 'TRANS_AZURE_CLIENT_ID' => 'client-id', + 'TRANS_AZURE_CLIENT_SECRET' => 'client-secret', + ], + ]; + + yield 'missing TRANS_AZURE_CLIENT_ID' => [ + [ + 'TRANS_AZURE_TENANT_ID' => 'tenant-id', + 'TRANS_AZURE_CLIENT_ID' => '', + 'TRANS_AZURE_CLIENT_SECRET' => 'client-secret', + ], + ]; + + yield 'missing TRANS_AZURE_CLIENT_SECRET' => [ + [ + 'TRANS_AZURE_TENANT_ID' => 'tenant-id', + 'TRANS_AZURE_CLIENT_ID' => 'client-id', + 'TRANS_AZURE_CLIENT_SECRET' => '', + ], + ]; + } + + /** @dataProvider provideInvalidTransEnvs */ + public function testTransAuthenticatorIsUnavailable(array $invalidEnvs): void + { + foreach ($invalidEnvs as $name => $value) { + putenv(sprintf('%s=%s', $name, $value)); + } + + $transAuthFactory = new TransAuthenticatorFactory(); + + $this->expectException(TransClientNotAvailableException::class); + + $transAuthFactory->getAuthenticator( + new GuzzleClientFactory(new NullLogger()), + 'https://vault.azure.net', + ); + } +} diff --git a/tests/Temporary/TransClientTest.php b/tests/Temporary/TransClientTest.php new file mode 100644 index 0000000..f89bd90 --- /dev/null +++ b/tests/Temporary/TransClientTest.php @@ -0,0 +1,166 @@ + [ + 'encryptorId' => null, + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL', + ]; + yield 'encryptorId is empty' => [ + 'encryptorId' => '', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL', + ]; + yield 'encryptorId = "internal"' => [ + 'encryptorId' => 'internal', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL_INTERNAL', + ]; + yield 'encryptorId = "job_queue"' => [ + 'encryptorId' => 'job_queue', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL_JOB_QUEUE', + ]; + yield 'encryptorId = "job__queue_"' => [ + 'encryptorId' => 'job__queue_', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL_JOB_QUEUE', + ]; + yield 'encryptorId = "job-queue"' => [ + 'encryptorId' => 'job-queue', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL_JOB_QUEUE', + ]; + yield 'encryptorId = "job queue"' => [ + 'encryptorId' => 'job queue', + 'expectedEnvName' => 'TRANS_AZURE_KEY_VAULT_URL_JOB_QUEUE', + ]; + } + + /** @dataProvider provideDeterminateVaultUrlEnvNameTestData */ + public function testDeterminateVaultUrlEnvName( + ?string $encryptorId, + string $expectedEnvName, + ): void { + self::assertSame( + $expectedEnvName, + TransClient::determinateVaultUrlEnvName($encryptorId), + ); + } + + public static function provideTransClientUrlTestData(): iterable + { + yield 'encryptorId is null' => [ + 'encryptorId' => null, + 'envs' => [ + 'TRANS_AZURE_KEY_VAULT_URL' => 'https://vault-url', + ], + 'expectedClientUrl' => 'https://vault-url', + ]; + + yield 'encryptorId = "internal"' => [ + 'encryptorId' => 'internal', + 'envs' => [ + 'TRANS_AZURE_KEY_VAULT_URL_INTERNAL' => 'https://internal-vault-url', + ], + 'expectedClientUrl' => 'https://internal-vault-url', + ]; + } + + /** @dataProvider provideTransClientUrlTestData */ + public function testTransClientUrl( + ?string $encryptorId, + array $envs, + string $expectedVaultUrl, + ): void { + putenv('TRANS_AZURE_TENANT_ID=tenant-id'); + putenv('TRANS_AZURE_CLIENT_ID=client-id'); + putenv('TRANS_AZURE_CLIENT_SECRET=client-secret'); + foreach ($envs as $envName => $envValue) { + putenv(sprintf('%s=%s', $envName, $envValue)); + } + + $guzzleClientFactoryCounter = self::exactly(2); + $guzzleClientFactoryMock = $this->createMock(GuzzleClientFactory::class); + $guzzleClientFactoryMock->expects($guzzleClientFactoryCounter) + ->method('getClient') + ->with( + self::callback(fn($url) => match ($guzzleClientFactoryCounter->getInvocationCount()) { + 1 => $url === $expectedVaultUrl, + 2 => $url === 'https://management.azure.com/metadata/endpoints?api-version=2020-01-01', + default => self::fail('Unexpected url: ' . $url), + }), + self::isType('array'), + ); + + try { + new TransClient( + $guzzleClientFactoryMock, + $encryptorId, + ); + } catch (TransClientNotAvailableException) { + self::fail('Test should not have thrown an exception'); + } + } + + public static function provideTransClientMismatchEnvsTestData(): iterable + { + yield 'encryptorId is null, env has suffix' => [ + 'encryptorId' => null, + 'envs' => [ + 'TRANS_AZURE_KEY_VAULT_URL_SOMETHING' => 'https://vault-url', + ], + ]; + + yield 'encryptorId = "internal", env suffix is missing' => [ + 'encryptorId' => 'internal', + 'envs' => [ + 'TRANS_AZURE_KEY_VAULT_URL' => 'https://vault-url', + ], + ]; + } + + /** @dataProvider provideTransClientMismatchEnvsTestData */ + public function testTransClientMismatchEnvs( + ?string $encryptorId, + array $envs, + ): void { + putenv('TRANS_AZURE_TENANT_ID=tenant-id'); + putenv('TRANS_AZURE_CLIENT_ID=client-id'); + putenv('TRANS_AZURE_CLIENT_SECRET=client-secret'); + foreach ($envs as $envName => $envValue) { + putenv(sprintf('%s=%s', $envName, $envValue)); + } + + $this->expectException(TransClientNotAvailableException::class); + + new TransClient( + new GuzzleClientFactory(new NullLogger()), + $encryptorId, + ); + } +}