fix(api): expose nested server secrets for privileged tokens

Add `exposeNestedServerSecrets()` to Applications, Databases, and
Services controllers so that `read:sensitive`/`root` tokens see
sentinel and logdrain fields on eager-loaded Server + ServerSetting
relations.

ServicesController handles both single models and Eloquent Collections
(listing endpoint passes a Collection per project).

Tests tightened to use JSON-key assertions (`"field":`) to avoid false
positives from field names appearing in values.
This commit is contained in:
Andras Bacsai 2026-04-30 11:49:15 +02:00
parent e828058755
commit 8dc79f4ed6
4 changed files with 116 additions and 6 deletions

View file

@ -57,6 +57,7 @@ private function removeSensitiveData($application)
'value', 'value',
'real_value', 'real_value',
]); ]);
$this->exposeNestedServerSecrets($application);
} else { } else {
$application->makeHidden([ $application->makeHidden([
'private_key_id', 'private_key_id',
@ -66,6 +67,34 @@ private function removeSensitiveData($application)
return serializeApiResponse($application); return serializeApiResponse($application);
} }
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
* Models hide these by default via $hidden; this re-exposes them per-request.
*/
private function exposeNestedServerSecrets($model): void
{
$server = $model->destination?->server ?? null;
if (! $server) {
return;
}
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
$settings = $server->settings ?? null;
if ($settings) {
$settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
#[OA\Get( #[OA\Get(
summary: 'List', summary: 'List',
description: 'List all applications.', description: 'List all applications.',

View file

@ -49,11 +49,39 @@ private function removeSensitiveData($database)
'mariadb_password', 'mariadb_password',
'mariadb_root_password', 'mariadb_root_password',
]); ]);
$this->exposeNestedServerSecrets($database);
} }
return serializeApiResponse($database); return serializeApiResponse($database);
} }
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
*/
private function exposeNestedServerSecrets($model): void
{
$server = $model->destination?->server ?? null;
if (! $server) {
return;
}
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
$settings = $server->settings ?? null;
if ($settings) {
$settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
#[OA\Get( #[OA\Get(
summary: 'List', summary: 'List',
description: 'List all databases.', description: 'List all databases.',

View file

@ -16,6 +16,7 @@
use App\Support\ValidationPatterns; use App\Support\ValidationPatterns;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA; use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
@ -37,11 +38,48 @@ private function removeSensitiveData($service)
'value', 'value',
'real_value', 'real_value',
]); ]);
$this->exposeNestedServerSecrets($service);
} }
return serializeApiResponse($service); return serializeApiResponse($service);
} }
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
* Handles both single models and Eloquent Collections (the listing endpoint
* passes a Collection of Services per project to removeSensitiveData()).
*/
private function exposeNestedServerSecrets($model): void
{
if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) {
foreach ($model as $item) {
$this->exposeNestedServerSecrets($item);
}
return;
}
$server = $model->destination?->server ?? $model->server ?? null;
if (! $server) {
return;
}
$server->makeVisible([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
$settings = $server->settings ?? null;
if ($settings) {
$settings->makeVisible([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array private function applyServiceUrls(Service $service, array $urlsArray, string $teamId, bool $forceDomainOverride = false): ?array
{ {
$errors = []; $errors = [];

View file

@ -128,9 +128,23 @@ function makeTeamUser(): array
$response->assertStatus(200); $response->assertStatus(200);
$body = $response->getContent(); $body = $response->getContent();
expect($body)->not->toContain('sentinel_token'); expect($body)->not->toContain('"sentinel_token":');
expect($body)->not->toContain('sentinel_custom_url'); expect($body)->not->toContain('"sentinel_custom_url":');
expect($body)->not->toContain('logdrain_axiom_api_key'); expect($body)->not->toContain('"logdrain_axiom_api_key":');
});
test('read-sensitive token sees nested sentinel_token via destination.server.settings', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/applications');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"sentinel_token":');
expect($body)->toContain('"sentinel_custom_url":');
}); });
}); });
@ -168,7 +182,7 @@ function makeTeamUser(): array
expect($body)->not->toContain('external_db_url'); expect($body)->not->toContain('external_db_url');
}); });
test('read sensitive token sees postgres_password', function () { test('read sensitive token sees postgres_password and nested sentinel_token', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']); $token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([ $response = $this->withHeaders([
@ -178,7 +192,8 @@ function makeTeamUser(): array
$response->assertStatus(200); $response->assertStatus(200);
$body = $response->getContent(); $body = $response->getContent();
expect($body)->toContain('postgres_password'); expect($body)->toContain('"postgres_password":');
expect($body)->toContain('internal_db_url'); expect($body)->toContain('"internal_db_url":');
expect($body)->toContain('"sentinel_token":');
}); });
}); });