Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"guzzlehttp/guzzle": "^7.0",
"mockery/mockery": "^1.6.12",
"phpunit/phpunit": "^10.5 || ^11.0 || ^12.0 || ^13.0",
"psr/log": "^1.0 || ^2.0 || ^3.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0",
"symfony/config": "^5.3 || ^6.0 || ^7.0 || ^8.0",
"symfony/dependency-injection": "^5.0 || ^6.0 || ^7.0 || ^8.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
*/
class Configuration implements ConfigurationInterface
{
/**
* @psalm-suppress UndefinedInterfaceMethod
*/
public function getConfigTreeBuilder(): TreeBuilder
{
$treeBuilder = new TreeBuilder('secretary', 'array');
Expand All @@ -28,6 +31,10 @@ public function getConfigTreeBuilder(): TreeBuilder

$rootNode
->children()
->booleanNode('allow_missing_secrets')
->defaultFalse()
->info('When true, missing secrets resolve to null with a warning instead of throwing')
->end()
->append($this->addAdaptersSection())
->end();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Symfony\Component\DependencyInjection\Alias;
use Symfony\Component\DependencyInjection\Argument\IteratorArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Extension\Extension;
use Symfony\Component\DependencyInjection\Reference;

Expand Down Expand Up @@ -74,6 +75,8 @@ public function load(array $configs, ContainerBuilder $container): void

$container->register('secretary.env_var_processor', EnvVarProcessor::class)
->addArgument(new IteratorArgument($services))
->addArgument(new Reference('logger', ContainerInterface::IGNORE_ON_INVALID_REFERENCE))
->addArgument($config['allow_missing_secrets'])
->addTag('container.env_var_processor')
->setPublic(false);
}
Expand Down
39 changes: 32 additions & 7 deletions src/Bundle/SecretaryBundle/EnvVar/EnvVarProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,12 @@

namespace Secretary\Bundle\SecretaryBundle\EnvVar;

use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Secretary\Exception\SecretNotFoundException;
use Secretary\Manager;
use Symfony\Component\DependencyInjection\EnvVarProcessorInterface;
use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException;

class EnvVarProcessor implements EnvVarProcessorInterface
{
Expand All @@ -20,9 +24,15 @@ class EnvVarProcessor implements EnvVarProcessorInterface
*/
private array $managers;

public function __construct(\Traversable $managers)
{
private LoggerInterface $logger;

public function __construct(
\Traversable $managers,
?LoggerInterface $logger = null,
private bool $allowMissingSecrets = false
) {
$this->managers = iterator_to_array($managers);
$this->logger = $logger ?? new NullLogger();
}

/**
Expand All @@ -38,7 +48,20 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed

$manager = $this->managers[$parts[0]];
$key = $getEnv($parts[1]);
$value = $manager->getSecret($key)->getValue();

try {
$value = $manager->getSecret($key)->getValue();
} catch (SecretNotFoundException $e) {
$message = sprintf('Secret "%s" was not found in manager "%s".', $key, $parts[0]);

if (!$this->allowMissingSecrets && !str_ends_with($prefix, 'Optional')) {
throw new EnvNotFoundException($message, 0, $e);
}

$this->logger->warning($message);

return null;
}

if (array_key_exists(2, $parts)) {
if (!is_array($value)) {
Expand All @@ -57,10 +80,12 @@ public function getEnv(string $prefix, string $name, \Closure $getEnv): mixed
public static function getProvidedTypes(): array
{
return [
'secretary' => 'bool|int|float|string',
'secret' => 'bool|int|float|string',
'secretArray' => 'array',
'secretaryArray' => 'array',
'secretary' => 'bool|int|float|string',
'secret' => 'bool|int|float|string',
'secretArray' => 'array',
'secretaryArray' => 'array',
'secretaryOptional' => 'bool|int|float|string',
'secretaryArrayOptional' => 'array',
];
}
}
40 changes: 39 additions & 1 deletion src/Bundle/SecretaryBundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,42 @@ secretary:
enabled: true
type: psr6
service_id: cache.secrets
```
```

### Resolving secrets in env vars

```yaml
parameters:
db_pass: '%env(secretary:default:DB_PASS)%' # scalar secret
db_conf: '%env(secretaryArray:default:DB_CONFIG)%' # array secret
```

### Missing secrets

By default, a missing secret throws Symfony's `EnvNotFoundException` (with the manager name and
secret key in the message, and the original `SecretNotFoundException` as `previous`).

To resolve `null` instead — with a warning logged through the default logger — you can either
opt in per reference with the `secretaryOptional:` / `secretaryArrayOptional:` prefixes:

```yaml
parameters:
feature_key: '%env(secretaryOptional:default:FEATURE_KEY)%'
```

or globally via the bundle config:

```yaml
secretary:
allow_missing_secrets: true # defaults to false
```

Symfony's built-in `default:` processor also composes, if you want a fallback parameter instead:

```yaml
parameters:
fallback: 'some-default'
db_pass: '%env(default:fallback:secretary:default:DB_PASS)%'
```

Only missing secrets are handled this way — network, auth, and other adapter errors always bubble up.
205 changes: 205 additions & 0 deletions src/Bundle/SecretaryBundle/Tests/EnvVarProcessorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

declare(strict_types=1);

/*
* @author Aaron Scherer <[email protected]>
* @date 2019
* @license https://opensource.org/licenses/MIT
*/

namespace Secretary\Tests;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Psr\Log\AbstractLogger;
use Secretary\Adapter\AbstractAdapter;
use Secretary\Bundle\SecretaryBundle\EnvVar\EnvVarProcessor;
use Secretary\Exception\SecretNotFoundException;
use Secretary\Manager;
use Secretary\Secret;
use Symfony\Component\DependencyInjection\Exception\EnvNotFoundException;

#[CoversClass(EnvVarProcessor::class)]
class EnvVarProcessorTest extends TestCase
{
private SpyLogger $logger;

protected function setUp(): void
{
$this->logger = new SpyLogger();
}

public function testMissingSecretThrowsEnvNotFoundException(): void
{
$processor = $this->createProcessor();

try {
$processor->getEnv('secretary', 'default:MISSING_KEY', fn (string $name) => $name);
$this->fail('Expected EnvNotFoundException to be thrown');
} catch (EnvNotFoundException $e) {
$this->assertStringContainsString('MISSING_KEY', $e->getMessage());
$this->assertStringContainsString('default', $e->getMessage());
$this->assertInstanceOf(SecretNotFoundException::class, $e->getPrevious());
}
}

public function testOptionalPrefixLogsWarningAndReturnsNull(): void
{
$processor = $this->createProcessor();

$value = $processor->getEnv('secretaryOptional', 'default:MISSING_KEY', fn (string $name) => $name);

$this->assertNull($value);
$this->assertCount(1, $this->logger->records);
$this->assertSame('warning', $this->logger->records[0]['level']);
$this->assertStringContainsString('MISSING_KEY', $this->logger->records[0]['message']);
}

public function testArrayOptionalPrefixLogsWarningAndReturnsNull(): void
{
$processor = $this->createProcessor();

$value = $processor->getEnv('secretaryArrayOptional', 'default:MISSING_KEY', fn (string $name) => $name);

$this->assertNull($value);
$this->assertCount(1, $this->logger->records);
$this->assertSame('warning', $this->logger->records[0]['level']);
}

public function testAllowMissingSecretsMakesStandardPrefixGraceful(): void
{
$processor = $this->createProcessor(allowMissingSecrets: true);

$value = $processor->getEnv('secretary', 'default:MISSING_KEY', fn (string $name) => $name);

$this->assertNull($value);
$this->assertCount(1, $this->logger->records);
$this->assertSame('warning', $this->logger->records[0]['level']);
}

public function testMissingSecretWithoutLoggerStillResolvesNull(): void
{
$manager = new Manager(new InMemoryAdapter([]));
$processor = new EnvVarProcessor(new \ArrayIterator(['default' => $manager]));

$value = $processor->getEnv('secretaryOptional', 'default:MISSING_KEY', fn (string $name) => $name);

$this->assertNull($value);
}

public function testExistingSecretResolvesNormally(): void
{
$processor = $this->createProcessor(['DB_PASS' => 'hunter2']);

$value = $processor->getEnv('secretary', 'default:DB_PASS', fn (string $name) => $name);

$this->assertSame('hunter2', $value);
$this->assertCount(0, $this->logger->records);
}

public function testExistingSecretResolvesNormallyWithOptionalPrefix(): void
{
$processor = $this->createProcessor(['DB_PASS' => 'hunter2']);

$value = $processor->getEnv('secretaryOptional', 'default:DB_PASS', fn (string $name) => $name);

$this->assertSame('hunter2', $value);
$this->assertCount(0, $this->logger->records);
}

public function testOtherExceptionsStillBubbleUp(): void
{
$manager = new Manager(new ThrowingAdapter());
$processor = new EnvVarProcessor(new \ArrayIterator(['default' => $manager]), $this->logger);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('adapter blew up');

$processor->getEnv('secretary', 'default:ANY_KEY', fn (string $name) => $name);
}

public function testOtherExceptionsStillBubbleUpWithOptionalPrefix(): void
{
$manager = new Manager(new ThrowingAdapter());
$processor = new EnvVarProcessor(new \ArrayIterator(['default' => $manager]), $this->logger);

$this->expectException(\RuntimeException::class);

$processor->getEnv('secretaryOptional', 'default:ANY_KEY', fn (string $name) => $name);
}

public function testGetProvidedTypesIncludesOptionalPrefixes(): void
{
$types = EnvVarProcessor::getProvidedTypes();

$this->assertSame('bool|int|float|string', $types['secretaryOptional']);
$this->assertSame('array', $types['secretaryArrayOptional']);
}

/**
* @param array<string, array|string> $secrets
*/
private function createProcessor(array $secrets = [], bool $allowMissingSecrets = false): EnvVarProcessor
{
$manager = new Manager(new InMemoryAdapter($secrets));

return new EnvVarProcessor(new \ArrayIterator(['default' => $manager]), $this->logger, $allowMissingSecrets);
}
}

class SpyLogger extends AbstractLogger
{
/** @var list<array{level: string, message: string, context: array}> */
public array $records = [];

public function log($level, \Stringable|string $message, array $context = []): void
{
$this->records[] = ['level' => (string) $level, 'message' => (string) $message, 'context' => $context];
}
}

class InMemoryAdapter extends AbstractAdapter
{
/**
* @param array<string, array|string> $secrets
*/
public function __construct(private array $secrets)
{
}

public function getSecret(string $key, ?array $options = []): Secret
{
if (!array_key_exists($key, $this->secrets)) {
throw new SecretNotFoundException($key);
}

return new Secret($key, $this->secrets[$key]);
}

public function putSecret(Secret $secret, ?array $options = []): Secret
{
return $secret;
}

public function deleteSecret(Secret $secret, ?array $options = []): void
{
}
}

class ThrowingAdapter extends AbstractAdapter
{
public function getSecret(string $key, ?array $options = []): Secret
{
throw new \RuntimeException('adapter blew up');
}

public function putSecret(Secret $secret, ?array $options = []): Secret
{
return $secret;
}

public function deleteSecret(Secret $secret, ?array $options = []): void
{
}
}
Loading
Loading