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:
parent
c97f916052
commit
12aba41d9a
2 changed files with 89 additions and 4 deletions
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue