diff --git a/libs/api-bundle/README.md b/libs/api-bundle/README.md index f32746fd1..b438d34dd 100644 --- a/libs/api-bundle/README.md +++ b/libs/api-bundle/README.md @@ -145,6 +145,44 @@ which the bundle requires directly, so no extra installation is needed. > If you forget to install appropriate client, you will get exception like > `Service "Keboola\ApiBundle\Attribute\ApplicationTokenAuth" not found: the container inside "Symfony\Component\DependencyInjection\Argument\ServiceLocator" is a smaller service locator` +## Storage API client + +When `#[StorageApiTokenAuth]` is enabled, type-hint +`Keboola\ApiBundle\StorageApiClient\StorageClientApiFactory` on a controller argument; the bundle +injects a factory already bound to the current request and the resolved `StorageApiToken`. Unlike the +header-based `StorageClientRequestFactory`, it uses the token resolved by the authenticator, so it +works for programmatic (`kbc_pat_*` / `kbc_at_*`) tokens too. + +The base options are preconfigured by the bundle: the Connection (Storage API) URL from the +`ServiceClient`, the shared logger, and the configured `app_name` as user agent. The run id comes +from the request's `X-KBC-RunId` header; branch / backend come from an optional per-call +`ClientOptions`. + +```php +#[StorageApiTokenAuth] +public function __invoke(StorageClientApiFactory $storage) +{ + $client = $storage->createClientWrapper()->getBasicClient(); + + // branch-aware / per-call overrides: + // $wrapper = $storage->createClientWrapper(new ClientOptions(branchId: $branchId)); +} +``` + +On a controller that may authenticate through another attribute too (e.g. +`#[ApplicationTokenAuth]` alongside `#[StorageApiTokenAuth]` — they are OR'd), the request can be +authenticated without a Storage token. Make the argument nullable to receive `null` in that case; +a required (non-nullable) argument throws when no Storage token is present: + +```php +#[ApplicationTokenAuth(scopes: ['something:manage'])] +#[StorageApiTokenAuth] +public function __invoke(?StorageClientApiFactory $storage) +{ + $client = $storage?->createClientWrapper()->getBasicClient(); // null when authenticated via Manage token +} +``` + ## Testing controllers `Keboola\ApiBundle\Test\AuthenticatorTestTrait` stubs the authenticators in functional diff --git a/libs/api-bundle/src/DependencyInjection/KeboolaApiExtension.php b/libs/api-bundle/src/DependencyInjection/KeboolaApiExtension.php index 2f4cb8c93..159331654 100644 --- a/libs/api-bundle/src/DependencyInjection/KeboolaApiExtension.php +++ b/libs/api-bundle/src/DependencyInjection/KeboolaApiExtension.php @@ -10,15 +10,20 @@ use Keboola\ApiBundle\Security\ApplicationToken\ManageApiClientFactory; use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenAuthenticator; use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenFactory; +use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactory; +use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactoryResolver; use Keboola\ManageApi\Client as ManageApiClient; use Keboola\ServiceClient\ServiceClient; use Keboola\ServiceClient\ServiceDnsType; +use Keboola\StorageApiBranch\Factory\ClientOptions; use Keboola\StorageApiBranch\Factory\StorageClientRequestFactory; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; class KeboolaApiExtension extends Extension { @@ -92,6 +97,21 @@ private function setupStorageApiAuthenticator( ->setArgument('$tokenFactory', new Reference(StorageApiTokenFactory::class)) ; $authenticators[StorageApiTokenAuth::class] = new Reference(StorageApiTokenAuthenticator::class); + + // StorageClientApiFactory controller-argument value resolver + $connectionUrl = (new Definition()) + ->setFactory([new Reference(ServiceClient::class), 'getConnectionServiceUrl']); + + $baseClientOptions = (new Definition(ClientOptions::class)) + ->setArgument('$url', $connectionUrl) + ->setArgument('$logger', new Reference('logger')) + ->setArgument('$userAgent', $config['app_name']); + + $container->register(StorageClientApiFactoryResolver::class) + ->setArgument('$baseClientOptions', $baseClientOptions) + ->setArgument('$tokenStorage', new Reference(TokenStorageInterface::class)) + ->addTag('controller.argument_value_resolver') + ; } private function setupApplicationTokenAuthenticator( diff --git a/libs/api-bundle/src/StorageApiClient/StorageClientApiFactory.php b/libs/api-bundle/src/StorageApiClient/StorageClientApiFactory.php new file mode 100644 index 000000000..51d41d868 --- /dev/null +++ b/libs/api-bundle/src/StorageApiClient/StorageClientApiFactory.php @@ -0,0 +1,54 @@ +baseClientOptions; + if ($clientOptions !== null) { + $options->addValuesFrom($clientOptions); + } + + $options->setToken($this->token->getTokenValue()); + $options->setAuthType(AuthType::STORAGE_TOKEN); + $options->setRunId($this->getRunId($options)); + + return new ClientWrapper($options); + } + + private function getRunId(ClientOptions $options): string + { + $runId = (string) $this->request->headers->get(self::RUN_ID_HEADER); + + if ($runId === '') { + $runIdGenerator = $options->getRunIdGenerator(); + if ($runIdGenerator !== null) { + $runId = $runIdGenerator($options); + assert(is_string($runId)); + } else { + $runId = uniqid('run-'); + } + } + + return $runId; + } +} diff --git a/libs/api-bundle/src/StorageApiClient/StorageClientApiFactoryResolver.php b/libs/api-bundle/src/StorageApiClient/StorageClientApiFactoryResolver.php new file mode 100644 index 000000000..e25f783ed --- /dev/null +++ b/libs/api-bundle/src/StorageApiClient/StorageClientApiFactoryResolver.php @@ -0,0 +1,44 @@ +getType() !== StorageClientApiFactory::class) { + return []; + } + + $user = $this->tokenStorage->getToken()?->getUser(); + if ($user instanceof StorageApiToken) { + return [new StorageClientApiFactory($this->baseClientOptions, $request, $user)]; + } + + if ($argument->isNullable()) { + return [null]; + } + + throw new RuntimeException(sprintf( + 'Cannot resolve argument "$%s": no authenticated Storage API token. Guard the ' + . 'controller with #[StorageApiTokenAuth], or make the argument nullable to allow null.', + $argument->getName(), + )); + } +} diff --git a/libs/api-bundle/tests/DependencyInjection/KeboolaApiExtensionTest.php b/libs/api-bundle/tests/DependencyInjection/KeboolaApiExtensionTest.php index 7c2c38e5f..367271db9 100644 --- a/libs/api-bundle/tests/DependencyInjection/KeboolaApiExtensionTest.php +++ b/libs/api-bundle/tests/DependencyInjection/KeboolaApiExtensionTest.php @@ -8,10 +8,15 @@ use Keboola\ApiBundle\Security\ApplicationToken\ManageApiClientFactory; use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenAuthenticator; use Keboola\ApiBundle\Security\StorageApiToken\StorageApiTokenFactory; +use Keboola\ApiBundle\StorageApiClient\StorageClientApiFactoryResolver; use Keboola\ManageApi\Client as ManageApiClient; +use Keboola\ServiceClient\ServiceClient; +use Keboola\StorageApiBranch\Factory\ClientOptions; use PHPUnit\Framework\TestCase; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; class KeboolaApiExtensionTest extends TestCase { @@ -52,6 +57,44 @@ public function testStorageApiServicesAreRegistered(): void ); } + public function testStorageClientApiFactoryResolverIsRegisteredWithBaseOptionsAndTagged(): void + { + $container = $this->buildContainer([['app_name' => 'storage-test-app']]); + + self::assertTrue( + $container->hasDefinition(StorageClientApiFactoryResolver::class), + 'StorageClientApiFactoryResolver must be registered', + ); + + $definition = $container->getDefinition(StorageClientApiFactoryResolver::class); + self::assertArrayHasKey('controller.argument_value_resolver', $definition->getTags()); + + $baseClientOptions = $definition->getArgument('$baseClientOptions'); + self::assertInstanceOf(Definition::class, $baseClientOptions); + self::assertSame(ClientOptions::class, $baseClientOptions->getClass()); + + // userAgent is the configured app name + self::assertSame('storage-test-app', $baseClientOptions->getArgument('$userAgent')); + + // logger is the shared @logger service + $logger = $baseClientOptions->getArgument('$logger'); + self::assertInstanceOf(Reference::class, $logger); + self::assertSame('logger', (string) $logger); + + // url is resolved at runtime from ServiceClient::getConnectionServiceUrl() + $url = $baseClientOptions->getArgument('$url'); + self::assertInstanceOf(Definition::class, $url); + $urlFactory = $url->getFactory(); + self::assertIsArray($urlFactory); + self::assertInstanceOf(Reference::class, $urlFactory[0]); + self::assertSame(ServiceClient::class, (string) $urlFactory[0]); + self::assertSame('getConnectionServiceUrl', $urlFactory[1]); + + $tokenStorage = $definition->getArgument('$tokenStorage'); + self::assertInstanceOf(Reference::class, $tokenStorage); + self::assertSame(TokenStorageInterface::class, (string) $tokenStorage); + } + // ------------------------------------------------------------------------- // Resolver client wiring // ------------------------------------------------------------------------- diff --git a/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryResolverTest.php b/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryResolverTest.php new file mode 100644 index 000000000..288b4dc16 --- /dev/null +++ b/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryResolverTest.php @@ -0,0 +1,137 @@ +createArgumentMetadata($controller) as $metadata) { + if ($metadata->getName() === $arg) { + return $metadata; + } + } + + self::fail(sprintf('Controller has no argument "%s"', $arg)); + } + + public function testResolvesNothingForOtherArgumentTypes(): void + { + $controller = new class { + public function __invoke(string $foo): void + { + } + }; + + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects(self::never())->method('getToken'); + + $result = $this->resolver($tokenStorage)->resolve(new Request(), $this->metadataFor($controller, 'foo')); + + self::assertSame([], [...$result]); + } + + public function testResolvesBoundFactoryBuildingClientFromSecurityToken(): void + { + $controller = new class { + public function __invoke(StorageClientApiFactory $storage): void + { + } + }; + + $storageToken = new SecurityStorageApiToken([], 'resolved-token'); + $securityToken = $this->createMock(TokenInterface::class); + $securityToken->expects(self::once())->method('getUser')->willReturn($storageToken); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects(self::once())->method('getToken')->willReturn($securityToken); + + $result = [...$this->resolver($tokenStorage)->resolve( + new Request(), + $this->metadataFor($controller, 'storage'), + )]; + + self::assertCount(1, $result); + self::assertInstanceOf(StorageClientApiFactory::class, $result[0]); + self::assertSame('resolved-token', $result[0]->createClientWrapper()->getClientOptionsReadOnly()->getToken()); + } + + public function testThrowsForRequiredArgumentWhenNoStorageApiToken(): void + { + $controller = new class { + public function __invoke(StorageClientApiFactory $storage): void + { + } + }; + + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects(self::once())->method('getToken')->willReturn(null); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('#[StorageApiTokenAuth]'); + + [...$this->resolver($tokenStorage)->resolve(new Request(), $this->metadataFor($controller, 'storage'))]; + } + + public function testResolvesNullForNullableArgumentWhenNoStorageApiToken(): void + { + // Dual-guarded controller (e.g. also #[ApplicationTokenAuth]) authenticated through the + // other path: the security user is not a StorageApiToken, so a nullable argument gets null. + $controller = new class { + public function __invoke(?StorageClientApiFactory $storage): void + { + } + }; + + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects(self::once())->method('getToken')->willReturn(null); + + $result = [...$this->resolver($tokenStorage)->resolve( + new Request(), + $this->metadataFor($controller, 'storage'), + )]; + + self::assertSame([null], $result); + } + + public function testResolvesFactoryForNullableArgumentWhenStorageApiTokenPresent(): void + { + $controller = new class { + public function __invoke(?StorageClientApiFactory $storage): void + { + } + }; + + $storageToken = new SecurityStorageApiToken([], 'resolved-token'); + $securityToken = $this->createMock(TokenInterface::class); + $securityToken->expects(self::once())->method('getUser')->willReturn($storageToken); + $tokenStorage = $this->createMock(TokenStorageInterface::class); + $tokenStorage->expects(self::once())->method('getToken')->willReturn($securityToken); + + $result = [...$this->resolver($tokenStorage)->resolve( + new Request(), + $this->metadataFor($controller, 'storage'), + )]; + + self::assertCount(1, $result); + self::assertInstanceOf(StorageClientApiFactory::class, $result[0]); + } +} diff --git a/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryTest.php b/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryTest.php new file mode 100644 index 000000000..38a995df9 --- /dev/null +++ b/libs/api-bundle/tests/StorageApiClient/StorageClientApiFactoryTest.php @@ -0,0 +1,82 @@ +createClientWrapper()->getClientOptionsReadOnly(); + + self::assertSame('bound-token', $options->getToken()); + self::assertSame(AuthType::STORAGE_TOKEN, $options->getAuthType()); + } + + public function testRunIdTakenFromRequestHeaderWhenPresent(): void + { + $request = new Request([], [], [], [], [], ['HTTP_X_KBC_RUNID' => '42']); + $factory = self::factory(new ClientOptions('https://connection.test'), $request); + + self::assertSame('42', $factory->createClientWrapper()->getClientOptionsReadOnly()->getRunId()); + } + + public function testRunIdFallsBackToGeneratedValueWhenHeaderMissing(): void + { + $factory = self::factory(new ClientOptions('https://connection.test'), new Request()); + + self::assertStringStartsWith( + 'run-', + (string) $factory->createClientWrapper()->getClientOptionsReadOnly()->getRunId(), + ); + } + + public function testRunIdGeneratorUsedWhenHeaderMissing(): void + { + $baseOptions = new ClientOptions('https://connection.test'); + $baseOptions->setRunIdGenerator(fn (ClientOptions $o): string => 'gen-' . $o->getUrl()); + $factory = self::factory($baseOptions, new Request()); + + self::assertSame( + 'gen-https://connection.test', + $factory->createClientWrapper()->getClientOptionsReadOnly()->getRunId(), + ); + } + + public function testPerCallClientOptionsAreMergedOverBase(): void + { + $factory = self::factory(new ClientOptions('https://connection.test'), new Request()); + + $options = $factory->createClientWrapper(new ClientOptions(branchId: '777'))->getClientOptionsReadOnly(); + + self::assertSame('777', $options->getBranchId()); + self::assertSame('https://connection.test', $options->getUrl()); + self::assertSame('bound-token', $options->getToken()); + } + + public function testBaseOptionsAreNotMutated(): void + { + $baseOptions = new ClientOptions('https://connection.test'); + $factory = self::factory($baseOptions, new Request()); + + $factory->createClientWrapper(); + + self::assertNull($baseOptions->getToken()); + self::assertNull($baseOptions->getAuthType()); + } +} diff --git a/libs/api-bundle/tests/config.yaml b/libs/api-bundle/tests/config.yaml index 2ad80a7be..a521137b0 100644 --- a/libs/api-bundle/tests/config.yaml +++ b/libs/api-bundle/tests/config.yaml @@ -12,3 +12,11 @@ monolog: type: stream path: php://stdout level: debug + +services: + # This minimal kernel has no SecurityBundle, so TokenStorageInterface would be missing and the + # tagged StorageClientApiFactoryResolver could not compile. Provide a plain TokenStorage (public so a + # test can seed a token). Real apps get this from SecurityBundle. + Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface: + class: Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorage + public: true