fix(api): gate service server secrets by sensitive scope

Only eager load nested server settings for service responses when the API token has read:sensitive, preventing read-only tokens from exposing sentinel fields while preserving sensitive access.
This commit is contained in:
Andras Bacsai 2026-05-11 11:53:22 +02:00
parent c97f916052
commit 12aba41d9a
2 changed files with 89 additions and 4 deletions

View file

@ -14,6 +14,7 @@
use App\Models\Server;
use App\Models\Service;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@ -50,9 +51,9 @@ private function removeSensitiveData($service)
* Handles both single models and Eloquent Collections (the listing endpoint
* passes a Collection of Services per project to removeSensitiveData()).
*/
private function exposeNestedServerSecrets($model): void
private function exposeNestedServerSecrets(Model|Collection $model): void
{
if ($model instanceof Collection || $model instanceof \Illuminate\Database\Eloquent\Collection) {
if ($model instanceof Collection) {
foreach ($model as $item) {
$this->exposeNestedServerSecrets($item);
}
@ -215,8 +216,12 @@ public function services(Request $request)
}
$projects = Project::where('team_id', $teamId)->get();
$services = collect();
$serviceRelations = $request->attributes->get('can_read_sensitive', false) === true
? ['destination.server.settings']
: [];
foreach ($projects as $project) {
$services->push($project->services()->get());
$services->push($project->services()->with($serviceRelations)->get());
}
foreach ($services as $service) {
$service = $this->removeSensitiveData($service);
@ -771,7 +776,12 @@ public function service_by_uuid(Request $request)
$this->authorize('view', $service);
$service = $service->load(['applications', 'databases']);
$serviceRelations = ['applications', 'databases'];
if ($request->attributes->get('can_read_sensitive', false) === true) {
$serviceRelations[] = 'destination.server.settings';
}
$service = $service->load($serviceRelations);
return response()->json($this->removeSensitiveData($service));
}

View file

@ -5,6 +5,7 @@
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
@ -207,3 +208,77 @@ function makeTeamUser(): array
->and($database->destination->server->relationLoaded('settings'))->toBeTrue();
});
});
describe('GET /api/v1/services sensitive field gating', function () {
beforeEach(function () {
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$this->service = Service::factory()->create([
'server_id' => $this->server->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
});
test('read token does not leak service or nested server sensitive fields', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/services');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('"docker_compose_raw":')
->and($body)->not->toContain('"sentinel_token":')
->and($body)->not->toContain('"sentinel_custom_url":');
});
test('read sensitive token sees service and nested server sensitive fields', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/services');
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"docker_compose_raw":')
->and($body)->toContain('"sentinel_token":')
->and($body)->toContain('"sentinel_custom_url":');
});
test('read sensitive service list eager loads nested server settings once', function () {
$secondServer = Server::factory()->create(['team_id' => $this->team->id]);
$secondDestination = $secondServer->standaloneDockers()->firstOrFail();
Service::factory()->create([
'server_id' => $secondServer->id,
'destination_id' => $secondDestination->id,
'destination_type' => $secondDestination->getMorphClass(),
'environment_id' => $this->environment->id,
]);
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$serverSettingsQueries = collect();
DB::listen(function ($query) use ($serverSettingsQueries) {
if (str_contains($query->sql, 'from "server_settings"')) {
$serverSettingsQueries->push($query->sql);
}
});
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/services');
$response->assertStatus(200);
expect($serverSettingsQueries->contains(fn (string $sql) => str_contains($sql, '"server_settings"."server_id" in')))->toBeTrue();
});
});