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
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public function __invoke(
$identity->commonName,
),
);
$this->transactionHelper->authorizeConsoleContext();
$this->transactionHelper->beginTransaction();
$secondFactorId = Uuid::uuid4()->toString();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ public function __invoke(
return 1;
}
try {
$this->transactionHelper->authorizeConsoleContext();
$this->transactionHelper->beginTransaction();
$output->writeln('<info>Creating a new identity</info>');
$identity = $this->bootstrapService->createIdentity(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public function __invoke(
$command->secondFactorId = $secondFactorId;
$command->yubikeyPublicId = $yubikey;

$this->transactionHelper->authorizeConsoleContext();
$this->transactionHelper->beginTransaction();

try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ public function __invoke(#[Argument(description: 'The NameID of the identity to
$output->writeln(
sprintf('<comment>Adding a %s SMS token for %s</comment>', $registrationStatus, $identity->commonName),
);
$this->transactionHelper->authorizeConsoleContext();
$this->transactionHelper->beginTransaction();
$secondFactorId = Uuid::uuid4()->toString();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public function __invoke(
$output->writeln(
sprintf('<comment>Adding a %s Yubikey token for %s</comment>', $registrationStatus, $identity->commonName),
);
$this->transactionHelper->authorizeConsoleContext();
$this->transactionHelper->beginTransaction();
$secondFactorId = Uuid::uuid4()->toString();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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,
Expand All @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,40 @@
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
{
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) {
Comment thread
kayjoosten marked this conversation as resolved.
$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();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

/**
* Copyright 2026 SURFnet bv
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

namespace Surfnet\StepupMiddleware\MiddlewareBundle\Tests\Service;

use Mockery as m;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Surfnet\StepupMiddleware\CommandHandlingBundle\EventHandling\BufferedEventBus;
use Surfnet\StepupMiddleware\CommandHandlingBundle\Pipeline\Pipeline;
use Surfnet\StepupMiddleware\MiddlewareBundle\Service\DBALConnectionHelper;
use Surfnet\StepupMiddleware\MiddlewareBundle\Service\TransactionHelper;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

class TransactionHelperTest extends TestCase
{
use MockeryPHPUnitIntegration;

private Pipeline&MockInterface $pipeline;

private BufferedEventBus&MockInterface $eventBus;

private DBALConnectionHelper&MockInterface $connection;

private TokenStorageInterface&MockInterface $tokenStorage;

private TransactionHelper $transactionHelper;

public function setUp(): void
{
$this->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();
}
}