diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index d1825e1e3..f3193e9f2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -61,11 +61,37 @@ private function removeSensitiveData($application) $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. diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index a8ad583f3..b36e557c0 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -51,11 +51,37 @@ private function removeSensitiveData($database) '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. diff --git a/app/Models/CloudProviderToken.php b/app/Models/CloudProviderToken.php index 02c662d9d..27214f1d1 100644 --- a/app/Models/CloudProviderToken.php +++ b/app/Models/CloudProviderToken.php @@ -2,8 +2,12 @@ namespace App\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; + class CloudProviderToken extends BaseModel { + use HasFactory; + protected $fillable = [ 'team_id', 'provider', diff --git a/database/factories/CloudProviderTokenFactory.php b/database/factories/CloudProviderTokenFactory.php new file mode 100644 index 000000000..303f08fc8 --- /dev/null +++ b/database/factories/CloudProviderTokenFactory.php @@ -0,0 +1,30 @@ + + */ +class CloudProviderTokenFactory extends Factory +{ + protected $model = CloudProviderToken::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'team_id' => Team::factory(), + 'provider' => 'hetzner', + 'token' => $this->faker->sha256(), + 'name' => $this->faker->words(2, true), + ]; + } +} diff --git a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php index 123fd226d..60553264c 100644 --- a/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php +++ b/database/migrations/2026_05_29_000000_encrypt_application_deployment_configuration_columns.php @@ -11,12 +11,20 @@ */ 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'); } diff --git a/tests/Feature/CloudProviderTokenApiTest.php b/tests/Feature/CloudProviderTokenApiTest.php index 7f7b2546b..0221faeac 100644 --- a/tests/Feature/CloudProviderTokenApiTest.php +++ b/tests/Feature/CloudProviderTokenApiTest.php @@ -171,6 +171,44 @@ $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 @@ '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 () { diff --git a/tests/Feature/Security/ApiSensitiveFieldsTest.php b/tests/Feature/Security/ApiSensitiveFieldsTest.php index 09040aca7..2d2d7a3ef 100644 --- a/tests/Feature/Security/ApiSensitiveFieldsTest.php +++ b/tests/Feature/Security/ApiSensitiveFieldsTest.php @@ -101,6 +101,53 @@ function makeTeamUser(): array $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 @@ function makeTeamUser(): array 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 @@ function makeTeamUser(): array 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 @@ function makeTeamUser(): array 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 @@ function makeTeamUser(): array 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 @@ function makeTeamUser(): array ->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();