Skip to content

Commit 70eda65

Browse files
committed
fix(api): hide nested server secrets from read tokens
Require read:sensitive for nested server logdrain and sentinel fields in application and database API responses. Limit deployment configuration column migration SQL to PostgreSQL.
1 parent 2fcc42b commit 70eda65

7 files changed

Lines changed: 743 additions & 2 deletions

File tree

‎app/Http/Controllers/Api/ApplicationsController.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,37 @@ private function removeSensitiveData($application)
6161
$application->makeHidden([
6262
'private_key_id',
6363
]);
64+
$this->hideNestedServerSecrets($application);
6465
}
6566

6667
return serializeApiResponse($application);
6768
}
6869

70+
private function hideNestedServerSecrets($model): void
71+
{
72+
$server = $model->destination?->server ?? null;
73+
if (! $server) {
74+
return;
75+
}
76+
77+
$server->makeHidden([
78+
'logdrain_axiom_api_key',
79+
'logdrain_newrelic_license_key',
80+
]);
81+
82+
$settings = $server->settings ?? null;
83+
if ($settings) {
84+
$settings->makeHidden([
85+
'sentinel_token',
86+
'sentinel_custom_url',
87+
'logdrain_newrelic_license_key',
88+
'logdrain_axiom_api_key',
89+
'logdrain_custom_config',
90+
'logdrain_custom_config_parser',
91+
]);
92+
}
93+
}
94+
6995
/**
7096
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
7197
* relations for callers with the `read:sensitive` or `root` token ability.

‎app/Http/Controllers/Api/DatabasesController.php‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,37 @@ private function removeSensitiveData($database)
5151
'mariadb_root_password',
5252
]);
5353
$this->exposeNestedServerSecrets($database);
54+
} else {
55+
$this->hideNestedServerSecrets($database);
5456
}
5557

5658
return serializeApiResponse($database);
5759
}
5860

61+
private function hideNestedServerSecrets(Model $model): void
62+
{
63+
$server = $model->destination?->server;
64+
if ($server === null) {
65+
return;
66+
}
67+
68+
$server->makeHidden([
69+
'logdrain_axiom_api_key',
70+
'logdrain_newrelic_license_key',
71+
]);
72+
73+
if ($server->settings !== null) {
74+
$server->settings->makeHidden([
75+
'sentinel_token',
76+
'sentinel_custom_url',
77+
'logdrain_newrelic_license_key',
78+
'logdrain_axiom_api_key',
79+
'logdrain_custom_config',
80+
'logdrain_custom_config_parser',
81+
]);
82+
}
83+
}
84+
5985
/**
6086
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
6187
* relations for callers with the `read:sensitive` or `root` token ability.

‎app/Models/CloudProviderToken.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
namespace App\Models;
44

5+
use Illuminate\Database\Eloquent\Factories\HasFactory;
6+
57
class CloudProviderToken extends BaseModel
68
{
9+
use HasFactory;
10+
711
protected $fillable = [
812
'team_id',
913
'provider',
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
namespace Database\Factories;
4+
5+
use App\Models\CloudProviderToken;
6+
use App\Models\Team;
7+
use Illuminate\Database\Eloquent\Factories\Factory;
8+
9+
/**
10+
* @extends Factory<CloudProviderToken>
11+
*/
12+
class CloudProviderTokenFactory extends Factory
13+
{
14+
protected $model = CloudProviderToken::class;
15+
16+
/**
17+
* Define the model's default state.
18+
*
19+
* @return array<string, mixed>
20+
*/
21+
public function definition(): array
22+
{
23+
return [
24+
'team_id' => Team::factory(),
25+
'provider' => 'hetzner',
26+
'token' => $this->faker->sha256(),
27+
'name' => $this->faker->words(2, true),
28+
];
29+
}
30+
}

‎database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,20 @@
1111
*/
1212
public function up(): void
1313
{
14+
if (DB::connection()->getDriverName() !== 'pgsql') {
15+
return;
16+
}
17+
1418
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
1519
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
1620
}
1721

1822
public function down(): void
1923
{
24+
if (DB::connection()->getDriverName() !== 'pgsql') {
25+
return;
26+
}
27+
2028
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
2129
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
2230
}

‎tests/Feature/CloudProviderTokenApiTest.php‎

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,44 @@
171171

172172
$response->assertStatus(404);
173173
});
174+
175+
test('read token does not include provider token value by UUID', function () {
176+
$token = CloudProviderToken::create([
177+
'team_id' => $this->team->id,
178+
'name' => 'Hidden Token Detail',
179+
'provider' => 'hetzner',
180+
'token' => 'hidden-cloud-provider-token-detail',
181+
]);
182+
183+
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
184+
185+
$response = $this->withHeaders([
186+
'Authorization' => 'Bearer '.$readToken,
187+
'Content-Type' => 'application/json',
188+
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
189+
190+
$response->assertSuccessful();
191+
expect($response->getContent())->not->toContain('"token":');
192+
});
193+
194+
test('read sensitive token includes provider token value by UUID', function () {
195+
$token = CloudProviderToken::create([
196+
'team_id' => $this->team->id,
197+
'name' => 'Visible Token Detail',
198+
'provider' => 'hetzner',
199+
'token' => 'visible-cloud-provider-token-detail',
200+
]);
201+
202+
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
203+
204+
$response = $this->withHeaders([
205+
'Authorization' => 'Bearer '.$readSensitiveToken,
206+
'Content-Type' => 'application/json',
207+
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
208+
209+
$response->assertSuccessful();
210+
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token-detail']);
211+
});
174212
});
175213

176214
describe('POST /api/v1/cloud-tokens', function () {
@@ -345,8 +383,11 @@
345383
'Content-Type' => 'application/json',
346384
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
347385

348-
$response->assertStatus(422);
349-
$response->assertJsonValidationErrors(['name']);
386+
$response->assertStatus(400);
387+
$response->assertJson([
388+
'message' => 'Invalid request.',
389+
'error' => 'Invalid JSON.',
390+
]);
350391
});
351392

352393
test('cannot update token from another team', function () {

0 commit comments

Comments
 (0)