From d1b9dad35e1e15b219233c351079f041ce86954a Mon Sep 17 00:00:00 2001 From: vk Date: Wed, 11 Feb 2026 23:49:53 +0100 Subject: [PATCH] =?UTF-8?q?[P2=20Security]=20Laravel=20Authorization=20Pol?= =?UTF-8?q?icies=20f=C3=BCr=20ProjectProposal,=20Vote,=20Election=20(vibe-?= =?UTF-8?q?kanban=2085007440)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Security Audit: Fehlende zentralisierte Autorisierung ### Problem Die Anwendung hat **keine Laravel Policy-Klassen**. Autorisierungslogik ist verstreut in: - **Blade Templates:** Inline `@if`-Checks gegen `config('einundzwanzig.config.current_board')` - **Livewire Trait:** `app/Livewire/Traits/WithNostrAuth.php` setzt `$isAllowed` und `$canEdit` Booleans - **Component-Mount-Methoden:** z.B. in `project-support/form/create.blade.php` (Zeile 41-47) Die Board-Member-Autorisierung funktioniert über einen Vergleich mit hartkodierten npubs in `config/einundzwanzig/config.php`: ```php 'current_board' => [ 'npub1pt0kw36...', 'npub1gvqkjc...', // etc. ] ``` **Probleme:** - Keine zentrale Stelle für Berechtigungsprüfungen - Autorisierung kann leicht vergessen werden wenn neue Endpoints hinzukommen - Board-Member-Check wird an vielen Stellen dupliziert - Livewire-Actions können ggf. direkt aufgerufen werden ohne UI-seitige Prüfung ### Lösung **1. Laravel Policies erstellen:** ```bash php artisan make:policy ProjectProposalPolicy --model=ProjectProposal --no-interaction php artisan make:policy VotePolicy --model=Vote --no-interaction php artisan make:policy ElectionPolicy --model=Election --no-interaction ``` **2. Policy-Methoden implementieren:** Für `ProjectProposalPolicy`: - `viewAny()` – Jeder darf die Liste sehen - `view()` – Jeder darf ein Proposal sehen - `create()` – Nur authentifizierte User mit `association_status > 1` und bezahlter Mitgliedschaft im aktuellen Jahr - `update()` – Nur der Ersteller ODER Board-Members - `delete()` – Nur Board-Members - `accept()` – Nur Board-Members (custom method für `accepted`-Flag) Für `VotePolicy`: - `create()` – Authentifizierte User, die noch nicht für dieses Proposal abgestimmt haben - `update()` / `delete()` – Nur der eigene Vote Für `ElectionPolicy`: - `viewAny()` / `view()` – Jeder - `create()` / `update()` / `delete()` – Nur Board-Members - `vote()` – Authentifizierte Mitglieder mit gültigem Status **3. Auth-Checks in der Nostr-Auth-Architektur:** Die App nutzt eine Custom NostrAuth (`app/Support/NostrAuth.php`, `app/Auth/NostrUser.php`). Die Policies müssen mit `NostrUser` funktionieren. Prüfe ob `NostrUser` das `Authenticatable` Interface korrekt implementiert, damit `$this->authorize()` und `Gate::allows()` in Livewire-Components funktionieren. **4. Policies in Livewire-Components nutzen:** Ersetze die inline-Checks in den Components durch Policy-Aufrufe: ```php // Vorher (verstreut in Blade/Component): if (in_array($this->currentPleb->npub, config('einundzwanzig.config.current_board'), true)) { ... } // Nachher (zentralisiert): $this->authorize('update', $projectProposal); ``` ### Betroffene Dateien - **Neue Dateien:** `app/Policies/ProjectProposalPolicy.php`, `app/Policies/VotePolicy.php`, `app/Policies/ElectionPolicy.php` - **Anpassen:** `app/Livewire/Traits/WithNostrAuth.php` – Board-Check in Policy auslagern - **Anpassen:** Livewire-Components in `app/Livewire/Association/ProjectSupport/` – `$this->authorize()` nutzen - **Anpassen:** Livewire-Components in `app/Livewire/Association/Election/` – `$this->authorize()` nutzen - **Prüfen:** `app/Auth/NostrUser.php` – Kompatibilität mit Policy-System - **Prüfen:** `config/einundzwanzig/config.php` – Board-Member-Liste wird weiterhin als Datenquelle genutzt ### Vorgehen 1. `search-docs` nutzen: `queries: ['policies', 'authorization', 'gates']` und `packages: ['laravel/framework']` 2. Prüfe wie `NostrUser` mit Laravel's Authorization-System zusammenspielt 3. Policies mit `php artisan make:policy` erstellen 4. Policy-Methoden implementieren (Board-Check-Logik zentralisieren) 5. Livewire-Components auf Policy-Aufrufe umstellen 6. Blade-Templates: `@can` / `@cannot` Directives nutzen statt inline `@if` 7. Pest Feature-Tests für jede Policy-Methode schreiben 8. `vendor/bin/pint --dirty` und `php artisan test --compact` ### Akzeptanzkriterien - 3 Policy-Klassen existieren und sind registriert - Board-Member-Check ist an EINER Stelle definiert (in Policy oder Helper) - Livewire-Components nutzen `$this->authorize()` statt inline-Checks - Blade-Templates nutzen `@can` / `@cannot` Directives - Pest-Tests decken alle Policy-Methoden ab (allow & deny) - Bestehende Funktionalität bleibt erhalten --- app/Policies/ElectionPolicy.php | 78 +++++++++ app/Policies/ProjectProposalPolicy.php | 96 +++++++++++ app/Policies/VotePolicy.php | 58 +++++++ app/Providers/AppServiceProvider.php | 11 ++ .../views/components/project-card.blade.php | 8 +- .../association/election/admin.blade.php | 16 +- .../association/election/index.blade.php | 31 ++-- .../association/election/show.blade.php | 16 +- .../project-support/form/create.blade.php | 21 +-- .../project-support/form/edit.blade.php | 49 +++--- .../project-support/index.blade.php | 5 +- .../project-support/show.blade.php | 13 +- .../Livewire/Association/ElectionTest.php | 13 +- tests/Feature/Policies/ElectionPolicyTest.php | 110 +++++++++++++ .../Policies/ProjectProposalPolicyTest.php | 150 ++++++++++++++++++ tests/Feature/Policies/VotePolicyTest.php | 97 +++++++++++ 16 files changed, 705 insertions(+), 67 deletions(-) create mode 100644 app/Policies/ElectionPolicy.php create mode 100644 app/Policies/ProjectProposalPolicy.php create mode 100644 app/Policies/VotePolicy.php create mode 100644 tests/Feature/Policies/ElectionPolicyTest.php create mode 100644 tests/Feature/Policies/ProjectProposalPolicyTest.php create mode 100644 tests/Feature/Policies/VotePolicyTest.php diff --git a/app/Policies/ElectionPolicy.php b/app/Policies/ElectionPolicy.php new file mode 100644 index 0000000..351fc9d --- /dev/null +++ b/app/Policies/ElectionPolicy.php @@ -0,0 +1,78 @@ +isBoardMember($user); + } + + /** + * Determine whether the user can update the election (e.g. manage candidates). + * Only board members. + */ + public function update(NostrUser $user, Election $election): bool + { + return $this->isBoardMember($user); + } + + /** + * Determine whether the user can delete the election. + * Only board members. + */ + public function delete(NostrUser $user, Election $election): bool + { + return $this->isBoardMember($user); + } + + /** + * Determine whether the user can vote in the election. + * Requires: authenticated pleb with active or honorary status. + */ + public function vote(NostrUser $user, Election $election): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->association_status->value >= 3; + } + + private function isBoardMember(NostrUser $user): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return in_array($pleb->npub, config('einundzwanzig.config.current_board'), true); + } +} diff --git a/app/Policies/ProjectProposalPolicy.php b/app/Policies/ProjectProposalPolicy.php new file mode 100644 index 0000000..4754279 --- /dev/null +++ b/app/Policies/ProjectProposalPolicy.php @@ -0,0 +1,96 @@ + 1, paid membership for current year. + */ + public function create(NostrUser $user): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->association_status->value > 1 + && $pleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists(); + } + + /** + * Determine whether the user can update the project proposal. + * Allowed for: the creator OR board members. + */ + public function update(NostrUser $user, ProjectProposal $projectProposal): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->id === $projectProposal->einundzwanzig_pleb_id + || $this->isBoardMember($pleb); + } + + /** + * Determine whether the user can delete the project proposal. + * Allowed for: the creator OR board members. + */ + public function delete(NostrUser $user, ProjectProposal $projectProposal): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->id === $projectProposal->einundzwanzig_pleb_id + || $this->isBoardMember($pleb); + } + + /** + * Determine whether the user can accept/reject the project proposal. + * Only board members can change the accepted flag and sats_paid. + */ + public function accept(NostrUser $user, ProjectProposal $projectProposal): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $this->isBoardMember($pleb); + } + + /** + * @param \App\Models\EinundzwanzigPleb $pleb + */ + private function isBoardMember(object $pleb): bool + { + return in_array($pleb->npub, config('einundzwanzig.config.current_board'), true); + } +} diff --git a/app/Policies/VotePolicy.php b/app/Policies/VotePolicy.php new file mode 100644 index 0000000..7999e4c --- /dev/null +++ b/app/Policies/VotePolicy.php @@ -0,0 +1,58 @@ +getPleb(); + + if (! $pleb) { + return false; + } + + return ! Vote::query() + ->where('project_proposal_id', $projectProposal->id) + ->where('einundzwanzig_pleb_id', $pleb->id) + ->exists(); + } + + /** + * Determine whether the user can update the vote. + * Only the vote owner can update their vote. + */ + public function update(NostrUser $user, Vote $vote): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->id === $vote->einundzwanzig_pleb_id; + } + + /** + * Determine whether the user can delete the vote. + * Only the vote owner can delete their vote. + */ + public function delete(NostrUser $user, Vote $vote): bool + { + $pleb = $user->getPleb(); + + if (! $pleb) { + return false; + } + + return $pleb->id === $vote->einundzwanzig_pleb_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 5c7faf4..30e3a2f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,8 +2,15 @@ namespace App\Providers; +use App\Models\Election; +use App\Models\ProjectProposal; +use App\Models\Vote; +use App\Policies\ElectionPolicy; +use App\Policies\ProjectProposalPolicy; +use App\Policies\VotePolicy; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; @@ -22,6 +29,10 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + Gate::policy(ProjectProposal::class, ProjectProposalPolicy::class); + Gate::policy(Vote::class, VotePolicy::class); + Gate::policy(Election::class, ElectionPolicy::class); + RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->ip()); }); diff --git a/resources/views/components/project-card.blade.php b/resources/views/components/project-card.blade.php index d13468b..4438fdc 100644 --- a/resources/views/components/project-card.blade.php +++ b/resources/views/components/project-card.blade.php @@ -78,10 +78,7 @@
- @if( - ($currentPleb && $currentPleb->id === $project->einundzwanzig_pleb_id) - || ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) - ) + @if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('delete', $project)) Löschen - + @endif + @if(Illuminate\Support\Facades\Gate::forUser(App\Support\NostrAuth::user())->allows('update', $project)) loadBoardEvents(); $this->loadVotes(); $this->loadBoardVotes(); + + $nostrUser = NostrAuth::user(); + if ($nostrUser) { + $this->currentPubkey = $nostrUser->getPubkey(); + $this->currentPleb = $nostrUser->getPleb(); + $this->isAllowed = Gate::forUser($nostrUser)->allows('update', $this->election); + } } public function handleNostrLoggedIn(string $pubkey): void { + NostrAuth::login($pubkey); + $this->currentPubkey = $pubkey; $this->currentPleb = \App\Models\EinundzwanzigPleb::query() ->where('pubkey', $pubkey)->first(); - $this->isAllowed = (bool) $this->currentPleb; + + $nostrUser = NostrAuth::user(); + $this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', $this->election); } public function handleNostrLoggedOut(): void { + NostrAuth::logout(); + $this->currentPubkey = null; $this->currentPleb = null; $this->isAllowed = false; diff --git a/resources/views/livewire/association/election/index.blade.php b/resources/views/livewire/association/election/index.blade.php index 8c2fe99..5f0b385 100644 --- a/resources/views/livewire/association/election/index.blade.php +++ b/resources/views/livewire/association/election/index.blade.php @@ -3,6 +3,7 @@ use App\Models\EinundzwanzigPleb; use App\Models\Election; use App\Support\NostrAuth; +use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Locked; use Livewire\Component; @@ -29,34 +30,31 @@ new class extends Component { $this->elections = Election::query() ->get() ->toArray(); - if (NostrAuth::check()) { - $this->currentPubkey = NostrAuth::pubkey(); - $logPubkeys = [ - '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033', - '430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279', - ]; - if (in_array($this->currentPubkey, $logPubkeys, true)) { - $this->isAllowed = true; - } + + $nostrUser = NostrAuth::user(); + + if ($nostrUser) { + $this->currentPubkey = $nostrUser->getPubkey(); + $this->isAllowed = Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election); } } public function handleNostrLoggedIn(string $pubkey): void { + NostrAuth::login($pubkey); + $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() ->where('pubkey', $pubkey)->first(); - $logPubkeys = [ - '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033', - '430169631f2f0682c60cebb4f902d68f0c71c498fd1711fd982f052cf1fd4279', - ]; - - $this->isAllowed = in_array($pubkey, $logPubkeys, true); + $nostrUser = NostrAuth::user(); + $this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('update', Election::query()->first() ?? new Election); } public function handleNostrLoggedOut(): void { + NostrAuth::logout(); + $this->currentPubkey = null; $this->currentPleb = null; $this->isAllowed = false; @@ -66,6 +64,9 @@ new class extends Component { { $election = $this->elections[$index]; $electionModel = Election::find($election['id']); + + Gate::forUser(NostrAuth::user())->authorize('update', $electionModel); + $electionModel->candidates = $election['candidates']; $electionModel->save(); } diff --git a/resources/views/livewire/association/election/show.blade.php b/resources/views/livewire/association/election/show.blade.php index 01be7fc..3843b5d 100644 --- a/resources/views/livewire/association/election/show.blade.php +++ b/resources/views/livewire/association/election/show.blade.php @@ -4,6 +4,7 @@ use App\Models\Election; use App\Models\EinundzwanzigPleb; use App\Models\Profile; use App\Support\NostrAuth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Computed; use Livewire\Attributes\Locked; @@ -197,6 +198,13 @@ new class extends Component { if ($this->election->end_time?->isPast() || ! config('services.voting')) { $this->isNotClosed = false; } + + $nostrUser = NostrAuth::user(); + if ($nostrUser) { + $this->currentPubkey = $nostrUser->getPubkey(); + $this->currentPleb = $nostrUser->getPleb(); + $this->isAllowed = Gate::forUser($nostrUser)->allows('vote', $this->election); + } } public function handleNostrLoggedIn(string $pubkey): void @@ -211,10 +219,14 @@ new class extends Component { abort(429, 'Too many login attempts.'); } + NostrAuth::login($pubkey); + $this->currentPubkey = $pubkey; $this->currentPleb = EinundzwanzigPleb::query() ->where('pubkey', $pubkey)->first(); - $this->isAllowed = (bool) $this->currentPleb; + + $nostrUser = NostrAuth::user(); + $this->isAllowed = $nostrUser && Gate::forUser($nostrUser)->allows('vote', $this->election); } public function handleNostrLoggedOut(): void @@ -291,6 +303,8 @@ new class extends Component { public function vote($pubkey, $type, $board = false): void { + Gate::forUser(NostrAuth::user())->authorize('vote', $this->election); + $executed = RateLimiter::attempt( 'voting:'.request()->ip(), 10, diff --git a/resources/views/livewire/association/project-support/form/create.blade.php b/resources/views/livewire/association/project-support/form/create.blade.php index 741f16e..93b121b 100644 --- a/resources/views/livewire/association/project-support/form/create.blade.php +++ b/resources/views/livewire/association/project-support/form/create.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; @@ -35,15 +36,15 @@ class extends Component public function mount(): void { - if (NostrAuth::check()) { - $currentPubkey = NostrAuth::pubkey(); - $currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first(); + $nostrUser = NostrAuth::user(); - if ($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists()) { - $this->isAllowed = true; - } + if ($nostrUser && Gate::forUser($nostrUser)->allows('create', ProjectProposal::class)) { + $this->isAllowed = true; + } - if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) { + if ($nostrUser) { + $pleb = $nostrUser->getPleb(); + if ($pleb && in_array($pleb->npub, config('einundzwanzig.config.current_board'), true)) { $this->isAdmin = true; } } @@ -59,6 +60,8 @@ class extends Component public function save(): void { + Gate::forUser(NostrAuth::user())->authorize('create', ProjectProposal::class); + $executed = RateLimiter::attempt( 'project-proposal-create:'.request()->ip(), 5, @@ -82,8 +85,8 @@ class extends Component $projectProposal->description = $this->form['description']; $projectProposal->support_in_sats = (int) $this->form['support_in_sats']; $projectProposal->website = $this->form['website']; - $projectProposal->accepted = $this->form['accepted']; - $projectProposal->sats_paid = $this->form['sats_paid']; + $projectProposal->accepted = $this->isAdmin ? $this->form['accepted'] : false; + $projectProposal->sats_paid = $this->isAdmin ? $this->form['sats_paid'] : 0; $projectProposal->einundzwanzig_pleb_id = \App\Models\EinundzwanzigPleb::query()->where('pubkey', NostrAuth::pubkey())->first()->id; $projectProposal->save(); diff --git a/resources/views/livewire/association/project-support/form/edit.blade.php b/resources/views/livewire/association/project-support/form/edit.blade.php index 9842526..85691f2 100644 --- a/resources/views/livewire/association/project-support/form/edit.blade.php +++ b/resources/views/livewire/association/project-support/form/edit.blade.php @@ -2,6 +2,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\Locked; @@ -40,36 +41,29 @@ class extends Component { $this->project = ProjectProposal::query()->where('slug', $projectProposal)->firstOrFail(); - if (NostrAuth::check()) { - $currentPubkey = NostrAuth::pubkey(); - $currentPleb = \App\Models\EinundzwanzigPleb::query()->where('pubkey', $currentPubkey)->first(); + $nostrUser = NostrAuth::user(); - if ( - ( - $currentPleb - && $currentPleb->id === $this->project->einundzwanzig_pleb_id - ) - || in_array($currentPleb->npub, config('einundzwanzig.config.current_board')) - ) { - $this->isAllowed = true; - $this->form = [ - 'name' => $this->project->name, - 'description' => $this->project->description, - 'support_in_sats' => (string) $this->project->support_in_sats, - 'website' => $this->project->website ?? '', - 'accepted' => (bool) $this->project->accepted, - 'sats_paid' => $this->project->sats_paid, - ]; - } + if ($nostrUser && Gate::forUser($nostrUser)->allows('update', $this->project)) { + $this->isAllowed = true; + $this->form = [ + 'name' => $this->project->name, + 'description' => $this->project->description, + 'support_in_sats' => (string) $this->project->support_in_sats, + 'website' => $this->project->website ?? '', + 'accepted' => (bool) $this->project->accepted, + 'sats_paid' => $this->project->sats_paid, + ]; + } - if ($currentPleb && in_array($currentPleb->npub, config('einundzwanzig.config.current_board'), true)) { - $this->isAdmin = true; - } + if ($nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project)) { + $this->isAdmin = true; } } public function deleteMainImage(): void { + Gate::forUser(NostrAuth::user())->authorize('update', $this->project); + if ($this->project->getFirstMedia('main')) { $this->project->getFirstMedia('main')->delete(); } @@ -85,6 +79,8 @@ class extends Component public function update(): void { + Gate::forUser(NostrAuth::user())->authorize('update', $this->project); + $executed = RateLimiter::attempt( 'project-proposal-update:'.request()->ip(), 5, @@ -103,13 +99,16 @@ class extends Component 'file' => 'nullable|file|mimes:jpeg,png,jpg,gif,webp|mimetypes:image/jpeg,image/png,image/gif,image/webp|max:10240', ]); + $nostrUser = NostrAuth::user(); + $canAccept = $nostrUser && Gate::forUser($nostrUser)->allows('accept', $this->project); + $this->project->update([ 'name' => $this->form['name'], 'description' => $this->form['description'], 'support_in_sats' => (int) $this->form['support_in_sats'], 'website' => $this->form['website'], - 'accepted' => $this->isAdmin ? (bool) $this->form['accepted'] : $this->project->accepted, - 'sats_paid' => $this->isAdmin ? $this->form['sats_paid'] : $this->project->sats_paid, + 'accepted' => $canAccept ? (bool) $this->form['accepted'] : $this->project->accepted, + 'sats_paid' => $canAccept ? $this->form['sats_paid'] : $this->project->sats_paid, ]); if ($this->file) { diff --git a/resources/views/livewire/association/project-support/index.blade.php b/resources/views/livewire/association/project-support/index.blade.php index 2a2b755..3a209a1 100644 --- a/resources/views/livewire/association/project-support/index.blade.php +++ b/resources/views/livewire/association/project-support/index.blade.php @@ -6,6 +6,7 @@ use App\Models\ProjectProposal; use App\Support\NostrAuth; use Flux\Flux; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Gate; use Livewire\Attributes\Locked; use Livewire\Component; @@ -77,6 +78,8 @@ new class extends Component { public function delete(): void { if ($this->projectToDelete) { + Gate::forUser(NostrAuth::user())->authorize('delete', $this->projectToDelete); + $this->projectToDelete->delete(); Flux::toast('Projektunterstützung gelöscht.'); $this->loadProjects(); @@ -110,7 +113,7 @@ new class extends Component { - @if($currentPleb && $currentPleb->association_status->value > 1 && $currentPleb->paymentEvents()->where('year', date('Y'))->where('paid', true)->exists()) + @if(Gate::forUser(NostrAuth::user())->allows('create', App\Models\ProjectProposal::class)) Projekt einreichen diff --git a/resources/views/livewire/association/project-support/show.blade.php b/resources/views/livewire/association/project-support/show.blade.php index d0be2f0..b16c305 100644 --- a/resources/views/livewire/association/project-support/show.blade.php +++ b/resources/views/livewire/association/project-support/show.blade.php @@ -4,6 +4,7 @@ use App\Livewire\Traits\WithNostrAuth; use App\Models\ProjectProposal; use App\Models\Vote; use App\Support\NostrAuth; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Locked; use Livewire\Component; @@ -61,10 +62,14 @@ new class extends Component { public function handleApprove(): void { - if (! $this->currentPleb) { + $nostrUser = NostrAuth::user(); + + if (! $nostrUser || ! $nostrUser->getPleb()) { return; } + Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]); + $executed = RateLimiter::attempt( 'voting:'.request()->ip(), 10, @@ -86,10 +91,14 @@ new class extends Component { public function handleNotApprove(): void { - if (! $this->currentPleb) { + $nostrUser = NostrAuth::user(); + + if (! $nostrUser || ! $nostrUser->getPleb()) { return; } + Gate::forUser($nostrUser)->authorize('create', [Vote::class, $this->projectProposal]); + $executed = RateLimiter::attempt( 'voting:'.request()->ip(), 10, diff --git a/tests/Feature/Livewire/Association/ElectionTest.php b/tests/Feature/Livewire/Association/ElectionTest.php index 0d17897..b72ec5a 100644 --- a/tests/Feature/Livewire/Association/ElectionTest.php +++ b/tests/Feature/Livewire/Association/ElectionTest.php @@ -26,8 +26,7 @@ it('denies access to unauthorized users in election index', function () { }); it('grants access to authorized users in election index', function () { - $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033'; - $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]); + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); $election = Election::factory()->create(); NostrAuth::login($pleb->pubkey); @@ -55,8 +54,7 @@ it('denies access to unauthorized users in election admin', function () { }); it('grants access to authorized users in election admin', function () { - $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033'; - $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]); + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); $election = Election::factory()->create(); Livewire::test('association.election.admin', ['election' => $election]) @@ -115,12 +113,11 @@ it('checks election closure status', function () { }); it('displays log for authorized users', function () { - $allowedPubkey = '0adf67475ccc5ca456fd3022e46f5d526eb0af6284bf85494c0dd7847f3e5033'; - $pleb = EinundzwanzigPleb::factory()->create(['pubkey' => $allowedPubkey]); + $pleb = EinundzwanzigPleb::factory()->active()->create(); $election = Election::factory()->create(); Livewire::test('association.election.show', ['election' => $election]) - ->call('handleNostrLoggedIn', $allowedPubkey) + ->call('handleNostrLoggedIn', $pleb->pubkey) ->assertSet('isAllowed', true) - ->assertSet('currentPubkey', $allowedPubkey); + ->assertSet('currentPubkey', $pleb->pubkey); }); diff --git a/tests/Feature/Policies/ElectionPolicyTest.php b/tests/Feature/Policies/ElectionPolicyTest.php new file mode 100644 index 0000000..c38ac2c --- /dev/null +++ b/tests/Feature/Policies/ElectionPolicyTest.php @@ -0,0 +1,110 @@ +allows('viewAny', Election::class))->toBeTrue(); +}); + +// view +it('allows anyone to view an election', function () { + $election = Election::factory()->create(); + + expect(Gate::forUser(null)->allows('view', $election))->toBeTrue(); +}); + +// create +it('allows board member to create elections', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', Election::class))->toBeTrue(); +}); + +it('denies non-board member from creating elections', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', Election::class))->toBeFalse(); +}); + +// update +it('allows board member to update an election', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $election))->toBeTrue(); +}); + +it('denies non-board member from updating an election', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $election))->toBeFalse(); +}); + +// delete +it('allows board member to delete an election', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $election))->toBeTrue(); +}); + +it('denies non-board member from deleting an election', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $election))->toBeFalse(); +}); + +// vote +it('allows active member to vote in an election', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeTrue(); +}); + +it('allows honorary member to vote in an election', function () { + $pleb = EinundzwanzigPleb::factory()->create([ + 'association_status' => \App\Enums\AssociationStatus::HONORARY, + ]); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeTrue(); +}); + +it('denies passive member from voting in an election', function () { + $pleb = EinundzwanzigPleb::factory()->create([ + 'association_status' => \App\Enums\AssociationStatus::PASSIVE, + ]); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeFalse(); +}); + +it('denies default (non-member) from voting in an election', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $election = Election::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('vote', $election))->toBeFalse(); +}); + +it('denies unauthenticated users from voting in an election', function () { + $election = Election::factory()->create(); + + expect(Gate::forUser(null)->allows('vote', $election))->toBeFalse(); +}); diff --git a/tests/Feature/Policies/ProjectProposalPolicyTest.php b/tests/Feature/Policies/ProjectProposalPolicyTest.php new file mode 100644 index 0000000..1a786ab --- /dev/null +++ b/tests/Feature/Policies/ProjectProposalPolicyTest.php @@ -0,0 +1,150 @@ +allows('viewAny', ProjectProposal::class))->toBeTrue(); +}); + +it('allows authenticated user to view any project proposals', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('viewAny', ProjectProposal::class))->toBeTrue(); +}); + +// view +it('allows anyone to view a project proposal', function () { + $project = ProjectProposal::factory()->create(); + + expect(Gate::forUser(null)->allows('view', $project))->toBeTrue(); +}); + +// create +it('allows active member with paid membership to create project proposals', function () { + $pleb = EinundzwanzigPleb::factory()->active()->withPaidCurrentYear()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeTrue(); +}); + +it('denies creation for default (non-member) pleb', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse(); +}); + +it('denies creation for active member without paid membership', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse(); +}); + +it('denies creation for passive member without paid membership', function () { + $pleb = EinundzwanzigPleb::factory()->create([ + 'association_status' => \App\Enums\AssociationStatus::PASSIVE, + ]); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeFalse(); +}); + +it('allows passive member with paid membership to create project proposals', function () { + $pleb = EinundzwanzigPleb::factory()->withPaidCurrentYear()->create([ + 'association_status' => \App\Enums\AssociationStatus::PASSIVE, + ]); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', ProjectProposal::class))->toBeTrue(); +}); + +it('denies creation for unauthenticated users', function () { + expect(Gate::forUser(null)->allows('create', ProjectProposal::class))->toBeFalse(); +}); + +// update +it('allows project creator to update their project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create([ + 'einundzwanzig_pleb_id' => $pleb->id, + ]); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeTrue(); +}); + +it('allows board member to update any project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeTrue(); +}); + +it('denies non-owner non-board member from updating a project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $project))->toBeFalse(); +}); + +// delete +it('allows project creator to delete their project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create([ + 'einundzwanzig_pleb_id' => $pleb->id, + ]); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeTrue(); +}); + +it('allows board member to delete any project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeTrue(); +}); + +it('denies non-owner non-board member from deleting a project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $project))->toBeFalse(); +}); + +// accept +it('allows board member to accept a project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->boardMember()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeTrue(); +}); + +it('denies non-board member from accepting a project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeFalse(); +}); + +it('denies project creator from accepting their own project proposal', function () { + $pleb = EinundzwanzigPleb::factory()->active()->create(); + $project = ProjectProposal::factory()->create([ + 'einundzwanzig_pleb_id' => $pleb->id, + ]); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('accept', $project))->toBeFalse(); +}); diff --git a/tests/Feature/Policies/VotePolicyTest.php b/tests/Feature/Policies/VotePolicyTest.php new file mode 100644 index 0000000..bb803fb --- /dev/null +++ b/tests/Feature/Policies/VotePolicyTest.php @@ -0,0 +1,97 @@ +create(); + $project = ProjectProposal::factory()->create(); + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', [Vote::class, $project]))->toBeTrue(); +}); + +it('denies vote creation if pleb has already voted on the proposal', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + + Vote::create([ + 'project_proposal_id' => $project->id, + 'einundzwanzig_pleb_id' => $pleb->id, + 'value' => true, + ]); + + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('create', [Vote::class, $project]))->toBeFalse(); +}); + +it('denies vote creation for unauthenticated users', function () { + $project = ProjectProposal::factory()->create(); + + expect(Gate::forUser(null)->allows('create', [Vote::class, $project]))->toBeFalse(); +}); + +// update +it('allows vote owner to update their vote', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $vote = Vote::create([ + 'project_proposal_id' => $project->id, + 'einundzwanzig_pleb_id' => $pleb->id, + 'value' => true, + ]); + + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $vote))->toBeTrue(); +}); + +it('denies non-owner from updating a vote', function () { + $owner = EinundzwanzigPleb::factory()->create(); + $otherPleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $vote = Vote::create([ + 'project_proposal_id' => $project->id, + 'einundzwanzig_pleb_id' => $owner->id, + 'value' => true, + ]); + + $nostrUser = new NostrUser($otherPleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('update', $vote))->toBeFalse(); +}); + +// delete +it('allows vote owner to delete their vote', function () { + $pleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $vote = Vote::create([ + 'project_proposal_id' => $project->id, + 'einundzwanzig_pleb_id' => $pleb->id, + 'value' => false, + ]); + + $nostrUser = new NostrUser($pleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $vote))->toBeTrue(); +}); + +it('denies non-owner from deleting a vote', function () { + $owner = EinundzwanzigPleb::factory()->create(); + $otherPleb = EinundzwanzigPleb::factory()->create(); + $project = ProjectProposal::factory()->create(); + $vote = Vote::create([ + 'project_proposal_id' => $project->id, + 'einundzwanzig_pleb_id' => $owner->id, + 'value' => true, + ]); + + $nostrUser = new NostrUser($otherPleb->pubkey); + + expect(Gate::forUser($nostrUser)->allows('delete', $vote))->toBeFalse(); +});