diff --git a/app/Support/EnvAuditor.php b/app/Support/EnvAuditor.php index 67de4aa82..95e1a5d0a 100644 --- a/app/Support/EnvAuditor.php +++ b/app/Support/EnvAuditor.php @@ -2,6 +2,10 @@ namespace App\Support; +use Illuminate\Support\Env; +use InvalidArgumentException; +use JsonException; + /** * Scans the process environment against the DW_* contract from * config/dw-contract.php and produces a structured report. @@ -139,4 +143,38 @@ public static function env(string $name, ?string $legacy, mixed $default = null) return $legacy === null ? $default : env($legacy, $default); } + + /** + * Resolve a policy object without converting malformed JSON into defaults. + * Read the raw repository value so Laravel's special null/false/empty env + * conversions cannot hide an explicitly supplied invalid JSON value. + * + * @return array + */ + public static function jsonObject(string $name, ?string $legacy): array + { + $repository = Env::getRepository(); + $raw = $repository->get($name); + if ($raw === null && $legacy !== null) { + $raw = $repository->get($legacy); + if ($raw !== null) { + $name = $legacy; + } + } + if ($raw === null) { + return []; + } + + try { + $value = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new InvalidArgumentException($name.' must contain a valid JSON object.'); + } + + if (! is_array($value) || ! str_starts_with(ltrim($raw), '{')) { + throw new InvalidArgumentException($name.' must contain a valid JSON object.'); + } + + return $value; + } } diff --git a/config/server.php b/config/server.php index 8cda72d93..9e6c0d793 100644 --- a/config/server.php +++ b/config/server.php @@ -364,14 +364,10 @@ null, ), ], - 'queue_overrides' => json_decode( - (string) EnvAuditor::env( - 'DW_TASK_QUEUE_ADMISSION_OVERRIDES', - 'WORKFLOW_SERVER_TASK_QUEUE_ADMISSION_OVERRIDES', - '{}', - ), - true, - ) ?: [], + 'queue_overrides' => EnvAuditor::jsonObject( + 'DW_TASK_QUEUE_ADMISSION_OVERRIDES', + 'WORKFLOW_SERVER_TASK_QUEUE_ADMISSION_OVERRIDES', + ), 'lock_ttl_seconds' => 5, 'lock_wait_seconds' => 1, ], @@ -409,14 +405,10 @@ 'WORKFLOW_SERVER_NAMESPACE_HARD_MAX_CONCURRENT_REQUESTS', null, ), - 'overrides' => json_decode( - (string) EnvAuditor::env( - 'DW_NAMESPACE_ADMISSION_OVERRIDES', - 'WORKFLOW_SERVER_NAMESPACE_ADMISSION_OVERRIDES', - '{}', - ), - true, - ) ?: [], + 'overrides' => EnvAuditor::jsonObject( + 'DW_NAMESPACE_ADMISSION_OVERRIDES', + 'WORKFLOW_SERVER_NAMESPACE_ADMISSION_OVERRIDES', + ), 'lock_ttl_seconds' => 5, 'lock_wait_seconds' => 1, 'request_lease_ttl_seconds' => 120, @@ -437,29 +429,17 @@ */ 'namespace_durable_state' => [ - 'limits' => json_decode( - (string) EnvAuditor::env( - 'DW_NAMESPACE_DURABLE_LIMITS', - 'WORKFLOW_SERVER_NAMESPACE_DURABLE_LIMITS', - '{}', - ), - true, + 'limits' => EnvAuditor::jsonObject( + 'DW_NAMESPACE_DURABLE_LIMITS', + 'WORKFLOW_SERVER_NAMESPACE_DURABLE_LIMITS', ), - 'hard_limits' => json_decode( - (string) EnvAuditor::env( - 'DW_NAMESPACE_DURABLE_HARD_LIMITS', - 'WORKFLOW_SERVER_NAMESPACE_DURABLE_HARD_LIMITS', - '{}', - ), - true, + 'hard_limits' => EnvAuditor::jsonObject( + 'DW_NAMESPACE_DURABLE_HARD_LIMITS', + 'WORKFLOW_SERVER_NAMESPACE_DURABLE_HARD_LIMITS', ), - 'overrides' => json_decode( - (string) EnvAuditor::env( - 'DW_NAMESPACE_DURABLE_OVERRIDES', - 'WORKFLOW_SERVER_NAMESPACE_DURABLE_OVERRIDES', - '{}', - ), - true, + 'overrides' => EnvAuditor::jsonObject( + 'DW_NAMESPACE_DURABLE_OVERRIDES', + 'WORKFLOW_SERVER_NAMESPACE_DURABLE_OVERRIDES', ), ], @@ -540,13 +520,9 @@ 'WORKFLOW_SERVER_EXTERNAL_PAYLOAD_HARD_MAX_OBJECTS_PER_NAMESPACE', null, ), - 'namespace_overrides' => json_decode( - (string) EnvAuditor::env( - 'DW_EXTERNAL_PAYLOAD_NAMESPACE_OVERRIDES', - 'WORKFLOW_SERVER_EXTERNAL_PAYLOAD_NAMESPACE_OVERRIDES', - '{}', - ), - true, + 'namespace_overrides' => EnvAuditor::jsonObject( + 'DW_EXTERNAL_PAYLOAD_NAMESPACE_OVERRIDES', + 'WORKFLOW_SERVER_EXTERNAL_PAYLOAD_NAMESPACE_OVERRIDES', ), ], diff --git a/docs/server-reference.md b/docs/server-reference.md index d57afc063..fdf7d4b1f 100644 --- a/docs/server-reference.md +++ b/docs/server-reference.md @@ -1741,6 +1741,16 @@ fine; renames require a major bump with the old name alias-honored for one major. Set `DW_ENV_AUDIT_STRICT=1` to fail container boot when the audit finds drift. +Admission policy values must be JSON objects. The queue admission, namespace +request, namespace durable-state and external-payload namespace maps reject +malformed JSON, arrays, scalars and explicit `null` during configuration loading +and `config:cache`, regardless of `DW_ENV_AUDIT_STRICT`. An omitted setting or +`{}` retains its empty-map default. A supplied `DW_*` value takes precedence +over its legacy alias even when it is invalid; an invalid primary does not +silently select the legacy policy. The error names the selected variable +without printing its value. Correct that variable, rebuild cached configuration +and restart every role that uses it. + #### Environment variable reference The full table below is generated from `config/dw-contract.php` and lists diff --git a/tests/Unit/AdmissionPolicyEnvironmentTest.php b/tests/Unit/AdmissionPolicyEnvironmentTest.php new file mode 100644 index 000000000..b04244160 --- /dev/null +++ b/tests/Unit/AdmissionPolicyEnvironmentTest.php @@ -0,0 +1,157 @@ + */ + public static function policyVariables(): iterable + { + yield 'queue admission' => ['DW_TASK_QUEUE_ADMISSION_OVERRIDES', 'admission.queue_overrides']; + yield 'namespace admission' => ['DW_NAMESPACE_ADMISSION_OVERRIDES', 'namespace_admission.overrides']; + yield 'durable limits' => ['DW_NAMESPACE_DURABLE_LIMITS', 'namespace_durable_state.limits']; + yield 'durable hard limits' => ['DW_NAMESPACE_DURABLE_HARD_LIMITS', 'namespace_durable_state.hard_limits']; + yield 'durable overrides' => ['DW_NAMESPACE_DURABLE_OVERRIDES', 'namespace_durable_state.overrides']; + yield 'payload overrides' => ['DW_EXTERNAL_PAYLOAD_NAMESPACE_OVERRIDES', 'external_payload_transport.namespace_overrides']; + } + + /** @return iterable */ + public static function invalidPolicyValues(): iterable + { + foreach (self::policyVariables() as $policy => [$name]) { + $invalid = [ + 'unfinished' => '{"private-policy":', + 'null' => 'null', + 'array' => '[]', + 'string' => '"private-policy"', + 'number' => '0', + 'boolean' => 'false', + 'blank' => '', + ]; + foreach ($invalid as $kind => $value) { + yield $policy.' / '.$kind => [$name, $value]; + } + } + } + + #[DataProvider('invalidPolicyValues')] + public function test_invalid_primary_policy_refuses_without_printing_its_value(string $name, string $value): void + { + $this->withEnvironment([$name => $value], function () use ($name): void { + try { + require dirname(__DIR__, 2).'/config/server.php'; + self::fail('Invalid policy configuration was silently accepted.'); + } catch (InvalidArgumentException $exception) { + self::assertStringContainsString($name, $exception->getMessage()); + self::assertStringNotContainsString('private-policy', $exception->getMessage()); + self::assertNull($exception->getPrevious()); + } + }); + } + + #[DataProvider('policyVariables')] + public function test_empty_objects_and_valid_primary_policies_keep_their_values(string $name, string $path): void + { + $legacy = str_replace('DW_', 'WORKFLOW_SERVER_', $name); + $this->withEnvironment([$name => '{}', $legacy => '{invalid'], function () use ($path): void { + $config = require dirname(__DIR__, 2).'/config/server.php'; + self::assertSame([], $this->atPath($config, $path)); + }); + $this->withEnvironment([$name => '{"default":{"max_requests_per_minute":1}}'], function () use ($path): void { + $config = require dirname(__DIR__, 2).'/config/server.php'; + self::assertSame(['default' => ['max_requests_per_minute' => 1]], $this->atPath($config, $path)); + }); + } + + #[DataProvider('policyVariables')] + public function test_omitted_policy_and_valid_legacy_policy_keep_existing_resolution(string $name, string $path): void + { + $legacy = str_replace('DW_', 'WORKFLOW_SERVER_', $name); + $this->withEnvironment([$name => null, $legacy => null], function () use ($path): void { + $config = require dirname(__DIR__, 2).'/config/server.php'; + self::assertSame([], $this->atPath($config, $path)); + }); + $this->withEnvironment([$name => null, $legacy => '{"payments":{"max_requests_per_minute":1}}'], function () use ($path): void { + $config = require dirname(__DIR__, 2).'/config/server.php'; + self::assertSame(['payments' => ['max_requests_per_minute' => 1]], $this->atPath($config, $path)); + }); + } + + #[DataProvider('policyVariables')] + public function test_invalid_primary_does_not_fall_back_to_legacy(string $name, string $path): void + { + $legacy = str_replace('DW_', 'WORKFLOW_SERVER_', $name); + $this->withEnvironment([$name => 'null', $legacy => '{}'], function () use ($name): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($name); + require dirname(__DIR__, 2).'/config/server.php'; + }); + } + + #[DataProvider('policyVariables')] + public function test_invalid_legacy_policy_names_the_selected_legacy_variable(string $name, string $path): void + { + $legacy = str_replace('DW_', 'WORKFLOW_SERVER_', $name); + $this->withEnvironment([$name => null, $legacy => '{invalid'], function () use ($legacy): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($legacy); + require dirname(__DIR__, 2).'/config/server.php'; + }); + } + + /** @param array $values */ + private function withEnvironment(array $values, callable $action): void + { + $application = Container::getInstance(); + new Application(dirname(__DIR__, 2)); + $previous = []; + foreach ($values as $name => $value) { + $previous[$name] = [getenv($name), $_ENV[$name] ?? null, $_SERVER[$name] ?? null]; + if ($value === null) { + putenv($name); + unset($_ENV[$name], $_SERVER[$name]); + } else { + putenv($name.'='.$value); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + } + + try { + $action(); + } finally { + Container::setInstance($application); + foreach ($previous as $name => [$process, $environment, $server]) { + $process === false ? putenv($name) : putenv($name.'='.$process); + if ($environment === null) { + unset($_ENV[$name]); + } else { + $_ENV[$name] = $environment; + } + if ($server === null) { + unset($_SERVER[$name]); + } else { + $_SERVER[$name] = $server; + } + } + } + } + + /** @param array $config */ + private function atPath(array $config, string $path): mixed + { + foreach (explode('.', $path) as $key) { + $config = $config[$key]; + } + + return $config; + } +}