mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(audit): log security resource changes under the resource team
Integration token, cloud provider token and cloud-init script edits were audit-logged under the session team. After a team switch in another tab, the owning team missed the entry and the other team saw the resource name. - Use the team of the edited resource for every audit row - Remove credentials, query and fragment from secret manager base URLs in the audit log
This commit is contained in:
@@ -67,7 +67,7 @@ class Show extends Component
|
||||
]);
|
||||
|
||||
auditLog('ui.cloud_init_script.updated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->cloudInitScript->team_id,
|
||||
'cloud_init_script_id' => $this->cloudInitScript->id,
|
||||
'cloud_init_script_name' => $this->cloudInitScript->name,
|
||||
]);
|
||||
@@ -86,7 +86,7 @@ class Show extends Component
|
||||
$this->cloudInitScript->delete();
|
||||
|
||||
auditLog('ui.cloud_init_script.deleted', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->cloudInitScript->team_id,
|
||||
'cloud_init_script_id' => $scriptId,
|
||||
'cloud_init_script_name' => $scriptName,
|
||||
]);
|
||||
|
||||
@@ -67,7 +67,7 @@ class Show extends Component
|
||||
]);
|
||||
|
||||
auditLog('ui.cloud_token.updated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->cloudProviderToken->team_id,
|
||||
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $this->cloudProviderToken->name,
|
||||
'provider' => $this->cloudProviderToken->provider,
|
||||
@@ -98,7 +98,7 @@ class Show extends Component
|
||||
);
|
||||
|
||||
auditLog('ui.cloud_token.validated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->cloudProviderToken->team_id,
|
||||
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $this->cloudProviderToken->name,
|
||||
'provider' => $this->cloudProviderToken->provider,
|
||||
@@ -118,7 +118,7 @@ class Show extends Component
|
||||
}
|
||||
|
||||
auditLog('ui.cloud_token.deleted', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->cloudProviderToken->team_id,
|
||||
'cloud_token_uuid' => $this->cloudProviderToken->uuid,
|
||||
'cloud_token_name' => $this->cloudProviderToken->name,
|
||||
'provider' => $this->cloudProviderToken->provider,
|
||||
|
||||
@@ -131,7 +131,7 @@ class IntegrationTokenEditor extends Component
|
||||
$this->loadZones();
|
||||
|
||||
auditLog('ui.integration_token.updated', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->integrationToken->team_id,
|
||||
'integration_token_uuid' => $this->integrationToken->uuid,
|
||||
'integration_token_name' => $this->integrationToken->name,
|
||||
'provider' => $this->integrationToken->provider,
|
||||
@@ -141,12 +141,12 @@ class IntegrationTokenEditor extends Component
|
||||
|
||||
if (in_array('base_url', $changedConnectionFields, true)) {
|
||||
auditLog('ui.integration_token.base_url_changed', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->integrationToken->team_id,
|
||||
'integration_token_uuid' => $this->integrationToken->uuid,
|
||||
'integration_token_name' => $this->integrationToken->name,
|
||||
'provider' => $provider,
|
||||
'previous_base_url' => data_get($storedMetadata, 'base_url'),
|
||||
'base_url' => data_get($metadata, 'base_url'),
|
||||
'previous_base_url' => $this->baseUrlForAudit(data_get($storedMetadata, 'base_url')),
|
||||
'base_url' => $this->baseUrlForAudit(data_get($metadata, 'base_url')),
|
||||
], 'warning');
|
||||
}
|
||||
|
||||
@@ -190,6 +190,25 @@ class IntegrationTokenEditor extends Component
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base URL without user info, query, or fragment, which can carry credentials.
|
||||
*/
|
||||
private function baseUrlForAudit(mixed $url): ?string
|
||||
{
|
||||
if (! is_string($url) || $url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts) || ! isset($parts['scheme'], $parts['host'])) {
|
||||
return '[invalid-url]';
|
||||
}
|
||||
|
||||
return $parts['scheme'].'://'.$parts['host']
|
||||
.(isset($parts['port']) ? ':'.$parts['port'] : '')
|
||||
.($parts['path'] ?? '');
|
||||
}
|
||||
|
||||
private function reenterSecretMessage(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
@@ -221,7 +240,7 @@ class IntegrationTokenEditor extends Component
|
||||
$this->integrationToken->delete();
|
||||
|
||||
auditLog('ui.integration_token.deleted', [
|
||||
'team_id' => currentTeam()->id,
|
||||
'team_id' => $this->integrationToken->team_id,
|
||||
'integration_token_uuid' => $uuid,
|
||||
'integration_token_name' => $name,
|
||||
'provider' => $provider,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\CloudInitScript\Show as CloudInitScriptShow;
|
||||
use App\Livewire\Security\CloudProviderToken\Show as CloudProviderTokenShow;
|
||||
use App\Livewire\Security\IntegrationTokenEditor;
|
||||
use App\Models\AuditEvent;
|
||||
use App\Models\CloudInitScript;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Once;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutDefer();
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
Once::flush();
|
||||
|
||||
$this->resourceTeam = Team::factory()->create();
|
||||
$this->otherTeam = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->resourceTeam->members()->attach($this->user->id, ['role' => 'admin']);
|
||||
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
|
||||
session(['currentTeam' => $this->resourceTeam]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
function switchSecurityAuditSessionTeam(Team $team): void
|
||||
{
|
||||
session(['currentTeam' => $team]);
|
||||
Cache::flush();
|
||||
Once::flush();
|
||||
}
|
||||
|
||||
function expectSecurityAuditEventInResourceTeam(string $event, Team $resourceTeam, Team $otherTeam, array $secrets): void
|
||||
{
|
||||
$auditEvent = AuditEvent::query()->where('event', $event)->sole();
|
||||
|
||||
expect($auditEvent->team_id)->toBe($resourceTeam->id)
|
||||
->and(AuditEvent::query()->where('team_id', $otherTeam->id)->exists())->toBeFalse();
|
||||
|
||||
$stored = json_encode($auditEvent->toArray());
|
||||
foreach ($secrets as $secret) {
|
||||
expect($stored)->not->toContain($secret);
|
||||
}
|
||||
}
|
||||
|
||||
test('integration token updates are audited in the token team after a session team switch', function () {
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->resourceTeam->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.st.super-secret',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]);
|
||||
switchSecurityAuditSessionTeam($this->otherTeam);
|
||||
|
||||
$component->set('name', 'Renamed secrets')->call('save')->assertHasNoErrors();
|
||||
|
||||
expect($token->fresh()->name)->toBe('Renamed secrets');
|
||||
expectSecurityAuditEventInResourceTeam('ui.integration_token.updated', $this->resourceTeam, $this->otherTeam, ['dp.st.super-secret']);
|
||||
});
|
||||
|
||||
test('integration token base url changes are audited in the token team without url credentials', function () {
|
||||
Http::fake(['*' => Http::response(['data' => []])]);
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->resourceTeam->id,
|
||||
'provider' => 'vault',
|
||||
'name' => 'Production Vault',
|
||||
'token' => 'hvs.stored-secret',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://old-user:old-pass@example.com:8200'],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]);
|
||||
switchSecurityAuditSessionTeam($this->otherTeam);
|
||||
|
||||
$component->set('metadata.base_url', 'https://new-user:new-pass@example.net:8200')
|
||||
->set('newToken', 'hvs.new-secret')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($token->fresh()->token)->toBe('hvs.new-secret');
|
||||
expectSecurityAuditEventInResourceTeam('ui.integration_token.base_url_changed', $this->resourceTeam, $this->otherTeam, [
|
||||
'hvs.stored-secret', 'hvs.new-secret', 'old-pass', 'new-pass',
|
||||
]);
|
||||
$auditEvent = AuditEvent::query()->where('event', 'ui.integration_token.base_url_changed')->sole();
|
||||
expect(data_get($auditEvent->metadata, 'previous_base_url'))->toBe('https://example.com:8200')
|
||||
->and(data_get($auditEvent->metadata, 'base_url'))->toBe('https://example.net:8200');
|
||||
});
|
||||
|
||||
test('integration token deletes are audited in the token team after a session team switch', function () {
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->resourceTeam->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.st.super-secret',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$component = Livewire::test(IntegrationTokenEditor::class, ['integration_token_uuid' => $token->uuid]);
|
||||
switchSecurityAuditSessionTeam($this->otherTeam);
|
||||
|
||||
$component->call('delete');
|
||||
|
||||
expect(IntegrationToken::query()->whereKey($token->id)->exists())->toBeFalse();
|
||||
expectSecurityAuditEventInResourceTeam('ui.integration_token.deleted', $this->resourceTeam, $this->otherTeam, ['dp.st.super-secret']);
|
||||
});
|
||||
|
||||
test('cloud provider token changes are audited in the token team after a session team switch', function (string $action, string $event) {
|
||||
Http::fake(['*' => Http::response([])]);
|
||||
$token = CloudProviderToken::factory()->create([
|
||||
'team_id' => $this->resourceTeam->id,
|
||||
'token' => 'hcloud-super-secret',
|
||||
'name' => 'Production Hetzner',
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CloudProviderTokenShow::class, ['cloud_token_uuid' => $token->uuid]);
|
||||
switchSecurityAuditSessionTeam($this->otherTeam);
|
||||
|
||||
$component->set('name', 'Renamed Hetzner')->call($action)->assertHasNoErrors();
|
||||
|
||||
expectSecurityAuditEventInResourceTeam($event, $this->resourceTeam, $this->otherTeam, ['hcloud-super-secret']);
|
||||
})->with([
|
||||
'save' => ['save', 'ui.cloud_token.updated'],
|
||||
'validate' => ['validateToken', 'ui.cloud_token.validated'],
|
||||
'delete' => ['delete', 'ui.cloud_token.deleted'],
|
||||
]);
|
||||
|
||||
test('cloud-init script changes are audited in the script team after a session team switch', function (string $action, string $event) {
|
||||
$script = CloudInitScript::query()->create([
|
||||
'team_id' => $this->resourceTeam->id,
|
||||
'name' => 'Bootstrap',
|
||||
'script' => "#cloud-config\nruncmd:\n - echo cloud-init-super-secret\n",
|
||||
]);
|
||||
|
||||
$component = Livewire::test(CloudInitScriptShow::class, ['cloud_init_script_uuid' => $script->uuid]);
|
||||
switchSecurityAuditSessionTeam($this->otherTeam);
|
||||
|
||||
$component->set('script', "#cloud-config\nruncmd:\n - echo cloud-init-new-secret\n")->call($action)->assertHasNoErrors();
|
||||
|
||||
expectSecurityAuditEventInResourceTeam($event, $this->resourceTeam, $this->otherTeam, ['cloud-init-super-secret', 'cloud-init-new-secret']);
|
||||
})->with([
|
||||
'save' => ['save', 'ui.cloud_init_script.updated'],
|
||||
'delete' => ['delete', 'ui.cloud_init_script.deleted'],
|
||||
]);
|
||||
Reference in New Issue
Block a user