From 220c425a2e43d5c2477ef677f5b61d8ba8d236c2 Mon Sep 17 00:00:00 2001 From: loks0n <22452787+loks0n@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:20:43 +0000 Subject: [PATCH] feat: accept OPR_EXECUTOR_SECRET_PREVIOUS during secret rotation The executor compared the bearer against one secret, so rotating it meant every caller and the executor had to switch at the same instant. It now also accepts an optional previous secret, and compares with hash_equals. Co-Authored-By: Claude Opus 5.5 --- .env | 1 + README.md | 1 + app/controllers.php | 9 ++++++++- docker-compose.yml | 1 + tests/e2e/ExecutorTest.php | 15 +++++++++++++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 8b49c8d..9f098d4 100644 --- a/.env +++ b/.env @@ -7,6 +7,7 @@ OPR_EXECUTOR_MAINTENANCE_INTERVAL=60 OPR_EXECUTOR_NETWORK=executor_runtimes OPR_EXECUTOR_IMAGE=executor-local OPR_EXECUTOR_SECRET=executor-secret-key +OPR_EXECUTOR_SECRET_PREVIOUS=executor-secret-key-previous OPR_EXECUTOR_DOCKER_HUB_USERNAME= OPR_EXECUTOR_DOCKER_HUB_PASSWORD= OPR_EXECUTOR_RUNTIME_VERSIONS=v2,v5 diff --git a/README.md b/README.md index 5beb066..b194fc7 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,7 @@ docker compose down | OPR_EXECUTOR_MAINTENANCE_INTERVAL| Interval (in seconds) at which the Executor performs maintenance tasks, ex: `60` | | OPR_EXECUTOR_NETWORK | Network used by the executor for runtimes, ex: `openruntimes-runtimes` | | OPR_EXECUTOR_SECRET | Secret key used by the executor for authentication | +| OPR_EXECUTOR_SECRET_PREVIOUS | Optional second key the executor also accepts, so callers can move to a new `OPR_EXECUTOR_SECRET` without downtime | | OPR_EXECUTOR_DOCKER_HUB_USERNAME | Username for Docker Hub authentication (if applicable) | | OPR_EXECUTOR_DOCKER_HUB_PASSWORD | Password for Docker Hub authentication (if applicable) | | OPR_EXECUTOR_RUNTIME_VERSIONS | Version tag for runtime environments, ex: `v5` | diff --git a/app/controllers.php b/app/controllers.php index cb7e5e5..52e5f54 100644 --- a/app/controllers.php +++ b/app/controllers.php @@ -295,7 +295,14 @@ function ( ->inject('request') ->action(function (Request $request): void { $secretKey = \explode(' ', $request->getHeaderLine('authorization'))[1] ?? ''; - if ($secretKey === '' || $secretKey === '0' || $secretKey !== System::getEnv('OPR_EXECUTOR_SECRET', '')) { + + // OPR_EXECUTOR_SECRET_PREVIOUS keeps the old secret valid while callers move to a new one. + $accepted = \array_filter( + [System::getEnv('OPR_EXECUTOR_SECRET', ''), System::getEnv('OPR_EXECUTOR_SECRET_PREVIOUS', '')], + fn (string $secret): bool => $secret !== '' && $secret !== '0', + ); + + if ($secretKey === '' || $secretKey === '0' || \array_filter($accepted, fn (string $secret): bool => \hash_equals($secret, $secretKey)) === []) { throw new Exception(Exception::GENERAL_UNAUTHORIZED, 'Missing executor key'); } }); diff --git a/docker-compose.yml b/docker-compose.yml index 656c95d..d4528db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,7 @@ services: - OPR_EXECUTOR_MAINTENANCE_INTERVAL - OPR_EXECUTOR_NETWORK - OPR_EXECUTOR_SECRET + - OPR_EXECUTOR_SECRET_PREVIOUS - OPR_EXECUTOR_DOCKER_HUB_USERNAME - OPR_EXECUTOR_DOCKER_HUB_PASSWORD - OPR_EXECUTOR_RUNTIME_VERSIONS diff --git a/tests/e2e/ExecutorTest.php b/tests/e2e/ExecutorTest.php index 17c6738..c8eac0e 100644 --- a/tests/e2e/ExecutorTest.php +++ b/tests/e2e/ExecutorTest.php @@ -250,6 +250,21 @@ public function testGetRuntimesUnauthorized(): void $this->client->setKey($this->key); } + public function testPreviousSecretAuthorizes(): void + { + // docker-compose.yml sets OPR_EXECUTOR_SECRET_PREVIOUS so a rotation can be exercised end to end. + $this->client->setKey('executor-secret-key-previous'); + $response = $this->client->call(Client::METHOD_GET, '/runtimes', [], []); + $this->assertEquals(200, $response['headers']['status-code']); + $this->assertIsArray($response['body']); + + $this->client->setKey('executor-secret-key-unknown'); + $response = $this->client->call(Client::METHOD_GET, '/runtimes', [], []); + $this->assertEquals(401, $response['headers']['status-code']); + + $this->client->setKey($this->key); + } + public function testCommandsUnauthorized(): void { $this->client->setKey('');