diff --git a/app/Http/Controllers/Api/v2026/Admin/ConfigCache.php b/app/Http/Controllers/Api/v2026/Admin/ConfigCache.php index d8a647da3..8bd8de5f4 100644 --- a/app/Http/Controllers/Api/v2026/Admin/ConfigCache.php +++ b/app/Http/Controllers/Api/v2026/Admin/ConfigCache.php @@ -15,37 +15,40 @@ use Illuminate\Support\Str; class ConfigCache extends Controller { - // GET config — bulk read; ?keys[] filters, unknown keys reject with 422. + // GET config — bulk read; requires an explicit ?keys[] filter. Unknown keys + // reject with 422. There is no fetch-everything default. public function index(Request $request): JsonResponse { $this->authorizeAdmin($request, 'admin:read'); $requested = $request->input('keys'); - if ($requested !== null) { - if (! is_array($requested)) { - return response()->json([ - 'message' => 'The keys parameter must be an array.', - ], 422); - } + if ($requested === null) { + return response()->json([ + 'message' => 'The keys parameter is required and must be a non-empty array.', + ], 422); + } - $unknown = array_values(array_filter( - $requested, - fn ($key) => ! is_string($key) || ! ConfigCacheService::isCached($key) - )); + if (! is_array($requested) || empty($requested)) { + return response()->json([ + 'message' => 'The keys parameter is required and must be a non-empty array.', + ], 422); + } - if (! empty($unknown)) { - return response()->json([ - 'message' => 'One or more requested keys are unknown or uncached.', - 'unknown_keys' => $unknown, - ], 422); - } + $unknown = array_values(array_filter( + $requested, + fn ($key) => ! is_string($key) || ! ConfigCacheService::isCached($key) + )); - $keys = array_values(array_unique($requested)); - } else { - $keys = ConfigCacheService::adminVisibleKeys(); + if (! empty($unknown)) { + return response()->json([ + 'message' => 'One or more requested keys are unknown or uncached.', + 'unknown_keys' => $unknown, + ], 422); } + $keys = array_values(array_unique($requested)); + return response()->json([ 'data' => array_map(fn ($key) => $this->itemFor($key), $keys), ]); @@ -87,7 +90,10 @@ class ConfigCache extends Controller ]); } - // POST config — bulk write, all-or-nothing. Payload: { config: { key: value } }. + // POST config — bulk write, partial success. Payload: { config: { key: value } }. + // Valid keys are persisted even when others fail; per-key failures are + // reported in `errors`. Returns 422 only when the payload is malformed or + // no submitted key was writable. public function store(Request $request): JsonResponse { $this->authorizeAdmin($request, 'admin:write'); @@ -103,7 +109,7 @@ class ConfigCache extends Controller $errors = []; $permitted = $this->collectWritable($config, $errors); - if (! empty($errors)) { + if (empty($permitted) && ! empty($errors)) { return response()->json([ 'message' => 'The submitted configuration is invalid.', 'errors' => $errors, @@ -112,6 +118,7 @@ class ConfigCache extends Controller return response()->json([ 'changed' => array_values($this->persist($permitted)), + 'errors' => empty($errors) ? (object) [] : $errors, ]); } diff --git a/app/Services/ConfigCacheService.php b/app/Services/ConfigCacheService.php index 3beae6c1d..07e28c502 100644 --- a/app/Services/ConfigCacheService.php +++ b/app/Services/ConfigCacheService.php @@ -13,10 +13,20 @@ class ConfigCacheService { const CACHE_KEY = 'config_cache:_v0-key:'; + // Keys whose value is a secret and must be encrypted at rest and masked + // when read back through the admin API / debug page. + const PROTECTED_KEYS = [ + 'filesystems.disks.s3.secret', + 'filesystems.disks.spaces.secret', + 'captcha.hcaptcha.secret', + 'captcha.turnstile.secret', + 'captcha.cap.secret', + ]; + const KEYS = [ // filesystems.php — s3 disk - 'filesystems.disks.s3.key' => ['list' => 'ENVCONFIG', 'env' => 'AWS_ACCESS_KEY_ID', 'rule' => 'string', 'secret' => true], - 'filesystems.disks.s3.secret' => ['list' => 'ENVCONFIG', 'env' => 'AWS_SECRET_ACCESS_KEY', 'rule' => 'string', 'secret' => true], + 'filesystems.disks.s3.key' => ['list' => 'ENVCONFIG', 'env' => 'AWS_ACCESS_KEY_ID', 'rule' => 'string'], + 'filesystems.disks.s3.secret' => ['list' => 'ENVCONFIG', 'env' => 'AWS_SECRET_ACCESS_KEY', 'rule' => 'string'], 'filesystems.disks.s3.region' => ['list' => 'ENVCONFIG', 'env' => 'AWS_DEFAULT_REGION', 'rule' => 'string'], 'filesystems.disks.s3.bucket' => ['list' => 'ENVCONFIG', 'env' => 'AWS_BUCKET', 'rule' => 'string'], 'filesystems.disks.s3.visibility' => ['list' => 'ENVCONFIG', 'env' => 'AWS_VISIBILITY', 'rule' => 'in:public,private'], @@ -25,8 +35,8 @@ class ConfigCacheService 'filesystems.disks.s3.use_path_style_endpoint' => ['list' => 'ENVCONFIG', 'env' => 'AWS_USE_PATH_STYLE_ENDPOINT', 'rule' => 'boolean'], // filesystems.php — spaces disk - 'filesystems.disks.spaces.key' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_KEY', 'rule' => 'string', 'secret' => true], - 'filesystems.disks.spaces.secret' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_SECRET', 'rule' => 'string', 'secret' => true], + 'filesystems.disks.spaces.key' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_KEY', 'rule' => 'string'], + 'filesystems.disks.spaces.secret' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_SECRET', 'rule' => 'string'], 'filesystems.disks.spaces.region' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_REGION', 'rule' => 'string'], 'filesystems.disks.spaces.bucket' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_BUCKET', 'rule' => 'string'], 'filesystems.disks.spaces.url' => ['list' => 'ENVCONFIG', 'env' => 'DO_SPACES_URL', 'rule' => 'url'], @@ -95,13 +105,13 @@ class ConfigCacheService // captcha.php 'captcha.enabled' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED', 'rule' => 'boolean'], 'captcha.driver' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_DRIVER', 'rule' => 'in:hcaptcha,turnstile,cap'], - 'captcha.hcaptcha.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SECRET', 'rule' => 'string', 'secret' => true], + 'captcha.hcaptcha.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SECRET', 'rule' => 'string'], 'captcha.hcaptcha.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_H_SITEKEY', 'rule' => 'string'], - 'captcha.turnstile.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SECRET', 'rule' => 'string', 'secret' => true], + 'captcha.turnstile.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SECRET', 'rule' => 'string'], 'captcha.turnstile.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_TURNSTILE_SITEKEY', 'rule' => 'string'], 'captcha.cap.endpoint' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_ENDPOINT', 'rule' => 'url'], 'captcha.cap.sitekey' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SITEKEY', 'rule' => 'string'], - 'captcha.cap.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SECRET', 'rule' => 'string', 'secret' => true], + 'captcha.cap.secret' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_CAP_SECRET', 'rule' => 'string'], 'captcha.active.login' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_LOGIN', 'rule' => 'boolean'], 'captcha.active.register' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_REGISTER', 'rule' => 'boolean'], 'captcha.active.forgot_password' => ['list' => 'ENVCONFIG', 'env' => 'CAPTCHA_ENABLED_ON_FORGOT_PASSWORD', 'rule' => 'boolean'], @@ -147,7 +157,7 @@ class ConfigCacheService public static function isProtected(string $key): bool { - return (self::KEYS[$key]['secret'] ?? false) === true; + return in_array($key, self::PROTECTED_KEYS, true); } public static function ruleFor(string $key): ?string diff --git a/bootstrap/app.php b/bootstrap/app.php index ab8564b58..7fe6f856c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -66,6 +66,9 @@ return Application::configure(basePath: dirname(__DIR__)) Route::middleware('api') ->group(base_path('routes/api.php')); + + Route::middleware('api') + ->group(base_path('routes/v2026.php')); }, channels: __DIR__.'/../routes/channels.php', commands: __DIR__.'/../routes/console.php', diff --git a/routes/api.php b/routes/api.php index 6525fbf88..415633fe4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,7 +8,6 @@ use App\Http\Controllers\Api\ApiV2Controller; use App\Http\Controllers\Api\V1\Admin\DomainBlocksController; use App\Http\Controllers\Api\V1\DomainBlockController; use App\Http\Controllers\Api\V1\TagsController; -use App\Http\Controllers\Api\v2026\Admin\ConfigCache as AdminConfigCacheController; use App\Http\Controllers\ApiController; use App\Http\Controllers\AppRegisterController; use App\Http\Controllers\CollectionController; @@ -128,17 +127,6 @@ Route::prefix('api/v0/groups')->middleware($middleware)->group(function () { Route::get('{id}', [GroupController::class, 'getGroup']); }); -Route::prefix('api')->group(function () use ($middleware) { - Route::prefix('v2026')->group(function () use ($middleware) { - Route::prefix('admin')->group(function () use ($middleware) { - Route::get('config', [AdminConfigCacheController::class, 'index'])->middleware($middleware); - Route::post('config', [AdminConfigCacheController::class, 'store'])->middleware($middleware); - Route::get('config/{key}', [AdminConfigCacheController::class, 'show'])->where('key', '.*')->middleware($middleware); - Route::post('config/{key}', [AdminConfigCacheController::class, 'update'])->where('key', '.*')->middleware($middleware); - }); - }); -}); - Route::prefix('api')->group(function () use ($middleware) { Route::prefix('v1')->group(function () use ($middleware) { diff --git a/routes/v2026.php b/routes/v2026.php new file mode 100644 index 000000000..e0c6ab7e3 --- /dev/null +++ b/routes/v2026.php @@ -0,0 +1,17 @@ +group(function () use ($middleware) { + Route::prefix('v2026')->group(function () use ($middleware) { + Route::prefix('admin')->group(function () use ($middleware) { + Route::get('config', [AdminConfigCacheController::class, 'index'])->middleware($middleware); + Route::post('config', [AdminConfigCacheController::class, 'store'])->middleware($middleware); + Route::get('config/{key}', [AdminConfigCacheController::class, 'show'])->where('key', '.*')->middleware($middleware); + Route::post('config/{key}', [AdminConfigCacheController::class, 'update'])->where('key', '.*')->middleware($middleware); + }); + }); +}); diff --git a/tests/Feature/Config/ConfigApiV2026Test.php b/tests/Feature/Config/ConfigApiV2026Test.php index 47036dc57..d69c8ab76 100644 --- a/tests/Feature/Config/ConfigApiV2026Test.php +++ b/tests/Feature/Config/ConfigApiV2026Test.php @@ -169,18 +169,19 @@ test('GET single for an unknown/uncached key returns 404, not a silent fallback |-------------------------------------------------------------------------- */ -test('GET bulk with no filter returns every admin-visible key (5.3)', function () { +test('GET bulk with no filter is rejected 422: keys are required (5.3)', function () { apiAdmin(['admin:read']); - $expectedCount = count(ConfigCacheService::adminVisibleKeys()); + $this->getJson('/api/v2026/admin/config') + ->assertStatus(422) + ->assertJsonStructure(['message']); +}); - $response = $this->getJson('/api/v2026/admin/config') - ->assertOk() - ->assertJsonCount($expectedCount, 'data'); +test('GET bulk with an empty keys array is rejected 422 (5.3b)', function () { + apiAdmin(['admin:read']); - $keys = collect($response->json('data'))->pluck('key'); - expect($keys)->toContain(API_ADMINONLY_KEY); - expect($keys)->toContain(API_ENVBOUND_KEY); + $this->getJson('/api/v2026/admin/config?keys[]=') + ->assertStatus(422); }); test('GET bulk with a ?keys[]= filter returns exactly the requested keys (5.4)', function () { @@ -344,7 +345,7 @@ test('POST single with a value failing its rule is rejected 422 (6.1)', function |-------------------------------------------------------------------------- */ -test('POST bulk is all-or-nothing: one invalid entry rejects the whole batch, nothing persists (6.2)', function () { +test('POST bulk is partial success: valid entries persist, invalid entries are reported (6.2)', function () { apiAdmin(['admin:write']); Config::set(API_ADMINONLY_KEY, '/* default css */'); apiForget(API_ADMINONLY_KEY); @@ -358,12 +359,30 @@ test('POST bulk is all-or-nothing: one invalid entry rejects the whole batch, no API_ADMINONLY_KEY => '.valid { color: green; }', // valid API_ENVBOUND_KEY => 'eu-west-9', // invalid: env-locked ], + ]) + ->assertOk() + ->assertJsonStructure(['changed', 'errors' => [API_ENVBOUND_KEY]]); + + // The VALID entry IS written; the invalid one is not. + expect(ConfigCacheModel::where('k', API_ADMINONLY_KEY)->exists())->toBeTrue(); + expect(ConfigCacheModel::where('k', API_ENVBOUND_KEY)->exists())->toBeFalse(); +}); + +test('POST bulk with every entry invalid rejects 422 and persists nothing (6.2b)', function () { + apiAdmin(['admin:write']); + + apiSetProcessEnv(API_ENVBOUND_VAR, 'us-east-1'); + Config::set(API_ENVBOUND_KEY, 'us-east-1'); + + $this->postJson('/api/v2026/admin/config', [ + 'config' => [ + API_ENVBOUND_KEY => 'eu-west-9', // invalid: env-locked + 'this.key.is.not.cached' => 'x', // invalid: unknown + ], ]) ->assertStatus(422) - ->assertJsonStructure(['errors' => [API_ENVBOUND_KEY]]); + ->assertJsonStructure(['errors' => [API_ENVBOUND_KEY, 'this.key.is.not.cached']]); - // The VALID entry must NOT have been written (all-or-nothing). - expect(ConfigCacheModel::where('k', API_ADMINONLY_KEY)->exists())->toBeFalse(); expect(ConfigCacheModel::where('k', API_ENVBOUND_KEY)->exists())->toBeFalse(); });