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.
This commit is contained in:
Andras Bacsai
2026-06-04 16:51:52 +02:00
parent 2fcc42b0a9
commit 70eda65d19
7 changed files with 743 additions and 2 deletions
@@ -61,11 +61,37 @@ class ApplicationsController extends Controller
$application->makeHidden([
'private_key_id',
]);
$this->hideNestedServerSecrets($application);
}
return serializeApiResponse($application);
}
private function hideNestedServerSecrets($model): void
{
$server = $model->destination?->server ?? null;
if (! $server) {
return;
}
$server->makeHidden([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
$settings = $server->settings ?? null;
if ($settings) {
$settings->makeHidden([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
@@ -51,11 +51,37 @@ class DatabasesController extends Controller
'mariadb_root_password',
]);
$this->exposeNestedServerSecrets($database);
} else {
$this->hideNestedServerSecrets($database);
}
return serializeApiResponse($database);
}
private function hideNestedServerSecrets(Model $model): void
{
$server = $model->destination?->server;
if ($server === null) {
return;
}
$server->makeHidden([
'logdrain_axiom_api_key',
'logdrain_newrelic_license_key',
]);
if ($server->settings !== null) {
$server->settings->makeHidden([
'sentinel_token',
'sentinel_custom_url',
'logdrain_newrelic_license_key',
'logdrain_axiom_api_key',
'logdrain_custom_config',
'logdrain_custom_config_parser',
]);
}
}
/**
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
* relations for callers with the `read:sensitive` or `root` token ability.
+4
View File
@@ -2,8 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class CloudProviderToken extends BaseModel
{
use HasFactory;
protected $fillable = [
'team_id',
'provider',
@@ -0,0 +1,30 @@
<?php
namespace Database\Factories;
use App\Models\CloudProviderToken;
use App\Models\Team;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CloudProviderToken>
*/
class CloudProviderTokenFactory extends Factory
{
protected $model = CloudProviderToken::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'team_id' => Team::factory(),
'provider' => 'hetzner',
'token' => $this->faker->sha256(),
'name' => $this->faker->words(2, true),
];
}
}
@@ -11,12 +11,20 @@ return new class extends Migration
*/
public function up(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE text USING configuration_snapshot::text');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
}
public function down(): void
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_snapshot TYPE json USING configuration_snapshot::json');
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
}
+43 -2
View File
@@ -171,6 +171,44 @@ describe('GET /api/v1/cloud-tokens/{uuid}', function () {
$response->assertStatus(404);
});
test('read token does not include provider token value by UUID', function () {
$token = CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Hidden Token Detail',
'provider' => 'hetzner',
'token' => 'hidden-cloud-provider-token-detail',
]);
$readToken = $this->user->createToken('read-token', ['read'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertSuccessful();
expect($response->getContent())->not->toContain('"token":');
});
test('read sensitive token includes provider token value by UUID', function () {
$token = CloudProviderToken::create([
'team_id' => $this->team->id,
'name' => 'Visible Token Detail',
'provider' => 'hetzner',
'token' => 'visible-cloud-provider-token-detail',
]);
$readSensitiveToken = $this->user->createToken('read-sensitive-token', ['read', 'read:sensitive'])->plainTextToken;
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$readSensitiveToken,
'Content-Type' => 'application/json',
])->getJson("/api/v1/cloud-tokens/{$token->uuid}");
$response->assertSuccessful();
$response->assertJsonFragment(['token' => 'visible-cloud-provider-token-detail']);
});
});
describe('POST /api/v1/cloud-tokens', function () {
@@ -345,8 +383,11 @@ describe('PATCH /api/v1/cloud-tokens/{uuid}', function () {
'Content-Type' => 'application/json',
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['name']);
$response->assertStatus(400);
$response->assertJson([
'message' => 'Invalid request.',
'error' => 'Invalid JSON.',
]);
});
test('cannot update token from another team', function () {
@@ -101,6 +101,53 @@ describe('GET /api/v1/servers sensitive field gating', function () {
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
});
test('read token does not leak sentinel or logdrain fields in server detail', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/servers/{$this->server->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('sentinel_token');
expect($body)->not->toContain('sentinel_custom_url');
expect($body)->not->toContain('logdrain_axiom_api_key');
expect($body)->not->toContain('logdrain_newrelic_license_key');
expect($body)->not->toContain('logdrain_custom_config');
});
test('read sensitive token sees sentinel and logdrain fields in server detail', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/servers/{$this->server->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('sentinel_token');
expect($body)->toContain('sentinel_custom_url');
expect($body)->toContain('logdrain_axiom_api_key');
});
test('server resources response does not leak server secrets', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/servers/{$this->server->uuid}/resources");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('sentinel_token');
expect($body)->not->toContain('logdrain_axiom_api_key');
expect($body)->not->toContain('logdrain_newrelic_license_key');
});
});
describe('GET /api/v1/security/keys sensitive field gating', function () {
@@ -140,6 +187,30 @@ describe('GET /api/v1/security/keys sensitive field gating', function () {
expect($response->getContent())->toContain('"private_key":');
});
test('read token does not leak private key material in key list', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/security/keys');
$response->assertStatus(200);
expect($response->getContent())->not->toContain('"private_key":');
});
test('read sensitive token sees private key material in key list', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/security/keys');
$response->assertStatus(200);
expect($response->getContent())->toContain('"private_key":');
});
});
describe('GET /api/v1/deployments sensitive field gating', function () {
@@ -194,6 +265,54 @@ describe('GET /api/v1/deployments sensitive field gating', function () {
expect($response->getContent())->toContain('"logs":');
});
test('read token does not leak deployment logs in deployment list', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/deployments');
$response->assertStatus(200);
expect($response->getContent())->not->toContain('"logs":');
});
test('read sensitive token sees deployment logs in deployment list', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson('/api/v1/deployments');
$response->assertStatus(200);
expect($response->getContent())->toContain('"logs":');
});
test('read token does not leak deployment logs in application deployment history', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/deployments/applications/{$this->application->uuid}");
$response->assertStatus(200);
expect($response->getContent())->not->toContain('"logs":');
});
test('read sensitive token sees deployment logs in application deployment history', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/deployments/applications/{$this->application->uuid}");
$response->assertStatus(200);
expect($response->getContent())->toContain('"logs":');
});
});
describe('GET /api/v1/applications nested-relation scrubbing', function () {
@@ -242,6 +361,174 @@ describe('GET /api/v1/applications nested-relation scrubbing', function () {
expect($body)->toContain('"sentinel_token":');
expect($body)->toContain('"sentinel_custom_url":');
});
test('read token does not leak application detail sensitive fields', function () {
$this->application->forceFill([
'manual_webhook_secret_github' => 'super-secret-github-webhook',
'http_basic_auth_password' => 'super-secret-basic-password',
])->save();
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/applications/{$this->application->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('"manual_webhook_secret_github":')
->and($body)->not->toContain('"http_basic_auth_password":')
->and($body)->not->toContain('"sentinel_token":');
});
test('read sensitive token sees application detail sensitive fields', function () {
$this->application->forceFill([
'manual_webhook_secret_github' => 'super-secret-github-webhook',
'http_basic_auth_password' => 'super-secret-basic-password',
])->save();
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/applications/{$this->application->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"manual_webhook_secret_github":')
->and($body)->toContain('"http_basic_auth_password":')
->and($body)->toContain('"sentinel_token":');
});
test('application env responses hide values for read tokens and reveal them for sensitive tokens', function () {
$this->application->environment_variables()->create([
'key' => 'APP_SECRET',
'value' => 'super-secret-app-env',
]);
$readToken = makeApiToken($this->user, $this->team, ['read']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$readResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->getJson("/api/v1/applications/{$this->application->uuid}/envs");
$readResponse->assertStatus(200);
$sensitiveResponse->assertStatus(200);
expect($readResponse->getContent())->not->toContain('"value":')
->and($readResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('application create env response does not include secret values', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->postJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'APP_CREATE_SECRET',
'value' => 'super-secret-app-create-env',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->postJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'APP_CREATE_SENSITIVE_SECRET',
'value' => 'super-secret-app-create-sensitive-env',
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->not->toContain('"value":')
->and($sensitiveResponse->getContent())->not->toContain('"real_value":');
});
test('application update env response hides values for write tokens and reveals them for sensitive tokens', function () {
$this->application->environment_variables()->create([
'key' => 'APP_UPDATE_SECRET',
'value' => 'old-app-update-secret',
]);
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'APP_UPDATE_SECRET',
'value' => 'hidden-app-update-secret',
'is_multiline' => false,
'is_shown_once' => false,
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/applications/{$this->application->uuid}/envs", [
'key' => 'APP_UPDATE_SECRET',
'value' => 'visible-app-update-secret',
'is_multiline' => false,
'is_shown_once' => false,
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('application bulk env response hides values for write tokens and reveals them for sensitive tokens', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/applications/{$this->application->uuid}/envs/bulk", [
'data' => [[
'key' => 'APP_BULK_SECRET',
'value' => 'hidden-app-bulk-secret',
]],
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/applications/{$this->application->uuid}/envs/bulk", [
'data' => [[
'key' => 'APP_BULK_SENSITIVE_SECRET',
'value' => 'visible-app-bulk-secret',
]],
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
});
describe('GET /api/v1/databases sensitive field gating', function () {
@@ -293,6 +580,161 @@ describe('GET /api/v1/databases sensitive field gating', function () {
expect($body)->toContain('"sentinel_token":');
});
test('read token does not leak database detail sensitive fields', function () {
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/databases/{$this->database->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('"postgres_password":')
->and($body)->not->toContain('"internal_db_url":')
->and($body)->not->toContain('"external_db_url":')
->and($body)->not->toContain('"sentinel_token":');
});
test('read sensitive token sees database detail sensitive fields', function () {
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/databases/{$this->database->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"postgres_password":')
->and($body)->toContain('"internal_db_url":')
->and($body)->toContain('"sentinel_token":');
});
test('database env responses hide values for read tokens and reveal them for sensitive tokens', function () {
$this->database->environment_variables()->create([
'key' => 'DB_SECRET',
'value' => 'super-secret-db-env',
]);
$readToken = makeApiToken($this->user, $this->team, ['read']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$readResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson("/api/v1/databases/{$this->database->uuid}/envs");
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->getJson("/api/v1/databases/{$this->database->uuid}/envs");
$readResponse->assertStatus(200);
$sensitiveResponse->assertStatus(200);
expect($readResponse->getContent())->not->toContain('"value":')
->and($readResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('database create env response hides values for write tokens and reveals them for sensitive tokens', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->postJson("/api/v1/databases/{$this->database->uuid}/envs", [
'key' => 'DB_CREATE_SECRET',
'value' => 'hidden-db-create-secret',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->postJson("/api/v1/databases/{$this->database->uuid}/envs", [
'key' => 'DB_CREATE_SENSITIVE_SECRET',
'value' => 'visible-db-create-secret',
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('database update env response hides values for write tokens and reveals them for sensitive tokens', function () {
$this->database->environment_variables()->create([
'key' => 'DB_UPDATE_SECRET',
'value' => 'old-db-update-secret',
]);
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/databases/{$this->database->uuid}/envs", [
'key' => 'DB_UPDATE_SECRET',
'value' => 'hidden-db-update-secret',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/databases/{$this->database->uuid}/envs", [
'key' => 'DB_UPDATE_SECRET',
'value' => 'visible-db-update-secret',
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('database bulk env response hides values for write tokens and reveals them for sensitive tokens', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/databases/{$this->database->uuid}/envs/bulk", [
'data' => [[
'key' => 'DB_BULK_SECRET',
'value' => 'hidden-db-bulk-secret',
]],
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/databases/{$this->database->uuid}/envs/bulk", [
'data' => [[
'key' => 'DB_BULK_SENSITIVE_SECRET',
'value' => 'visible-db-bulk-secret',
]],
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('project database list can eager load nested destination server settings', function () {
$databases = $this->project->databases(['destination.server.settings']);
$database = $databases->firstWhere('id', $this->database->id);
@@ -348,6 +790,170 @@ describe('GET /api/v1/services sensitive field gating', function () {
->and($body)->toContain('"sentinel_custom_url":');
});
test('read token does not leak service detail sensitive fields', function () {
$this->service->forceFill([
'docker_compose_raw' => 'services: secret',
'docker_compose' => 'services: rendered',
])->save();
$token = makeApiToken($this->user, $this->team, ['read']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/services/{$this->service->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->not->toContain('"docker_compose_raw":')
->and($body)->not->toContain('"docker_compose":')
->and($body)->not->toContain('"sentinel_token":');
});
test('read sensitive token sees service detail sensitive fields', function () {
$this->service->forceFill([
'docker_compose_raw' => 'services: secret',
'docker_compose' => 'services: rendered',
])->save();
$token = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$token,
])->getJson("/api/v1/services/{$this->service->uuid}");
$response->assertStatus(200);
$body = $response->getContent();
expect($body)->toContain('"docker_compose_raw":')
->and($body)->toContain('"docker_compose":')
->and($body)->toContain('"sentinel_token":');
});
test('service env responses hide values for read tokens and reveal them for sensitive tokens', function () {
$this->service->environment_variables()->create([
'key' => 'SERVICE_SECRET',
'value' => 'super-secret-service-env',
]);
$readToken = makeApiToken($this->user, $this->team, ['read']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$readResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$readToken,
])->getJson("/api/v1/services/{$this->service->uuid}/envs");
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->getJson("/api/v1/services/{$this->service->uuid}/envs");
$readResponse->assertStatus(200);
$sensitiveResponse->assertStatus(200);
expect($readResponse->getContent())->not->toContain('"value":')
->and($readResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('service create env response hides values for write tokens and reveals them for sensitive tokens', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->postJson("/api/v1/services/{$this->service->uuid}/envs", [
'key' => 'SERVICE_CREATE_SECRET',
'value' => 'hidden-service-create-secret',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->postJson("/api/v1/services/{$this->service->uuid}/envs", [
'key' => 'SERVICE_CREATE_SENSITIVE_SECRET',
'value' => 'visible-service-create-secret',
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('service update env response hides values for write tokens and reveals them for sensitive tokens', function () {
$this->service->environment_variables()->create([
'key' => 'SERVICE_UPDATE_SECRET',
'value' => 'old-service-update-secret',
]);
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/services/{$this->service->uuid}/envs", [
'key' => 'SERVICE_UPDATE_SECRET',
'value' => 'hidden-service-update-secret',
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/services/{$this->service->uuid}/envs", [
'key' => 'SERVICE_UPDATE_SECRET',
'value' => 'visible-service-update-secret',
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
test('service bulk env response hides values for write tokens and reveals them for sensitive tokens', function () {
$writeToken = makeApiToken($this->user, $this->team, ['write']);
$sensitiveToken = makeApiToken($this->user, $this->team, ['write', 'read:sensitive']);
$writeResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$writeToken,
])->patchJson("/api/v1/services/{$this->service->uuid}/envs/bulk", [
'data' => [[
'key' => 'SERVICE_BULK_SECRET',
'value' => 'hidden-service-bulk-secret',
]],
]);
auth()->forgetGuards();
$sensitiveResponse = $this->withHeaders([
'Authorization' => 'Bearer '.$sensitiveToken,
])->patchJson("/api/v1/services/{$this->service->uuid}/envs/bulk", [
'data' => [[
'key' => 'SERVICE_BULK_SENSITIVE_SECRET',
'value' => 'visible-service-bulk-secret',
]],
]);
$writeResponse->assertStatus(201);
$sensitiveResponse->assertStatus(201);
expect($writeResponse->getContent())->not->toContain('"value":')
->and($writeResponse->getContent())->not->toContain('"real_value":')
->and($sensitiveResponse->getContent())->toContain('"value":')
->and($sensitiveResponse->getContent())->toContain('"real_value":');
});
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();