fix(auth): use the resource team in database import, SQLite, and tokens

These paths used the session team after the resource was loaded. After a
team switch in another tab they used the wrong team or denied access.

- ImportForm: use the database team for the server, S3 storages, and
  the import
- SQLite ConnectApplication: list applications of the database team
- IntegrationTokenPolicy: check admin of the token team for view,
  update, and delete
This commit is contained in:
Andras Bacsai
2026-10-05 21:22:33 +02:00
parent 4f43c96e53
commit 379838251b
7 changed files with 329 additions and 13 deletions
+14 -5
View File
@@ -216,7 +216,16 @@ class ImportForm extends Component
return null;
}
return Server::ownedByCurrentTeam()->find($this->serverId);
return Server::query()->where('team_id', $this->resourceTeamId())->find($this->serverId);
}
/**
* Team of the loaded database. The session team can differ: a user can switch teams in
* another tab while this form stays open.
*/
private function resourceTeamId(): ?int
{
return $this->resource?->team()?->id;
}
protected $listeners = [
@@ -467,7 +476,7 @@ class ImportForm extends Component
$source = Storage::exists("upload/{$this->resourceUuid}/restore")
? new DatabaseImportSource('upload', dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers)
: new DatabaseImportSource('server', path: $this->customLocation, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId());
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
@@ -497,7 +506,7 @@ class ImportForm extends Component
public function loadAvailableS3Storages()
{
try {
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
$this->availableS3Storages = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId(), ['id', 'name', 'description'])
->where('is_usable', true)
->get()
->map(fn ($s) => ['id' => $s->id, 'name' => $s->name, 'description' => $s->description])
@@ -551,7 +560,7 @@ class ImportForm extends Component
}
try {
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
$s3Storage = S3Storage::ownedByCurrentTeamAPI((int) $this->resourceTeamId())->findOrFail($this->s3StorageId);
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
@@ -629,7 +638,7 @@ class ImportForm extends Component
try {
$this->importRunning = true;
$source = new DatabaseImportSource('s3', path: $this->s3Path, s3StorageUuid: (string) $this->s3StorageId, dumpAll: $this->dumpAll, replaceExisting: $this->replaceExisting, keepOwners: $this->keepOwners, sqliteDatabase: $this->sqliteDatabase, restoreMysqlUsers: $this->restoreMysqlUsers);
$activity = StartDatabaseImport::run($this->resource, $source, (int) currentTeam()->id);
$activity = StartDatabaseImport::run($this->resource, $source, (int) $this->resourceTeamId());
$this->activityId = $activity->id;
$this->dispatch('activityMonitor', $activity->id);
$this->dispatch('databaserestore');
@@ -184,18 +184,20 @@ class ConnectApplication extends Component
}
/**
* Applications owned by the current team that run on the same server as the database.
* Applications of the database's team that run on the same server as the database. The session
* team can differ: a user can switch teams in another tab while this component is still open.
*
* @return Collection<int, Application>
*/
private function connectableApplications(): Collection
{
$serverId = $this->database->destination?->server_id;
if ($serverId === null) {
$teamId = $this->database->team()?->id;
if ($serverId === null || $teamId === null) {
return collect();
}
return Application::ownedByCurrentTeam()
return Application::ownedByCurrentTeamAPI($teamId)
->with(['destination', 'environment.project'])
->get()
->filter(fn (Application $application) => $application->destination?->server_id === $serverId)
+9 -3
View File
@@ -7,6 +7,9 @@ use App\Models\User;
class IntegrationTokenPolicy
{
/**
* Listing and creating tokens happen on the session team's security pages.
*/
public function viewAny(User $user): bool
{
return $user->isAdmin();
@@ -17,18 +20,21 @@ class IntegrationTokenPolicy
return $user->isAdmin();
}
/**
* A loaded token is checked against its own team: the session team can differ.
*/
public function view(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
public function update(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
public function delete(User $user, IntegrationToken $integrationToken): bool
{
return $user->isAdmin() && $integrationToken->team_id === currentTeam()->id;
return $user->isAdminOfTeam((int) $integrationToken->team_id);
}
}
@@ -89,7 +89,7 @@ describe('locked properties', function () {
});
describe('server method uses team scoping', function () {
test('server computed property calls ownedByCurrentTeam', function () {
test('server computed property is scoped to the database team', function () {
$method = new ReflectionMethod(ImportForm::class, 'server');
// Extract the server method body
@@ -98,7 +98,7 @@ describe('server method uses team scoping', function () {
$lines = array_slice(file($method->getFileName()), $startLine - 1, $endLine - $startLine + 1);
$methodBody = implode('', $lines);
expect($methodBody)->toContain('ownedByCurrentTeam');
expect($methodBody)->toContain('where(\'team_id\', $this->resourceTeamId())');
expect($methodBody)->not->toContain('Server::find($this->serverId)');
});
});
@@ -0,0 +1,153 @@
<?php
use App\Actions\Database\StartDatabaseImport;
use App\Livewire\Project\Database\ImportForm;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Spatie\Activitylog\Models\Activity;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
InstanceSettings::forceCreate([
'id' => 0,
'disable_two_step_confirmation' => true,
]);
$this->resourceTeam = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$this->user = User::factory()->create();
$this->resourceTeam->members()->attach($this->user, ['role' => 'admin']);
$this->otherTeam->members()->attach($this->user, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->resourceTeam]);
$this->server = Server::factory()->create(['team_id' => $this->resourceTeam->id]);
$destination = StandaloneDocker::firstOrCreate(
['server_id' => $this->server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'docker']
);
$project = Project::factory()->create(['team_id' => $this->resourceTeam->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->database = StandalonePostgresql::create([
'uuid' => (string) Str::uuid(),
'name' => 'db',
'postgres_user' => 'postgres',
'postgres_password' => 'password',
'postgres_db' => 'db',
'image' => 'postgres:17',
'status' => 'running',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$s3Attributes = ['region' => 'us-east-1', 'key' => 'key', 'secret' => 'secret', 'bucket' => 'backups', 'endpoint' => 'https://s3.example.com', 'is_usable' => true];
$this->resourceTeamStorage = S3Storage::create([...$s3Attributes, 'team_id' => $this->resourceTeam->id, 'name' => 'resource-team-s3']);
$this->otherTeamStorage = S3Storage::create([...$s3Attributes, 'team_id' => $this->otherTeam->id, 'name' => 'other-team-s3']);
});
class ImportFormResourceTeamTestComponent extends ImportForm
{
public function mount($database = null, $server = null): void
{
$this->resourceId = $database->id;
$this->resourceType = $database::class;
$this->serverId = $server->id;
$this->container = $database->uuid;
$this->resourceUuid = $database->uuid;
$this->resourceStatus = $database->status ?? '';
$this->resourceDbType = $database->type();
$this->loadAvailableS3Storages();
}
public function render()
{
return view('livewire.project.database.import-form');
}
}
function importFormAfterTeamSwitch(): Testable
{
$component = Livewire::test(ImportFormResourceTeamTestComponent::class, [
'database' => test()->database,
'server' => test()->server,
]);
session(['currentTeam' => test()->otherTeam]);
return $component;
}
function fakeStartedDatabaseImport(): Activity
{
return Activity::create([
'log_name' => 'default',
'description' => 'queued',
'properties' => ['status' => 'queued'],
]);
}
test('a file import started after a session team switch runs for the database team', function () {
StartDatabaseImport::shouldRun()->once()
->withArgs(fn ($resource, $source, int $teamId): bool => $teamId === $this->resourceTeam->id)
->andReturn(fakeStartedDatabaseImport());
importFormAfterTeamSwitch()
->set('filename', 'backup.dump')
->call('runImport')
->assertNotDispatched('error')
->assertDispatched('databaserestore');
});
test('an S3 restore started after a session team switch runs for the database team', function () {
StartDatabaseImport::shouldRun()->once()
->withArgs(fn ($resource, $source, int $teamId): bool => $teamId === $this->resourceTeam->id
&& $source->s3StorageUuid === (string) $this->resourceTeamStorage->id)
->andReturn(fakeStartedDatabaseImport());
importFormAfterTeamSwitch()
->set('s3StorageId', $this->resourceTeamStorage->id)
->set('s3Path', '/backups/backup.dump')
->set('s3FileSize', 1024)
->call('restoreFromS3')
->assertNotDispatched('error')
->assertDispatched('databaserestore');
});
test('the S3 storage list contains only storages of the database team', function () {
importFormAfterTeamSwitch()
->call('loadAvailableS3Storages')
->assertSet('availableS3Storages', fn (array $storages): bool => collect($storages)->pluck('id')->all() === [$this->resourceTeamStorage->id]);
});
test('an S3 storage of another team cannot be checked for the database', function () {
importFormAfterTeamSwitch()
->set('s3StorageId', $this->otherTeamStorage->id)
->set('s3Path', '/backups/backup.dump')
->call('checkS3File')
->assertNotFound();
});
test('a member of the database team cannot start an import after switching to a team they own', function () {
$this->resourceTeam->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->user->refresh();
StartDatabaseImport::shouldRun()->never();
importFormAfterTeamSwitch()
->set('filename', 'backup.dump')
->call('runImport')
->assertForbidden();
});
@@ -0,0 +1,36 @@
<?php
use App\Models\IntegrationToken;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tokenTeam = Team::factory()->create();
$this->otherTeam = Team::factory()->create();
$this->token = IntegrationToken::factory()->create(['team_id' => $this->tokenTeam->id]);
$this->user = User::factory()->create();
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->otherTeam]);
});
it('lets an admin of the token team manage the token while the session team is another team', function (string $ability) {
$this->tokenTeam->members()->attach($this->user->id, ['role' => 'admin']);
expect($this->user->fresh()->can($ability, $this->token))->toBeTrue();
})->with(['view', 'update', 'delete']);
it('denies a member of the token team who owns the session team', function (string $ability) {
$this->tokenTeam->members()->attach($this->user->id, ['role' => 'member']);
expect($this->user->fresh()->can($ability, $this->token))->toBeFalse();
})->with(['view', 'update', 'delete']);
it('denies a user outside the token team even when the session points to the token team', function (string $ability) {
session(['currentTeam' => $this->tokenTeam]);
expect($this->user->fresh()->can($ability, $this->token))->toBeFalse();
})->with(['view', 'update', 'delete']);
@@ -0,0 +1,110 @@
<?php
use App\Livewire\Project\Database\Sqlite\ConnectApplication;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\StandaloneSqlite;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
$this->withoutVite();
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(['id' => 0], ['id' => 0]));
$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']);
$this->actingAs($this->user);
session(['currentTeam' => $this->resourceTeam]);
$server = Server::factory()->create(['team_id' => $this->resourceTeam->id]);
$this->destination = StandaloneDocker::withoutEvents(fn () => StandaloneDocker::firstOrCreate(
['server_id' => $server->id, 'network' => 'coolify'],
['uuid' => (string) Str::uuid(), 'name' => 'test-docker']
));
$this->sqlite = StandaloneSqlite::create([
'name' => 'app-sqlite',
'environment_id' => sqliteResourceTeamEnvironment($this->resourceTeam)->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$this->resourceTeamApplication = sqliteResourceTeamApplication(sqliteResourceTeamEnvironment($this->resourceTeam), $this->destination, 'Resource Team App');
$this->otherTeamApplication = sqliteResourceTeamApplication(sqliteResourceTeamEnvironment($this->otherTeam), $this->destination, 'Other Team App');
});
function sqliteResourceTeamEnvironment(Team $team): Environment
{
return Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]);
}
function sqliteResourceTeamApplication(Environment $environment, StandaloneDocker $destination, string $name): Application
{
return Application::factory()->create([
'uuid' => (string) Str::uuid(),
'name' => $name,
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
'build_pack' => 'dockerimage',
'docker_registry_image_name' => 'nginx',
]);
}
it('offers only applications of the database team when the session team is another team', function () {
session(['currentTeam' => $this->otherTeam]);
Livewire::test(ConnectApplication::class, ['database' => $this->sqlite])
->assertSet('applicationOptions', fn (array $options): bool => collect($options)->pluck('value')->all() === [$this->resourceTeamApplication->uuid]);
});
it('connects an application of the database team after a session team switch', function () {
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->resourceTeamApplication->uuid)
->call('connect')
->assertNotDispatched('error')
->assertRedirect();
expect($this->resourceTeamApplication->persistentStorages()->where('standalone_sqlite_id', $this->sqlite->id)->exists())->toBeTrue();
});
it('does not connect an application of another team after a session team switch', function () {
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->otherTeamApplication->uuid)
->call('connect')
->assertDispatched('error')
->assertNoRedirect();
expect($this->otherTeamApplication->persistentStorages()->count())->toBe(0);
});
it('forbids a member of the database team from connecting after switching to a team they own', function () {
$this->resourceTeam->members()->updateExistingPivot($this->user->id, ['role' => 'member']);
$this->user->refresh();
$component = Livewire::test(ConnectApplication::class, ['database' => $this->sqlite]);
session(['currentTeam' => $this->otherTeam]);
$component->set('applicationUuid', $this->resourceTeamApplication->uuid)
->call('connect')
->assertForbidden();
expect($this->resourceTeamApplication->persistentStorages()->count())->toBe(0);
});