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:
parent
2fcc42b0a9
commit
70eda65d19
7 changed files with 743 additions and 2 deletions
|
|
@ -61,11 +61,37 @@ private function removeSensitiveData($application)
|
||||||
$application->makeHidden([
|
$application->makeHidden([
|
||||||
'private_key_id',
|
'private_key_id',
|
||||||
]);
|
]);
|
||||||
|
$this->hideNestedServerSecrets($application);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeApiResponse($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
|
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
|
||||||
* relations for callers with the `read:sensitive` or `root` token ability.
|
* relations for callers with the `read:sensitive` or `root` token ability.
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,37 @@ private function removeSensitiveData($database)
|
||||||
'mariadb_root_password',
|
'mariadb_root_password',
|
||||||
]);
|
]);
|
||||||
$this->exposeNestedServerSecrets($database);
|
$this->exposeNestedServerSecrets($database);
|
||||||
|
} else {
|
||||||
|
$this->hideNestedServerSecrets($database);
|
||||||
}
|
}
|
||||||
|
|
||||||
return serializeApiResponse($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
|
* Expose sensitive fields on eager-loaded nested Server + ServerSetting
|
||||||
* relations for callers with the `read:sensitive` or `root` token ability.
|
* relations for callers with the `read:sensitive` or `root` token ability.
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,12 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
class CloudProviderToken extends BaseModel
|
class CloudProviderToken extends BaseModel
|
||||||
{
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'team_id',
|
'team_id',
|
||||||
'provider',
|
'provider',
|
||||||
|
|
|
||||||
30
database/factories/CloudProviderTokenFactory.php
Normal file
30
database/factories/CloudProviderTokenFactory.php
Normal file
|
|
@ -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 @@
|
||||||
*/
|
*/
|
||||||
public function up(): void
|
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_snapshot TYPE text USING configuration_snapshot::text');
|
||||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE text USING configuration_diff::text');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function down(): void
|
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_snapshot TYPE json USING configuration_snapshot::json');
|
||||||
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
|
DB::statement('ALTER TABLE application_deployment_queues ALTER COLUMN configuration_diff TYPE json USING configuration_diff::json');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,44 @@
|
||||||
|
|
||||||
$response->assertStatus(404);
|
$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 () {
|
describe('POST /api/v1/cloud-tokens', function () {
|
||||||
|
|
@ -345,8 +383,11 @@
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
|
])->patchJson("/api/v1/cloud-tokens/{$token->uuid}", []);
|
||||||
|
|
||||||
$response->assertStatus(422);
|
$response->assertStatus(400);
|
||||||
$response->assertJsonValidationErrors(['name']);
|
$response->assertJson([
|
||||||
|
'message' => 'Invalid request.',
|
||||||
|
'error' => 'Invalid JSON.',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cannot update token from another team', function () {
|
test('cannot update token from another team', function () {
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,53 @@ function makeTeamUser(): array
|
||||||
$body = $response->getContent();
|
$body = $response->getContent();
|
||||||
expect($body)->toContain('sentinel_token');
|
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 () {
|
describe('GET /api/v1/security/keys sensitive field gating', function () {
|
||||||
|
|
@ -140,6 +187,30 @@ function makeTeamUser(): array
|
||||||
|
|
||||||
expect($response->getContent())->toContain('"private_key":');
|
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 () {
|
describe('GET /api/v1/deployments sensitive field gating', function () {
|
||||||
|
|
@ -194,6 +265,54 @@ function makeTeamUser(): array
|
||||||
|
|
||||||
expect($response->getContent())->toContain('"logs":');
|
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 () {
|
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_token":');
|
||||||
expect($body)->toContain('"sentinel_custom_url":');
|
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 () {
|
describe('GET /api/v1/databases sensitive field gating', function () {
|
||||||
|
|
@ -293,6 +580,161 @@ function makeTeamUser(): array
|
||||||
expect($body)->toContain('"sentinel_token":');
|
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 () {
|
test('project database list can eager load nested destination server settings', function () {
|
||||||
$databases = $this->project->databases(['destination.server.settings']);
|
$databases = $this->project->databases(['destination.server.settings']);
|
||||||
$database = $databases->firstWhere('id', $this->database->id);
|
$database = $databases->firstWhere('id', $this->database->id);
|
||||||
|
|
@ -348,6 +790,170 @@ function makeTeamUser(): array
|
||||||
->and($body)->toContain('"sentinel_custom_url":');
|
->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 () {
|
test('read sensitive service list eager loads nested server settings once', function () {
|
||||||
$secondServer = Server::factory()->create(['team_id' => $this->team->id]);
|
$secondServer = Server::factory()->create(['team_id' => $this->team->id]);
|
||||||
$secondDestination = $secondServer->standaloneDockers()->firstOrFail();
|
$secondDestination = $secondServer->standaloneDockers()->firstOrFail();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue