Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/Http/Controllers/Api/DeliveryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class DeliveryController extends Controller
*/
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'read');

$validator = Validator::make($request->all(), [
'status' => ['nullable', 'string', Rule::in(['pending', 'retrying', 'success', 'failed'])],
'from_date' => ['nullable', 'date'],
Expand Down Expand Up @@ -70,6 +72,8 @@ public function index(Request $request): JsonResponse
*/
public function show(Request $request, Delivery $delivery): JsonResponse
{
$this->authorizeAbility($request, 'read');

// Load the event with trashed included: a soft-deleted parent event
// must not make an existing delivery's ownership check (or its
// history) silently disappear.
Expand All @@ -88,6 +92,8 @@ public function show(Request $request, Delivery $delivery): JsonResponse
*/
public function stats(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'read');

$baseQuery = Delivery::query()
->whereHas('event', function ($q) use ($request) {
$q->withTrashed()->where('user_id', $request->user()->id);
Expand Down
12 changes: 12 additions & 0 deletions app/Http/Controllers/Api/EndpointController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class EndpointController extends Controller
*/
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'read');

$endpoints = $request->user()->endpoints()
->with('events')
->paginate(15);
Expand All @@ -29,6 +31,8 @@ public function index(Request $request): JsonResponse
*/
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'create');

$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'url' => ['required', 'url', 'max:2048', new SafeWebhookUrl()],
Expand Down Expand Up @@ -58,6 +62,8 @@ public function store(Request $request): JsonResponse
*/
public function show(Request $request, Endpoint $endpoint): JsonResponse
{
$this->authorizeAbility($request, 'read');

if ($endpoint->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand All @@ -72,6 +78,8 @@ public function show(Request $request, Endpoint $endpoint): JsonResponse
*/
public function update(Request $request, Endpoint $endpoint): JsonResponse
{
$this->authorizeAbility($request, 'update');

if ($endpoint->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand Down Expand Up @@ -100,6 +108,8 @@ public function update(Request $request, Endpoint $endpoint): JsonResponse
*/
public function regenerateSecret(Request $request, Endpoint $endpoint): JsonResponse
{
$this->authorizeAbility($request, 'update');

if ($endpoint->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand All @@ -117,6 +127,8 @@ public function regenerateSecret(Request $request, Endpoint $endpoint): JsonResp
*/
public function destroy(Request $request, Endpoint $endpoint): JsonResponse
{
$this->authorizeAbility($request, 'delete');

if ($endpoint->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand Down
10 changes: 10 additions & 0 deletions app/Http/Controllers/Api/EventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ class EventController extends Controller
*/
public function index(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'read');

$events = $request->user()->events()
->with(['endpoints', 'deliveries' => function ($query) {
$query->latest()->limit(5);
Expand All @@ -31,6 +33,8 @@ public function index(Request $request): JsonResponse
*/
public function store(Request $request): JsonResponse
{
$this->authorizeAbility($request, 'create');

$validator = Validator::make($request->all(), [
'name' => [
'required', 'string', 'max:255',
Expand Down Expand Up @@ -71,6 +75,8 @@ public function store(Request $request): JsonResponse
*/
public function show(Request $request, Event $event): JsonResponse
{
$this->authorizeAbility($request, 'read');

if ($event->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand All @@ -88,6 +94,8 @@ public function show(Request $request, Event $event): JsonResponse
*/
public function update(Request $request, Event $event): JsonResponse
{
$this->authorizeAbility($request, 'update');

if ($event->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand Down Expand Up @@ -132,6 +140,8 @@ public function update(Request $request, Event $event): JsonResponse
*/
public function destroy(Request $request, Event $event): JsonResponse
{
$this->authorizeAbility($request, 'delete');

if ($event->user_id !== $request->user()->id) {
return response()->json(['message' => 'Not Found'], 404);
}
Expand Down
4 changes: 4 additions & 0 deletions app/Http/Controllers/Api/WebhookController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class WebhookController extends Controller
*/
public function trigger(Request $request, string $eventName): JsonResponse
{
$this->authorizeAbility($request, 'create');

$validator = Validator::make($request->all(), [
'payload' => ['required', 'array', new WebhookPayloadSize()],
]);
Expand Down Expand Up @@ -128,6 +130,8 @@ private function dispatchDelivery(Delivery $delivery): void
*/
public function retryDelivery(Request $request, Delivery $delivery): JsonResponse
{
$this->authorizeAbility($request, 'update');

// Load the event with trashed included: a soft-deleted parent event
// must not turn this ownership check into a crash on a null relation.
$event = $delivery->event()->withTrashed()->first();
Expand Down
24 changes: 23 additions & 1 deletion app/Http/Controllers/Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,29 @@

namespace App\Http\Controllers;

use Illuminate\Http\Request;

abstract class Controller
{
//
/**
* Ensure the current request's Sanctum access token is scoped to the
* given ability before proceeding.
*
* Session-authenticated requests (the Inertia/web UI, and tests using
* actingAs()) carry Sanctum's TransientToken, whose can() always
* returns true, so this only restricts requests actually made with a
* personal access token that was scoped to a limited set of
* abilities. Without this check, a token restricted to "read" in the
* API Tokens UI silently retained full create/update/delete access,
* since `auth:sanctum` alone only verifies the token is valid, not
* what it's scoped to do.
*/
protected function authorizeAbility(Request $request, string $ability): void
{
abort_unless(
$request->user()->tokenCan($ability),
403,
"This action requires the \"{$ability}\" API token ability."
);
}
}
25 changes: 13 additions & 12 deletions tests/Feature/ApiEventNameUniquenessScopeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\Event;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ApiEventNameUniquenessScopeTest extends TestCase
Expand All @@ -16,12 +17,12 @@ public function test_two_users_can_create_events_with_the_same_name_via_api(): v
$userA = User::factory()->withPersonalTeam()->create();
$userB = User::factory()->withPersonalTeam()->create();

$this->actingAs($userA)
->postJson('/api/v1/events', ['name' => 'order.created'])
Sanctum::actingAs($userA, ['*']);
$this->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(201);

$this->actingAs($userB)
->postJson('/api/v1/events', ['name' => 'order.created'])
Sanctum::actingAs($userB, ['*']);
$this->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(201);

$this->assertDatabaseHas('events', ['user_id' => $userA->id, 'name' => 'order.created']);
Expand All @@ -32,12 +33,12 @@ public function test_same_user_cannot_create_duplicate_event_name_via_api(): voi
{
$user = User::factory()->withPersonalTeam()->create();

$this->actingAs($user)
->postJson('/api/v1/events', ['name' => 'order.created'])
Sanctum::actingAs($user, ['*']);
$this->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(201);

$this->actingAs($user)
->postJson('/api/v1/events', ['name' => 'order.created'])
Sanctum::actingAs($user, ['*']);
$this->postJson('/api/v1/events', ['name' => 'order.created'])
->assertStatus(422)
->assertJsonValidationErrors('name');

Expand All @@ -52,8 +53,8 @@ public function test_user_can_rename_event_to_a_name_used_by_another_user_via_ap
Event::factory()->for($userA)->create(['name' => 'order.created']);
$eventB = Event::factory()->for($userB)->create(['name' => 'order.updated']);

$this->actingAs($userB)
->putJson("/api/v1/events/{$eventB->id}", ['name' => 'order.created'])
Sanctum::actingAs($userB, ['*']);
$this->putJson("/api/v1/events/{$eventB->id}", ['name' => 'order.created'])
->assertStatus(200);

$this->assertDatabaseHas('events', ['id' => $eventB->id, 'name' => 'order.created']);
Expand All @@ -66,8 +67,8 @@ public function test_user_cannot_rename_event_to_a_name_they_already_use_via_api
Event::factory()->for($user)->create(['name' => 'order.created']);
$second = Event::factory()->for($user)->create(['name' => 'order.updated']);

$this->actingAs($user)
->putJson("/api/v1/events/{$second->id}", ['name' => 'order.created'])
Sanctum::actingAs($user, ['*']);
$this->putJson("/api/v1/events/{$second->id}", ['name' => 'order.created'])
->assertStatus(422)
->assertJsonValidationErrors('name');
}
Expand Down
Loading
Loading