diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapGsspSecondFactorCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapGsspSecondFactorCommand.php index 66fde3b7..d926c8d7 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapGsspSecondFactorCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapGsspSecondFactorCommand.php @@ -87,6 +87,7 @@ public function __invoke( $identity->commonName, ), ); + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); $secondFactorId = Uuid::uuid4()->toString(); diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityCommand.php index fede46a4..58d62ab6 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityCommand.php @@ -67,6 +67,7 @@ public function __invoke( return 1; } try { + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); $output->writeln('Creating a new identity'); $identity = $this->bootstrapService->createIdentity( diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityWithYubikeySecondFactorCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityWithYubikeySecondFactorCommand.php index 45d615c9..e6ce0007 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityWithYubikeySecondFactorCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapIdentityWithYubikeySecondFactorCommand.php @@ -85,6 +85,7 @@ public function __invoke( $command->secondFactorId = $secondFactorId; $command->yubikeyPublicId = $yubikey; + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); try { diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapSmsSecondFactorCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapSmsSecondFactorCommand.php index 0154146d..44390b20 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapSmsSecondFactorCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapSmsSecondFactorCommand.php @@ -68,6 +68,7 @@ public function __invoke(#[Argument(description: 'The NameID of the identity to $output->writeln( sprintf('Adding a %s SMS token for %s', $registrationStatus, $identity->commonName), ); + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); $secondFactorId = Uuid::uuid4()->toString(); diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapYubikeySecondFactorCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapYubikeySecondFactorCommand.php index 9be3326c..c2cd7b93 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapYubikeySecondFactorCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/BootstrapYubikeySecondFactorCommand.php @@ -74,6 +74,7 @@ public function __invoke( $output->writeln( sprintf('Adding a %s Yubikey token for %s', $registrationStatus, $identity->commonName), ); + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); $secondFactorId = Uuid::uuid4()->toString(); diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/MigrateSecondFactorCommand.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/MigrateSecondFactorCommand.php index f1768c0d..cbd7781f 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/MigrateSecondFactorCommand.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Console/Command/MigrateSecondFactorCommand.php @@ -64,6 +64,7 @@ public function __invoke( $targetIdentity = $this->bootstrapService->getIdentityByNameId($targetNameId); try { + $this->transactionHelper->authorizeConsoleContext(); $this->transactionHelper->beginTransaction(); // Check if target identity should be created diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Resources/config/console_commands.yml b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Resources/config/console_commands.yml index d274fc65..14b92d16 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Resources/config/console_commands.yml +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Resources/config/console_commands.yml @@ -7,7 +7,6 @@ services: arguments: - "@surfnet_stepup_middleware_command_handling.pipeline.transaction_aware_pipeline" - "@surfnet_stepup_middleware_command_handling.metadata_enricher.actor" - - "@security.token_storage" - "@surfnet_stepup_middleware_api.repository.identity" - "@surfnet_stepup_middleware_api.repository.unverified_second_factor" - "@surfnet_stepup_middleware_api.repository.verified_second_factor" @@ -19,6 +18,7 @@ services: - "@surfnet_stepup_middleware_command_handling.pipeline.transaction_aware_pipeline" - "@surfnet_stepup_middleware_command_handling.event_bus.buffered" - "@surfnet_stepup_middleware_middleware.dbal_connection_helper" + - "@security.token_storage" Surfnet\StepupMiddleware\MiddlewareBundle\Console\Command\ReplaySpecificEventsCommand: arguments: diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/BootstrapCommandService.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/BootstrapCommandService.php index 18f2512e..2a8e27fb 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/BootstrapCommandService.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/BootstrapCommandService.php @@ -41,8 +41,6 @@ use Surfnet\StepupMiddleware\CommandHandlingBundle\Identity\Command\VetSecondFactorCommand; use Surfnet\StepupMiddleware\CommandHandlingBundle\Pipeline\Pipeline; use Surfnet\StepupMiddleware\MiddlewareBundle\Exception\InvalidArgumentException; -use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; /** * @SuppressWarnings("PHPMD.CouplingBetweenObjects") @@ -58,7 +56,6 @@ class BootstrapCommandService public function __construct( private readonly Pipeline $pipeline, private readonly MetadataEnricher $enricher, - private readonly TokenStorageInterface $tokenStorage, private readonly IdentityRepository $identityRepository, private readonly UnverifiedSecondFactorRepository $unverifiedSecondFactorRepository, private readonly VerifiedSecondFactorRepository $verifiedSecondFactorRepository, @@ -67,11 +64,6 @@ public function __construct( ) { } - public function setToken(TokenInterface $token): void - { - $this->tokenStorage->setToken($token); - } - public function validRegistrationStatus(string $registrationStatus): void { if (!in_array($registrationStatus, $this->validRegistrationStatuses)) { diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/TransactionHelper.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/TransactionHelper.php index 9764ce1e..4f62e2ba 100644 --- a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/TransactionHelper.php +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Service/TransactionHelper.php @@ -21,6 +21,9 @@ use Surfnet\StepupMiddleware\CommandHandlingBundle\Command\AbstractCommand; use Surfnet\StepupMiddleware\CommandHandlingBundle\EventHandling\BufferedEventBus; use Surfnet\StepupMiddleware\CommandHandlingBundle\Pipeline\Pipeline; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\User\InMemoryUser; final readonly class TransactionHelper { @@ -28,9 +31,30 @@ public function __construct( private Pipeline $pipeline, private BufferedEventBus $eventBus, private DBALConnectionHelper $connection, + private TokenStorageInterface $tokenStorage, ) { } + /** + * Console commands run without an authenticated security token. Commands processed through the + * pipeline (including ones triggered internally by event processors, e.g. institution + * configuration bootstrapping) are checked by the AuthorizingStage, so bootstrap console commands + * that need to authorize such internally-triggered commands must call this explicitly. Limited to + * ROLE_SS, ROLE_RA and ROLE_MANAGEMENT: the identity/vetting/configuration commands the bootstrap + * console commands dispatch through this pipeline. ROLE_DEPROVISION is deliberately excluded, + * nothing in these flows needs it. Guarded to CLI only, so this can never grant privileges to an + * HTTP request even if called from a context that shouldn't. + */ + public function authorizeConsoleContext(): void + { + if (PHP_SAPI === 'cli' && $this->tokenStorage->getToken() === null) { + $roles = ['ROLE_SS', 'ROLE_RA', 'ROLE_MANAGEMENT']; + $this->tokenStorage->setToken( + new UsernamePasswordToken(new InMemoryUser('console', null, $roles), 'api', $roles), + ); + } + } + public function beginTransaction(): void { $this->connection->beginTransaction(); diff --git a/src/Surfnet/StepupMiddleware/MiddlewareBundle/Tests/Service/TransactionHelperTest.php b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Tests/Service/TransactionHelperTest.php new file mode 100644 index 00000000..19c9fe62 --- /dev/null +++ b/src/Surfnet/StepupMiddleware/MiddlewareBundle/Tests/Service/TransactionHelperTest.php @@ -0,0 +1,99 @@ +pipeline = m::mock(Pipeline::class); + $this->eventBus = m::mock(BufferedEventBus::class); + $this->connection = m::mock(DBALConnectionHelper::class); + $this->tokenStorage = m::mock(TokenStorageInterface::class); + + $this->transactionHelper = new TransactionHelper( + $this->pipeline, + $this->eventBus, + $this->connection, + $this->tokenStorage, + ); + } + + #[Test] + public function authorize_console_context_sets_a_console_token_with_the_bootstrap_roles_when_none_is_present(): void + { + $this->tokenStorage->shouldReceive('getToken')->once()->andReturn(null); + $this->tokenStorage->shouldReceive('setToken') + ->once() + ->with(m::on(function (TokenInterface $token): bool { + $roles = $token->getRoleNames(); + sort($roles); + + return $roles === ['ROLE_MANAGEMENT', 'ROLE_RA', 'ROLE_SS']; + })); + + $this->transactionHelper->authorizeConsoleContext(); + } + + #[Test] + public function authorize_console_context_does_not_overwrite_an_existing_token(): void + { + $existingToken = m::mock(TokenInterface::class); + + $this->tokenStorage->shouldReceive('getToken')->once()->andReturn($existingToken); + $this->tokenStorage->shouldNotReceive('setToken'); + + $this->transactionHelper->authorizeConsoleContext(); + } + + #[Test] + public function begin_transaction_does_not_touch_the_token_storage(): void + { + $this->tokenStorage->shouldNotReceive('getToken'); + $this->tokenStorage->shouldNotReceive('setToken'); + + $this->connection->shouldReceive('beginTransaction')->once(); + + $this->transactionHelper->beginTransaction(); + } +}