From 6ab2c39b16318b0e83828b098f1460af6ffd1b74 Mon Sep 17 00:00:00 2001 From: EddyLogipro Date: Tue, 23 Jun 2026 14:35:17 +0200 Subject: [PATCH] K#3563 one token used --- CHANGELOG.md | 54 ++----------- composer.json | 2 +- .../Console/Command/CallbackSendCommand.php | 3 +- .../Command/ConsumptionPushCommand.php | 3 +- .../Command/OrchestrationSimulateCommand.php | 5 +- src/Bridge/Console/Command/ServeCommand.php | 6 +- .../Command/StatePushSampleCommand.php | 5 +- .../Controller/OperationalStateController.php | 28 ++----- .../OrchestrationCommandController.php | 25 ++---- .../DependencyInjection/Configuration.php | 9 ++- .../ReceiverRoutePaths.php | 28 +++++++ .../Factory/AmApiClientConfigFactory.php | 3 +- .../Symfony/Resources/config/services.yaml | 4 +- src/Core/Cli/ReceptacleBootstrapFactory.php | 6 +- src/Core/Cli/ReceptacleHttpKernel.php | 33 ++------ src/Core/Dto/OrchestrationCommand.php | 15 ---- src/Core/Http/AmApiClient.php | 25 +----- src/Core/Http/AmApiClientConfig.php | 20 ++--- .../Http/ApplicationTokenAuthenticator.php | 76 +++++++++++++++++++ .../HttpOrchestrationConnectivityProbe.php | 2 +- src/Core/Tenant/FileTenantWorkspace.php | 18 ----- .../Integration/ReceptacleHttpKernelTest.php | 7 +- .../ConfigurationParametersTest.php | 8 +- tests/Unit/Http/AmApiClientTest.php | 10 +-- .../ApplicationTokenAuthenticatorTest.php | 37 +++++++++ 25 files changed, 211 insertions(+), 221 deletions(-) create mode 100644 src/Core/Http/ApplicationTokenAuthenticator.php create mode 100644 tests/Unit/Http/ApplicationTokenAuthenticatorTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index d62af1c..dc192ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,55 +1,13 @@ # Changelog -## 0.0.13 — 2026-06-22 +## 0.0.14 — 2026-06-23 -### Added - -- OpenAPI `receptacle-v1` : propriétés recommandées sous `metadata` pour `CREATE_INSTANCE` billing (`subscriptionId`, `formulaId`, `formulaName`). -- Fixture `orchestration-command-create-enriched.json` : exemple metadata billing AM. - -### Changed +### Breaking -- Documentation uniquement ; le DTO `OrchestrationCommand` accepte déjà `metadata` libre (`additionalProperties`). - -## 0.0.12 — 2026-06-22 +- Configuration : un seul paramètre `application_token` / env `AM_DRIVER_APPLICATION_TOKEN` (les 4 clés legacy restent acceptées en alias de migration). +- HTTP : uniquement `X-AM-Application-Token` (entrée et sortie vers AM). +- Retrait de `instanceIntegrationToken` dans `OrchestrationCommand`. ### Added -- **`create_instance_execution`** (`sync` | `deferred`, défaut `sync`) : en mode `deferred`, `CREATE_INSTANCE` répond HTTP 200 immédiatement ; l’hôte dispatche l’exécution via `DeferredCreateInstanceDispatcherInterface` ; le callback AM part après `executeCreateInstance()`. -- **`DeferredCreateInstanceDispatcherInterface`**, **`DeferredCreateInstanceWorker`**, commande console **`am-driver:execute-deferred-create-instance`**. -- **`OrchestrationCommandLifecycleStoreInterface`** : suivi des commandes en cours (`idempotency-in-progress/`) pour éviter un double provisionnement pendant le traitement async. - -### Changed - -- **`OrchestrationCommandProcessor::executeCreateInstance()`** : méthode publique pour l’exécution différée (handler + idempotence + callback). - -## 0.0.10 — 2026-06-11 - -### Added - -- **`OrchestrationCommand`** : champs enrichis optionnels pour `CREATE_INSTANCE` uniquement : - - `name` (getter `name()`) - - `credentials.login` (getter `credentialsLogin()`) - - `metadata` (getter `metadata()`, défaut `[]`) -- Validation : `name`, `credentials`, `metadata` interdits hors `CREATE_INSTANCE` ; clé legacy `shortname` rejetée. - -## 0.0.9 — 2026-06-11 - -### Breaking changes - -- **Correlation key** : `tenantId` remplacé par `instanceId` partout dans le bundle (DTO, snapshots, état opérationnel, consommation, espace disque local). -- **`OrchestrationCommand`** : `tenantId` supprimé du corps ; `correlationId` devient optionnel. Clés requises : `operation`, `appId`, `instanceId`, `idempotencyKey`, `occurredAt`. -- **`ConsumptionWebhookEvent`** : champ JSON `tenantId` → `instanceId`. -- **`ManagedInstanceResourceSnapshot`** : champ JSON `tenantId` → `instanceId` ; getter `instanceId()`. -- **Stores / managers** : paramètres et méthodes `findByTenantId()` → `findByInstanceId()` ; `load($instanceId)`, `save($instanceId, …)`, etc. -- **`InstanceOperationalStateValidator`** : exige `instance.instanceId` ; garde-fou `expected_tenant_id` supprimé (utiliser `expected_instance_id`). -- **CLI** : `--tenant-id` → `--instance-id` (`consumption:push`, `orchestration:simulate`). - -### Migration - -- Renommer les fichiers locaux `snapshots/{tenantId}.json` → `snapshots/{instanceId}.json` (et équivalents operational-state / receipts). -- Mettre à jour les payloads AM et les appels `pushResourceConsumption($instanceId, …)`. - -## 0.0.8 - -Versions antérieures : voir tags Git. +- `ApplicationTokenAuthenticator` partagé (contrôleurs Symfony + `ReceptacleHttpKernel`). diff --git a/composer.json b/composer.json index 5fedc30..da0753d 100644 --- a/composer.json +++ b/composer.json @@ -2,7 +2,7 @@ "name": "application-manager-tools/am-driver", "description": "Symfony bundle and framework-agnostic core to integrate managed applications with Application Manager (orchestration, consumption, operational state).", "type": "symfony-bundle", - "version": "0.0.13", + "version": "0.0.14", "license": "MIT", "keywords": ["application-manager", "orchestration", "symfony-bundle"], "require": { diff --git a/src/Bridge/Console/Command/CallbackSendCommand.php b/src/Bridge/Console/Command/CallbackSendCommand.php index 3429a34..591c4fa 100644 --- a/src/Bridge/Console/Command/CallbackSendCommand.php +++ b/src/Bridge/Console/Command/CallbackSendCommand.php @@ -23,7 +23,7 @@ protected function configure(): void ->setName('callback:send') ->setDescription('Send an orchestration callback to Application Manager') ->addOption('am-url', null, InputOption::VALUE_REQUIRED, 'AM base URL') - ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-Orchestration-Callback-Token') + ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-AM-Application-Token') ->addOption('idempotency-key', null, InputOption::VALUE_REQUIRED, 'idempotencyKey') ->addOption('status', null, InputOption::VALUE_REQUIRED, 'SUCCEEDED|FAILED|RETRYABLE_FAILURE', 'SUCCEEDED') ->addOption('message', null, InputOption::VALUE_OPTIONAL, 'Optional message') @@ -43,7 +43,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $client = new AmApiClient(HttpClient::create(), new AmApiClientConfig( (string) $input->getOption('am-url'), - 'unused-consumption-token', (string) $input->getOption('token'), )); diff --git a/src/Bridge/Console/Command/ConsumptionPushCommand.php b/src/Bridge/Console/Command/ConsumptionPushCommand.php index baf55d4..5fe5ed5 100644 --- a/src/Bridge/Console/Command/ConsumptionPushCommand.php +++ b/src/Bridge/Console/Command/ConsumptionPushCommand.php @@ -30,7 +30,7 @@ protected function configure(): void ->addOption('source', null, InputOption::VALUE_REQUIRED, 'source', 'am-driver-cli') ->addOption('occurred-at', null, InputOption::VALUE_REQUIRED, 'ISO8601 occurredAt') ->addOption('am-url', null, InputOption::VALUE_REQUIRED, 'AM base URL') - ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-Consumption-Webhook-Token'); + ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-AM-Application-Token'); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -55,7 +55,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $client = new AmApiClient(HttpClient::create(), new AmApiClientConfig( (string) $input->getOption('am-url'), (string) $input->getOption('token'), - 'unused-callback-token', )); $event = new ConsumptionWebhookEvent( diff --git a/src/Bridge/Console/Command/OrchestrationSimulateCommand.php b/src/Bridge/Console/Command/OrchestrationSimulateCommand.php index 831f67a..af2bce9 100644 --- a/src/Bridge/Console/Command/OrchestrationSimulateCommand.php +++ b/src/Bridge/Console/Command/OrchestrationSimulateCommand.php @@ -4,6 +4,7 @@ namespace ApplicationManagerTools\AmDriver\Bridge\Console\Command; +use ApplicationManagerTools\AmDriver\Core\Http\ApplicationTokenAuthenticator; use DateTimeImmutable; use DateTimeInterface; use DateTimeZone; @@ -25,7 +26,7 @@ protected function configure(): void ->addArgument('operation', InputArgument::OPTIONAL, 'create|stop|start', 'create') ->addOption('base-url', null, InputOption::VALUE_REQUIRED, 'Receptacle base URL', 'http://127.0.0.1:8099') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Command path', '/internal/am/orchestration/commands') - ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-Orchestration-Command-Token', 'dev-command-token') + ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-AM-Application-Token', 'dev-application-token') ->addOption('instance-id', null, InputOption::VALUE_REQUIRED, 'instanceId', 'am_ins_10000000-0000-4000-8000-000000000001') ->addOption('app-id', null, InputOption::VALUE_REQUIRED, 'appId', 'am_app_10000000-0000-4000-8000-000000000001') ->addOption('target-id', null, InputOption::VALUE_REQUIRED, 'targetId', 'local-receptacle'); @@ -63,7 +64,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $response = $client->request('POST', $url, [ 'headers' => [ 'Content-Type' => 'application/json', - 'X-Orchestration-Command-Token' => (string) $input->getOption('token'), + ApplicationTokenAuthenticator::HEADER_NAME => (string) $input->getOption('token'), ], 'json' => $payload, ]); diff --git a/src/Bridge/Console/Command/ServeCommand.php b/src/Bridge/Console/Command/ServeCommand.php index 82bf748..cbffc20 100644 --- a/src/Bridge/Console/Command/ServeCommand.php +++ b/src/Bridge/Console/Command/ServeCommand.php @@ -21,8 +21,7 @@ protected function configure(): void ->setDescription('Start the managed-app receptacle HTTP server (built-in PHP server)') ->addOption('port', null, InputOption::VALUE_REQUIRED, 'Listen port', '8099') ->addOption('host', null, InputOption::VALUE_REQUIRED, 'Listen host', '127.0.0.1') - ->addOption('token-command', null, InputOption::VALUE_REQUIRED, 'X-Orchestration-Command-Token', 'dev-command-token') - ->addOption('token-state', null, InputOption::VALUE_REQUIRED, 'X-Instance-Operational-State-Token', 'dev-state-token') + ->addOption('application-token', null, InputOption::VALUE_REQUIRED, 'X-AM-Application-Token', 'dev-application-token') ->addOption('data-dir', null, InputOption::VALUE_REQUIRED, 'Persistence directory', sys_get_temp_dir().'/am-driver-receptacle') ->addOption('source', null, InputOption::VALUE_REQUIRED, 'Consumption source identifier', 'am-driver-receptacle'); } @@ -47,8 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'source' => $source, 'orchestration_path' => $orchestrationPath, 'operational_state_path' => $statePath, - 'token_command' => (string) $input->getOption('token-command'), - 'token_state' => (string) $input->getOption('token-state'), + 'application_token' => (string) $input->getOption('application-token'), ]); $router = __DIR__.'/../../../Core/Cli/ReceptacleServerRouter.php'; diff --git a/src/Bridge/Console/Command/StatePushSampleCommand.php b/src/Bridge/Console/Command/StatePushSampleCommand.php index e56fe6e..cedf35b 100644 --- a/src/Bridge/Console/Command/StatePushSampleCommand.php +++ b/src/Bridge/Console/Command/StatePushSampleCommand.php @@ -4,6 +4,7 @@ namespace ApplicationManagerTools\AmDriver\Bridge\Console\Command; +use ApplicationManagerTools\AmDriver\Core\Http\ApplicationTokenAuthenticator; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -20,7 +21,7 @@ protected function configure(): void ->setDescription('POST a sample instance-operational-state.v1 document to a receptacle') ->addOption('base-url', null, InputOption::VALUE_REQUIRED, 'Receptacle base URL', 'http://127.0.0.1:8099') ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Operational state path', '/internal/am/instance-operational-state') - ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-Instance-Operational-State-Token', 'dev-state-token') + ->addOption('token', null, InputOption::VALUE_REQUIRED, 'X-AM-Application-Token', 'dev-application-token') ->addOption('fixture', null, InputOption::VALUE_REQUIRED, 'Fixture file path'); } @@ -47,7 +48,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $response = HttpClient::create()->request('POST', $url, [ 'headers' => [ 'Content-Type' => 'application/json', - 'X-Instance-Operational-State-Token' => (string) $input->getOption('token'), + ApplicationTokenAuthenticator::HEADER_NAME => (string) $input->getOption('token'), ], 'body' => $body, ]); diff --git a/src/Bridge/Symfony/Controller/OperationalStateController.php b/src/Bridge/Symfony/Controller/OperationalStateController.php index b3bd5e1..42bd62b 100644 --- a/src/Bridge/Symfony/Controller/OperationalStateController.php +++ b/src/Bridge/Symfony/Controller/OperationalStateController.php @@ -5,6 +5,7 @@ namespace ApplicationManagerTools\AmDriver\Bridge\Symfony\Controller; use ApplicationManagerTools\AmDriver\Core\Exception\ValidationException; +use ApplicationManagerTools\AmDriver\Core\Http\ApplicationTokenAuthenticator; use ApplicationManagerTools\AmDriver\Core\OperationalState\OperationalStateProcessor; use ApplicationManagerTools\AmDriver\Core\Validation\JsonPayloadValidator; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,24 +20,23 @@ final class OperationalStateController /** @var OperationalStateProcessor */ private $processor; - /** @var string */ - private $expectedToken; + /** @var ApplicationTokenAuthenticator */ + private $authenticator; - public function __construct(OperationalStateProcessor $processor, string $operationalStateToken) + public function __construct(OperationalStateProcessor $processor, string $applicationToken) { $this->processor = $processor; - $this->expectedToken = $operationalStateToken; + $this->authenticator = new ApplicationTokenAuthenticator($applicationToken); } public function __invoke(Request $request): JsonResponse { - if (!$this->tokenMatches($request)) { - return new JsonResponse(['error' => 'Invalid operational state token'], Response::HTTP_UNAUTHORIZED); + if (!$this->authenticator->matchesRequest($request)) { + return new JsonResponse(['error' => 'Invalid application token'], Response::HTTP_UNAUTHORIZED); } try { - $document = JsonPayloadValidator::parseJsonObject((string) $request->getContent()); - $this->processor->process($document); + $this->processor->process(JsonPayloadValidator::parseJsonObject((string) $request->getContent())); return new JsonResponse(['accepted' => true], Response::HTTP_OK); } catch (ValidationException $e) { @@ -45,16 +45,4 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(['error' => 'Transient error'], Response::HTTP_INTERNAL_SERVER_ERROR); } } - - private function tokenMatches(Request $request): bool - { - foreach (['X-AM-Application-Token', 'X-Instance-Operational-State-Token'] as $header) { - $token = trim((string) $request->headers->get($header, '')); - if ('' !== $token && hash_equals($this->expectedToken, $token)) { - return true; - } - } - - return false; - } } diff --git a/src/Bridge/Symfony/Controller/OrchestrationCommandController.php b/src/Bridge/Symfony/Controller/OrchestrationCommandController.php index f2ed521..dfabc26 100644 --- a/src/Bridge/Symfony/Controller/OrchestrationCommandController.php +++ b/src/Bridge/Symfony/Controller/OrchestrationCommandController.php @@ -6,6 +6,7 @@ use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; use ApplicationManagerTools\AmDriver\Core\Exception\ValidationException; +use ApplicationManagerTools\AmDriver\Core\Http\ApplicationTokenAuthenticator; use ApplicationManagerTools\AmDriver\Core\Orchestration\OrchestrationCommandProcessor; use ApplicationManagerTools\AmDriver\Core\Validation\JsonPayloadValidator; use Symfony\Component\HttpFoundation\JsonResponse; @@ -19,19 +20,19 @@ final class OrchestrationCommandController /** @var OrchestrationCommandProcessor */ private $processor; - /** @var string */ - private $expectedToken; + /** @var ApplicationTokenAuthenticator */ + private $authenticator; - public function __construct(OrchestrationCommandProcessor $processor, string $orchestrationCommandToken) + public function __construct(OrchestrationCommandProcessor $processor, string $applicationToken) { $this->processor = $processor; - $this->expectedToken = $orchestrationCommandToken; + $this->authenticator = new ApplicationTokenAuthenticator($applicationToken); } public function __invoke(Request $request): JsonResponse { - if (!$this->tokenMatches($request)) { - return new JsonResponse(['error' => 'Invalid orchestration command token'], Response::HTTP_UNAUTHORIZED); + if (!$this->authenticator->matchesRequest($request)) { + return new JsonResponse(['error' => 'Invalid application token'], Response::HTTP_UNAUTHORIZED); } try { @@ -47,16 +48,4 @@ public function __invoke(Request $request): JsonResponse return new JsonResponse(['error' => $e->getMessage()], Response::HTTP_BAD_REQUEST); } } - - private function tokenMatches(Request $request): bool - { - foreach (['X-AM-Application-Token', 'X-Orchestration-Command-Token'] as $header) { - $token = trim((string) $request->headers->get($header, '')); - if ('' !== $token && hash_equals($this->expectedToken, $token)) { - return true; - } - } - - return false; - } } diff --git a/src/Bridge/Symfony/DependencyInjection/Configuration.php b/src/Bridge/Symfony/DependencyInjection/Configuration.php index 87d5e25..4d06333 100644 --- a/src/Bridge/Symfony/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/DependencyInjection/Configuration.php @@ -21,10 +21,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->scalarNode('am_base_url')->defaultValue('%env(AM_DRIVER_AM_BASE_URL)%')->end() ->scalarNode('source')->defaultValue('%env(AM_DRIVER_SOURCE)%')->end() ->scalarNode('data_dir')->defaultValue('%kernel.project_dir%/var/am-driver')->end() - ->scalarNode('consumption_webhook_token')->defaultValue('%env(AM_DRIVER_CONSUMPTION_WEBHOOK_TOKEN)%')->end() - ->scalarNode('orchestration_callback_token')->defaultValue('%env(AM_DRIVER_ORCHESTRATION_CALLBACK_TOKEN)%')->end() - ->scalarNode('orchestration_command_token')->defaultValue('%env(AM_DRIVER_ORCHESTRATION_COMMAND_TOKEN)%')->end() - ->scalarNode('operational_state_token')->defaultValue('%env(AM_DRIVER_OPERATIONAL_STATE_TOKEN)%')->end() + ->scalarNode('application_token')->defaultValue('%env(AM_DRIVER_APPLICATION_TOKEN)%')->end() + ->scalarNode('consumption_webhook_token')->defaultNull()->info('Deprecated: use application_token.')->end() + ->scalarNode('orchestration_callback_token')->defaultNull()->info('Deprecated: use application_token.')->end() + ->scalarNode('orchestration_command_token')->defaultNull()->info('Deprecated: use application_token.')->end() + ->scalarNode('operational_state_token')->defaultNull()->info('Deprecated: use application_token.')->end() ->floatNode('http_timeout')->defaultValue(10.0)->end() ->integerNode('consumption_max_retries')->defaultValue(3)->end() ->integerNode('consumption_retry_delay_ms')->defaultValue(500)->end() diff --git a/src/Bridge/Symfony/DependencyInjection/ReceiverRoutePaths.php b/src/Bridge/Symfony/DependencyInjection/ReceiverRoutePaths.php index 0f8d1e8..bfd5aa8 100644 --- a/src/Bridge/Symfony/DependencyInjection/ReceiverRoutePaths.php +++ b/src/Bridge/Symfony/DependencyInjection/ReceiverRoutePaths.php @@ -52,7 +52,35 @@ public static function finalize(array $config): array $config['route_prefix'] = $prefix; $config['orchestration_commands_path'] ??= self::orchestrationCommandsPath($prefix); $config['operational_state_path'] ??= self::operationalStatePath($prefix); + $config['application_token'] = self::resolveApplicationToken($config); return $config; } + + /** + * @param array $config + */ + private static function resolveApplicationToken(array $config): string + { + $token = trim((string) ($config['application_token'] ?? '')); + if ('' !== $token && !str_starts_with($token, '%env(')) { + return $token; + } + + foreach ( + [ + 'orchestration_command_token', + 'consumption_webhook_token', + 'orchestration_callback_token', + 'operational_state_token', + ] as $legacyKey + ) { + $legacy = trim((string) ($config[$legacyKey] ?? '')); + if ('' !== $legacy) { + return $legacy; + } + } + + return '' !== $token ? $token : '%env(AM_DRIVER_APPLICATION_TOKEN)%'; + } } diff --git a/src/Bridge/Symfony/Factory/AmApiClientConfigFactory.php b/src/Bridge/Symfony/Factory/AmApiClientConfigFactory.php index d90263d..f139135 100644 --- a/src/Bridge/Symfony/Factory/AmApiClientConfigFactory.php +++ b/src/Bridge/Symfony/Factory/AmApiClientConfigFactory.php @@ -15,8 +15,7 @@ public static function create(array $config): AmApiClientConfig { return new AmApiClientConfig( (string) $config['am_base_url'], - (string) $config['consumption_webhook_token'], - (string) $config['orchestration_callback_token'], + (string) $config['application_token'], (float) $config['http_timeout'], (int) $config['consumption_max_retries'], (int) $config['consumption_retry_delay_ms'], diff --git a/src/Bridge/Symfony/Resources/config/services.yaml b/src/Bridge/Symfony/Resources/config/services.yaml index 29ebd6a..7c44ef7 100644 --- a/src/Bridge/Symfony/Resources/config/services.yaml +++ b/src/Bridge/Symfony/Resources/config/services.yaml @@ -5,14 +5,14 @@ services: ApplicationManagerTools\AmDriver\Bridge\Symfony\Controller\OrchestrationCommandController: arguments: - $orchestrationCommandToken: '%am_driver.config.orchestration_command_token%' + $applicationToken: '%am_driver.config.application_token%' tags: - controller.service_arguments public: true ApplicationManagerTools\AmDriver\Bridge\Symfony\Controller\OperationalStateController: arguments: - $operationalStateToken: '%am_driver.config.operational_state_token%' + $applicationToken: '%am_driver.config.application_token%' tags: - controller.service_arguments public: true diff --git a/src/Core/Cli/ReceptacleBootstrapFactory.php b/src/Core/Cli/ReceptacleBootstrapFactory.php index 77921da..9f1fb80 100644 --- a/src/Core/Cli/ReceptacleBootstrapFactory.php +++ b/src/Core/Cli/ReceptacleBootstrapFactory.php @@ -27,8 +27,7 @@ final class ReceptacleBootstrapFactory * source: string, * orchestration_path: string, * operational_state_path: string, - * token_command: string, - * token_state: string + * application_token: string * } $config */ public static function createKernel(array $config): ReceptacleHttpKernel @@ -53,8 +52,7 @@ public static function createKernel(array $config): ReceptacleHttpKernel ), $config['orchestration_path'], $config['operational_state_path'], - $config['token_command'], - $config['token_state'], + $config['application_token'], ); } diff --git a/src/Core/Cli/ReceptacleHttpKernel.php b/src/Core/Cli/ReceptacleHttpKernel.php index eadf819..40e8744 100644 --- a/src/Core/Cli/ReceptacleHttpKernel.php +++ b/src/Core/Cli/ReceptacleHttpKernel.php @@ -6,6 +6,7 @@ use ApplicationManagerTools\AmDriver\Core\Dto\OrchestrationCommand; use ApplicationManagerTools\AmDriver\Core\Exception\ValidationException; +use ApplicationManagerTools\AmDriver\Core\Http\ApplicationTokenAuthenticator; use ApplicationManagerTools\AmDriver\Core\OperationalState\OperationalStateProcessor; use ApplicationManagerTools\AmDriver\Core\Orchestration\OrchestrationCommandProcessor; use ApplicationManagerTools\AmDriver\Core\Validation\JsonPayloadValidator; @@ -28,26 +29,21 @@ final class ReceptacleHttpKernel /** @var string */ private $operationalStatePath; - /** @var string */ - private $orchestrationToken; - - /** @var string */ - private $operationalStateToken; + /** @var ApplicationTokenAuthenticator */ + private $authenticator; public function __construct( OrchestrationCommandProcessor $orchestrationProcessor, OperationalStateProcessor $operationalStateProcessor, string $orchestrationPath, string $operationalStatePath, - string $orchestrationToken, - string $operationalStateToken + string $applicationToken ) { $this->orchestrationProcessor = $orchestrationProcessor; $this->operationalStateProcessor = $operationalStateProcessor; $this->orchestrationPath = $orchestrationPath; $this->operationalStatePath = $operationalStatePath; - $this->orchestrationToken = $orchestrationToken; - $this->operationalStateToken = $operationalStateToken; + $this->authenticator = new ApplicationTokenAuthenticator($applicationToken); } /** @@ -83,7 +79,7 @@ public function handle(string $method, string $uri, string $body, array $headers */ private function handleOrchestration(string $body, array $headers): array { - if (!$this->tokenMatches($headers, 'X-Orchestration-Command-Token', $this->orchestrationToken)) { + if (!$this->authenticator->matchesHeaderMap($headers)) { return [401, json_encode(['error' => 'Invalid token'], JSON_THROW_ON_ERROR)]; } @@ -104,7 +100,7 @@ private function handleOrchestration(string $body, array $headers): array */ private function handleOperationalState(string $body, array $headers): array { - if (!$this->tokenMatches($headers, 'X-Instance-Operational-State-Token', $this->operationalStateToken)) { + if (!$this->authenticator->matchesHeaderMap($headers)) { return [401, json_encode(['error' => 'Invalid token'], JSON_THROW_ON_ERROR)]; } @@ -118,19 +114,4 @@ private function handleOperationalState(string $body, array $headers): array return [500, json_encode(['error' => 'Transient error'], JSON_THROW_ON_ERROR)]; } } - - /** - * @param array> $headers - */ - private function tokenMatches(array $headers, string $name, string $expected): bool - { - $needle = strtolower($name); - foreach ($headers as $key => $values) { - if (strtolower((string) $key) === $needle) { - return '' !== $expected && hash_equals($expected, (string) ($values[0] ?? '')); - } - } - - return false; - } } diff --git a/src/Core/Dto/OrchestrationCommand.php b/src/Core/Dto/OrchestrationCommand.php index 1c397ea..15b5fe3 100644 --- a/src/Core/Dto/OrchestrationCommand.php +++ b/src/Core/Dto/OrchestrationCommand.php @@ -31,9 +31,6 @@ final class OrchestrationCommand /** @var string */ private $occurredAt; - /** @var string|null */ - private $instanceIntegrationToken; - /** @var string|null */ private $name; @@ -53,7 +50,6 @@ public function __construct( string $idempotencyKey, string $occurredAt, ?string $correlationId = null, - ?string $instanceIntegrationToken = null, ?string $name = null, ?string $credentialsLogin = null, array $metadata = [] @@ -64,7 +60,6 @@ public function __construct( $this->correlationId = $correlationId; $this->idempotencyKey = $idempotencyKey; $this->occurredAt = $occurredAt; - $this->instanceIntegrationToken = $instanceIntegrationToken; $this->name = $name; $this->credentialsLogin = $credentialsLogin; $this->metadata = $metadata; @@ -87,7 +82,6 @@ public static function fromArray(array $data): self self::assertEnrichmentRules($operation, $data); $correlationId = self::parseOptionalNonEmptyString($data, 'correlationId'); - $instanceToken = self::parseOptionalNonEmptyString($data, 'instanceIntegrationToken'); $name = null; $credentialsLogin = null; @@ -106,7 +100,6 @@ public static function fromArray(array $data): self (string) $data['idempotencyKey'], (string) $data['occurredAt'], $correlationId, - $instanceToken, $name, $credentialsLogin, $metadata, @@ -143,11 +136,6 @@ public function occurredAt(): string return $this->occurredAt; } - public function instanceIntegrationToken(): ?string - { - return $this->instanceIntegrationToken; - } - public function name(): ?string { return $this->name; @@ -181,9 +169,6 @@ public function toArray(): array if (null !== $this->correlationId) { $payload['correlationId'] = $this->correlationId; } - if (null !== $this->instanceIntegrationToken) { - $payload['instanceIntegrationToken'] = $this->instanceIntegrationToken; - } if ($this->operation->isCreate()) { if (null !== $this->name) { $payload['name'] = $this->name; diff --git a/src/Core/Http/AmApiClient.php b/src/Core/Http/AmApiClient.php index 0b1bd19..b6e1439 100644 --- a/src/Core/Http/AmApiClient.php +++ b/src/Core/Http/AmApiClient.php @@ -26,7 +26,7 @@ public function pushConsumption(ConsumptionWebhookEvent $event): array { $url = $this->config->baseUrl().'/api/v1/orchestration/consumption-events'; $options = [ - 'headers' => $this->consumptionHeaders(), + 'headers' => $this->applicationHeaders(), 'json' => $event->toArray(), 'timeout' => $this->config->timeoutSeconds(), ]; @@ -42,7 +42,7 @@ public function reportOrchestrationCallback(OrchestrationCallbackRequest $reques 'POST', $url, [ - 'headers' => $this->callbackHeaders(), + 'headers' => $this->applicationHeaders(), 'json' => $request->toArray(), 'timeout' => $this->config->timeoutSeconds(), ], @@ -52,28 +52,11 @@ public function reportOrchestrationCallback(OrchestrationCallbackRequest $reques /** * @return array */ - private function consumptionHeaders(): array + private function applicationHeaders(): array { - $token = $this->config->consumptionWebhookToken(); - - return [ - 'Content-Type' => 'application/json', - 'X-AM-Application-Token' => $token, - 'X-Consumption-Webhook-Token' => $token, - ]; - } - - /** - * @return array - */ - private function callbackHeaders(): array - { - $token = $this->config->orchestrationCallbackToken(); - return [ 'Content-Type' => 'application/json', - 'X-AM-Application-Token' => $token, - 'X-Orchestration-Callback-Token' => $token, + ApplicationTokenAuthenticator::HEADER_NAME => $this->config->applicationToken(), ]; } diff --git a/src/Core/Http/AmApiClientConfig.php b/src/Core/Http/AmApiClientConfig.php index 018fa9b..cb0c6da 100644 --- a/src/Core/Http/AmApiClientConfig.php +++ b/src/Core/Http/AmApiClientConfig.php @@ -10,10 +10,7 @@ final class AmApiClientConfig private $baseUrl; /** @var string */ - private $consumptionWebhookToken; - - /** @var string */ - private $orchestrationCallbackToken; + private $applicationToken; /** @var float */ private $timeoutSeconds; @@ -26,15 +23,13 @@ final class AmApiClientConfig public function __construct( string $baseUrl, - string $consumptionWebhookToken, - string $orchestrationCallbackToken, + string $applicationToken, float $timeoutSeconds = 10.0, int $consumptionMaxRetries = 3, int $consumptionRetryDelayMs = 500 ) { $this->baseUrl = rtrim($baseUrl, '/'); - $this->consumptionWebhookToken = $consumptionWebhookToken; - $this->orchestrationCallbackToken = $orchestrationCallbackToken; + $this->applicationToken = $applicationToken; $this->timeoutSeconds = $timeoutSeconds; $this->consumptionMaxRetries = $consumptionMaxRetries; $this->consumptionRetryDelayMs = $consumptionRetryDelayMs; @@ -45,14 +40,9 @@ public function baseUrl(): string return $this->baseUrl; } - public function consumptionWebhookToken(): string - { - return $this->consumptionWebhookToken; - } - - public function orchestrationCallbackToken(): string + public function applicationToken(): string { - return $this->orchestrationCallbackToken; + return $this->applicationToken; } public function timeoutSeconds(): float diff --git a/src/Core/Http/ApplicationTokenAuthenticator.php b/src/Core/Http/ApplicationTokenAuthenticator.php new file mode 100644 index 0000000..3fb1fb6 --- /dev/null +++ b/src/Core/Http/ApplicationTokenAuthenticator.php @@ -0,0 +1,76 @@ +expectedToken = $expectedToken; + } + + public function matchesRequest(Request $request): bool + { + return $this->matchesHeaderMap($this->headersFromRequest($request)); + } + + /** + * @param array> $headers + */ + public function matchesHeaderMap(array $headers): bool + { + $token = $this->extractToken($headers); + if ('' === $token) { + return false; + } + + return '' !== $this->expectedToken && hash_equals($this->expectedToken, $token); + } + + /** + * @return array> + */ + private function headersFromRequest(Request $request): array + { + /** @var array> $headers */ + $headers = []; + foreach ($request->headers->all() as $name => $values) { + if (!\is_array($values)) { + continue; + } + $stringValues = []; + foreach ($values as $value) { + $stringValues[] = (string) $value; + } + $headers[(string) $name] = $stringValues; + } + + return $headers; + } + + /** + * @param array> $headers + */ + private function extractToken(array $headers): string + { + $needle = strtolower(self::HEADER_NAME); + foreach ($headers as $name => $values) { + if (strtolower((string) $name) !== $needle) { + continue; + } + + return trim((string) ($values[0] ?? '')); + } + + return ''; + } +} diff --git a/src/Core/Http/HttpOrchestrationConnectivityProbe.php b/src/Core/Http/HttpOrchestrationConnectivityProbe.php index ebc6e81..3ed0951 100644 --- a/src/Core/Http/HttpOrchestrationConnectivityProbe.php +++ b/src/Core/Http/HttpOrchestrationConnectivityProbe.php @@ -33,7 +33,7 @@ public function probeOrchestrationRoute(string $orchestrationUrl, string $comman $response = $this->httpClient->request('POST', $orchestrationUrl, [ 'headers' => [ 'Content-Type' => 'application/json', - 'X-Orchestration-Command-Token' => $commandToken, + ApplicationTokenAuthenticator::HEADER_NAME => $commandToken, ], 'body' => '{}', 'timeout' => $this->timeoutSeconds, diff --git a/src/Core/Tenant/FileTenantWorkspace.php b/src/Core/Tenant/FileTenantWorkspace.php index 42e1f8d..072950e 100644 --- a/src/Core/Tenant/FileTenantWorkspace.php +++ b/src/Core/Tenant/FileTenantWorkspace.php @@ -13,7 +13,6 @@ final class FileTenantWorkspace { private const SUSPENDED_FLAG = 'suspended.flag'; - private const INSTANCE_TOKEN_FILE = 'instance-integration.token'; /** @var string */ private $tenantsBaseDirectory; @@ -52,23 +51,6 @@ public function isSuspended(string $instanceId): bool return is_file($this->directoryFor($instanceId).'/'.self::SUSPENDED_FLAG); } - public function storeInstanceIntegrationToken(string $instanceId, string $token): void - { - $dir = $this->ensureContext($instanceId); - AtomicFileWriter::write($dir.'/'.self::INSTANCE_TOKEN_FILE, $token); - } - - public function instanceIntegrationToken(string $instanceId): ?string - { - $path = $this->directoryFor($instanceId).'/'.self::INSTANCE_TOKEN_FILE; - if (!is_file($path)) { - return null; - } - $content = file_get_contents($path); - - return false === $content ? null : trim($content); - } - private function directoryFor(string $instanceId): string { $safe = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $instanceId) ?? $instanceId; diff --git a/tests/Integration/ReceptacleHttpKernelTest.php b/tests/Integration/ReceptacleHttpKernelTest.php index 1551447..cc5df0b 100644 --- a/tests/Integration/ReceptacleHttpKernelTest.php +++ b/tests/Integration/ReceptacleHttpKernelTest.php @@ -32,7 +32,7 @@ public function testOrchestrationCreateAndIdempotency(): void $kernel = $this->kernel($dataDir, $log); $body = file_get_contents(dirname(__DIR__).'/fixtures/orchestration-command-create.json'); self::assertNotFalse($body); - $headers = ['X-Orchestration-Command-Token' => ['dev-command-token']]; + $headers = ['X-AM-Application-Token' => ['dev-application-token']]; // Act $orchestrationPath = ReceiverRoutePaths::orchestrationCommandsPath(ReceiverRoutePaths::DEFAULT_ROUTE_PREFIX); @@ -52,7 +52,7 @@ public function testOperationalStatePersistsSnapshotCorrelation(): void $kernel = $this->kernel($dataDir, new CommandCallLog()); $body = file_get_contents(dirname(__DIR__).'/fixtures/instance-operational-state-am-minimal.json'); self::assertNotFalse($body); - $headers = ['X-Instance-Operational-State-Token' => ['dev-state-token']]; + $headers = ['X-AM-Application-Token' => ['dev-application-token']]; // Act $statePath = ReceiverRoutePaths::operationalStatePath(ReceiverRoutePaths::DEFAULT_ROUTE_PREFIX); @@ -86,8 +86,7 @@ private function kernel(string $dataDir, CommandCallLog $log): ReceptacleHttpKer ), ReceiverRoutePaths::orchestrationCommandsPath(ReceiverRoutePaths::DEFAULT_ROUTE_PREFIX), ReceiverRoutePaths::operationalStatePath(ReceiverRoutePaths::DEFAULT_ROUTE_PREFIX), - 'dev-command-token', - 'dev-state-token', + 'dev-application-token', ); } } diff --git a/tests/Unit/Bridge/Symfony/DependencyInjection/ConfigurationParametersTest.php b/tests/Unit/Bridge/Symfony/DependencyInjection/ConfigurationParametersTest.php index 60105df..7358438 100644 --- a/tests/Unit/Bridge/Symfony/DependencyInjection/ConfigurationParametersTest.php +++ b/tests/Unit/Bridge/Symfony/DependencyInjection/ConfigurationParametersTest.php @@ -21,8 +21,7 @@ public function testExtensionRegistersFlattenedConfigParameters(): void 'source' => 'application-manager', 'data_dir' => '/var/am-driver-data', 'route_prefix' => 'internal/am', - 'orchestration_command_token' => 'cmd-token', - 'operational_state_token' => 'state-token', + 'application_token' => 'app-token', ]], $container); // Assert @@ -38,7 +37,7 @@ public function testExtensionRegistersFlattenedConfigParameters(): void '/internal/am/instance-operational-state', $container->getParameter('am_driver.config.operational_state_path'), ); - self::assertSame('cmd-token', $container->getParameter('am_driver.config.orchestration_command_token')); + self::assertSame('app-token', $container->getParameter('am_driver.config.application_token')); } public function testExtensionUsesDefaultRoutePrefixWhenOmitted(): void @@ -50,8 +49,7 @@ public function testExtensionUsesDefaultRoutePrefixWhenOmitted(): void // Act $extension->load([[ 'source' => 'application-manager', - 'orchestration_command_token' => 'cmd-token', - 'operational_state_token' => 'state-token', + 'application_token' => 'app-token', ]], $container); // Assert diff --git a/tests/Unit/Http/AmApiClientTest.php b/tests/Unit/Http/AmApiClientTest.php index 7ccd04f..5d5a9fc 100644 --- a/tests/Unit/Http/AmApiClientTest.php +++ b/tests/Unit/Http/AmApiClientTest.php @@ -18,7 +18,7 @@ public function testPushConsumptionUsesTokenHeader(): void { // Arrange $recording = new RecordingHttpClient(); - $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-cons', 'secret-cb', 5.0, 0)); + $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-app', 5.0, 0)); // Act $response = $api->pushConsumption(new ConsumptionWebhookEvent( @@ -33,14 +33,14 @@ public function testPushConsumptionUsesTokenHeader(): void self::assertSame(202, $response['statusCode']); self::assertSame('POST', $recording->method); self::assertStringContainsString('/api/v1/orchestration/consumption-events', $recording->url); - self::assertSame('secret-cons', $this->headerValue($recording->options, 'X-Consumption-Webhook-Token')); + self::assertSame('secret-app', $this->headerValue($recording->options, 'X-AM-Application-Token')); } public function testReportCallbackUsesCallbackToken(): void { // Arrange $recording = new RecordingHttpClient(); - $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-cons', 'secret-cb')); + $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-app')); // Act $response = $api->reportOrchestrationCallback(new OrchestrationCallbackRequest( @@ -51,14 +51,14 @@ public function testReportCallbackUsesCallbackToken(): void // Assert self::assertSame(202, $response['statusCode']); self::assertStringContainsString('/api/v1/orchestration/commands/callbacks', $recording->url); - self::assertSame('secret-cb', $this->headerValue($recording->options, 'X-Orchestration-Callback-Token')); + self::assertSame('secret-app', $this->headerValue($recording->options, 'X-AM-Application-Token')); } public function testReportCallbackSerializesLocationInJsonBody(): void { // Arrange $recording = new RecordingHttpClient(); - $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-cons', 'secret-cb')); + $api = new AmApiClient($recording, new AmApiClientConfig('https://am.example', 'secret-app')); // Act $api->reportOrchestrationCallback(new OrchestrationCallbackRequest( diff --git a/tests/Unit/Http/ApplicationTokenAuthenticatorTest.php b/tests/Unit/Http/ApplicationTokenAuthenticatorTest.php new file mode 100644 index 0000000..a642001 --- /dev/null +++ b/tests/Unit/Http/ApplicationTokenAuthenticatorTest.php @@ -0,0 +1,37 @@ +matchesHeaderMap(['X-AM-Application-Token' => ['secret-app']]); + + // Assert + self::assertTrue($matches); + } + + public function testRejectsMissingOrInvalidToken(): void + { + // Arrange + $authenticator = new ApplicationTokenAuthenticator('secret-app'); + + // Act + $missing = $authenticator->matchesHeaderMap([]); + $invalid = $authenticator->matchesHeaderMap(['X-AM-Application-Token' => ['wrong']]); + + // Assert + self::assertFalse($missing); + self::assertFalse($invalid); + } +}