fix(vpn): dispatch the revocation removal after the transaction commits

A worker could pick the job up mid-transaction, still see the peer as active,
leave it on the hub — and never be asked again, because the committed state is
only a soft delete. A revoked colleague would keep their tunnel until some
later reconciliation noticed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 22:36:11 +02:00
parent 1112b4cdec
commit 371d517261
2 changed files with 27 additions and 2 deletions

View File

@ -155,7 +155,9 @@ class Settings extends Component
{
$this->authorize('staff.manage');
$result = DB::transaction(function () use ($id) {
$revokedPeers = [];
$result = DB::transaction(function () use ($id, &$revokedPeers) {
Role::query()->where('name', 'Owner')->lockForUpdate()->first();
$target = User::query()->whereKey($id)->lockForUpdate()->first();
@ -177,12 +179,20 @@ class Settings extends Component
foreach (VpnPeer::query()->where('user_id', $target->id)->get() as $peer) {
$peer->purgeSecret();
$peer->delete();
ApplyVpnPeer::dispatch($peer->public_key, null, false);
$revokedPeers[] = $peer->public_key;
}
return 'revoked';
});
// Dispatched only once the rows are committed: a worker picking the job
// up mid-transaction would still see the peer as active, leave it on the
// hub, and never be asked again — a revoked colleague would keep their
// tunnel until the next reconciliation happened to notice.
foreach ($revokedPeers as $publicKey) {
ApplyVpnPeer::dispatch($publicKey, null, false);
}
$this->flash($result);
}

View File

@ -640,3 +640,18 @@ it('takes the tunnel away when a staff member is revoked', function () {
->and(VpnPeer::withTrashed()->first()->config_secret)->toBeNull();
Queue::assertPushed(ApplyVpnPeer::class, fn ($job) => $job->publicKey === $peer->public_key && ! $job->enabled);
});
it('removes a revoked colleague from the hub only after the rows are committed', function () {
$hub = vpnHub();
$support = operator('Support');
$peer = VpnPeer::factory()->ownedBy($support)->create();
$hub->addPeer($peer->public_key, $peer->allowed_ip);
// Sync queue: the job runs the moment it is dispatched, so if that happened
// inside the transaction it would still see a live peer and leave it up.
Livewire::actingAs(operator('Owner'))->test(App\Livewire\Admin\Settings::class)
->call('revokeStaff', $support->id);
expect($hub->peerIps())->toBe([])
->and(VpnPeer::withTrashed()->count())->toBe(0); // tombstone cleared by the removal
});