fix(vpn): lock the owner row while issuing an access
A revocation committing between the operator check and the insert would find no peer to remove and still leave the revoked colleague with a brand-new tunnel. Issuing now runs in a transaction that locks the owner row — the same row revokeStaff() locks — so the two cannot interleave. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/portal-design
parent
3bd3e64a3d
commit
02750a7c9a
|
|
@ -13,6 +13,7 @@ use App\Services\Wireguard\Keypair;
|
||||||
use App\Services\Wireguard\QrCode;
|
use App\Services\Wireguard\QrCode;
|
||||||
use App\Services\Wireguard\WireguardHub;
|
use App\Services\Wireguard\WireguardHub;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
@ -75,13 +76,6 @@ class Vpn extends Component
|
||||||
'ownerId' => ['required', Rule::exists('users', 'id')],
|
'ownerId' => ['required', Rule::exists('users', 'id')],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$owner = User::query()->find($this->ownerId);
|
|
||||||
if ($owner === null || ! $owner->isOperator()) {
|
|
||||||
$this->addError('ownerId', __('vpn.owner_must_be_operator'));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Storing needs a key. Without one we would either write the credential
|
// Storing needs a key. Without one we would either write the credential
|
||||||
// in the clear or pretend we stored it — both worse than saying no.
|
// in the clear or pretend we stored it — both worse than saying no.
|
||||||
if ($this->storeConfig && ! ConfigVault::available()) {
|
if ($this->storeConfig && ! ConfigVault::available()) {
|
||||||
|
|
@ -114,7 +108,16 @@ class Vpn extends Component
|
||||||
// tables, so neither unique index can catch the other's insert — the
|
// tables, so neither unique index can catch the other's insert — the
|
||||||
// shared lock is what keeps a host and an access off the same tunnel IP.
|
// shared lock is what keeps a host and an access off the same tunnel IP.
|
||||||
try {
|
try {
|
||||||
$peer = Cache::lock('wireguard:allocate', 30)->block(10, function () use ($hub, $publicKey) {
|
$peer = Cache::lock('wireguard:allocate', 30)->block(10, fn () => DB::transaction(function () use ($hub, $publicKey) {
|
||||||
|
// The owner row is locked for the whole insert, and revokeStaff()
|
||||||
|
// locks the same row: without that, a revocation could commit
|
||||||
|
// between the check and the insert, find no peer to remove, and
|
||||||
|
// leave the revoked colleague with a brand-new tunnel.
|
||||||
|
$owner = User::query()->whereKey($this->ownerId)->lockForUpdate()->first();
|
||||||
|
if ($owner === null || ! $owner->isOperator()) {
|
||||||
|
return 'owner_must_be_operator';
|
||||||
|
}
|
||||||
|
|
||||||
// Inside the lock: checking before it would let two concurrent
|
// Inside the lock: checking before it would let two concurrent
|
||||||
// requests both pass and the loser hit the unique index as a
|
// requests both pass and the loser hit the unique index as a
|
||||||
// 500. withTrashed, because a revoked peer keeps its key until
|
// 500. withTrashed, because a revoked peer keeps its key until
|
||||||
|
|
@ -142,7 +145,7 @@ class Vpn extends Component
|
||||||
'present' => false,
|
'present' => false,
|
||||||
'created_by' => auth()->id(),
|
'created_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
});
|
}));
|
||||||
} catch (QueryException) {
|
} catch (QueryException) {
|
||||||
// Backstop: the unique index caught a writer that did not take this
|
// Backstop: the unique index caught a writer that did not take this
|
||||||
// lock. Report it like any other duplicate instead of a 500.
|
// lock. Report it like any other duplicate instead of a 500.
|
||||||
|
|
@ -152,7 +155,8 @@ class Vpn extends Component
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($peer)) {
|
if (is_string($peer)) {
|
||||||
$this->addError('publicKey', __('vpn.'.$peer));
|
$field = $peer === 'owner_must_be_operator' ? 'ownerId' : 'publicKey';
|
||||||
|
$this->addError($field, __('vpn.'.$peer));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -731,3 +731,15 @@ it('does not keep polling while a private config is on screen', function () {
|
||||||
->assertSee('wire:poll', false)
|
->assertSee('wire:poll', false)
|
||||||
->assertDontSee('PrivateKey');
|
->assertDontSee('PrivateKey');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('will not issue an access to someone who is no longer staff', function () {
|
||||||
|
vpnHub();
|
||||||
|
$former = App\Models\User::factory()->create(); // no operator role
|
||||||
|
|
||||||
|
Livewire::actingAs(operator('Owner'))->test(Vpn::class)
|
||||||
|
->set('name', 'Ex-Kollege')->set('ownerId', $former->id)
|
||||||
|
->call('create')
|
||||||
|
->assertHasErrors('ownerId');
|
||||||
|
|
||||||
|
expect(VpnPeer::withTrashed()->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue