diff --git a/composer.json b/composer.json index 3b4ae4e..af837f6 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,12 @@ "host-uk/core": "@dev", "symfony/yaml": "^7.0" }, + "repositories": [ + { + "type": "git", + "url": "https://github.com/host-uk/core-php.git" + } + ], "autoload": { "psr-4": { "Core\\Api\\": "src/Api/", diff --git a/src/Api/Boot.php b/src/Api/Boot.php index 904a679..a447fd5 100644 --- a/src/Api/Boot.php +++ b/src/Api/Boot.php @@ -12,9 +12,13 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Cache\Repository as CacheRepository; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Core\Social\Models\Webhook; +use Core\Content\Models\ContentWebhookEndpoint; +use Core\Api\Policies\WebhookPolicy; /** * API Module Boot. @@ -74,6 +78,10 @@ public function boot(): void { $this->loadMigrationsFrom(__DIR__.'/Migrations'); $this->configureRateLimiting(); + + // Register Webhook policies + Gate::policy(Webhook::class, WebhookPolicy::class); + Gate::policy(ContentWebhookEndpoint::class, WebhookPolicy::class); } /** diff --git a/src/Api/Controllers/Api/WebhookSecretController.php b/src/Api/Controllers/Api/WebhookSecretController.php index 5dee8c1..d80c322 100644 --- a/src/Api/Controllers/Api/WebhookSecretController.php +++ b/src/Api/Controllers/Api/WebhookSecretController.php @@ -4,6 +4,7 @@ namespace Core\Api\Controllers\Api; +use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -16,6 +17,8 @@ */ class WebhookSecretController extends Controller { + use AuthorizesRequests; + public function __construct( protected WebhookSecretRotationService $rotationService ) {} @@ -39,6 +42,8 @@ public function rotateSocialSecret(Request $request, string $uuid): JsonResponse return response()->json(['error' => 'Webhook not found'], 404); } + $this->authorize('update', $webhook); + $validated = $request->validate([ 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', // 5 min to 7 days ]); @@ -77,6 +82,8 @@ public function rotateContentSecret(Request $request, string $uuid): JsonRespons return response()->json(['error' => 'Webhook endpoint not found'], 404); } + $this->authorize('update', $endpoint); + $validated = $request->validate([ 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', ]); @@ -115,6 +122,8 @@ public function socialSecretStatus(Request $request, string $uuid): JsonResponse return response()->json(['error' => 'Webhook not found'], 404); } + $this->authorize('update', $webhook); + return response()->json([ 'data' => $this->rotationService->getSecretStatus($webhook), ]); @@ -139,6 +148,8 @@ public function contentSecretStatus(Request $request, string $uuid): JsonRespons return response()->json(['error' => 'Webhook endpoint not found'], 404); } + $this->authorize('update', $endpoint); + return response()->json([ 'data' => $this->rotationService->getSecretStatus($endpoint), ]); @@ -163,6 +174,8 @@ public function invalidateSocialPreviousSecret(Request $request, string $uuid): return response()->json(['error' => 'Webhook not found'], 404); } + $this->authorize('update', $webhook); + $this->rotationService->invalidatePreviousSecret($webhook); return response()->json([ @@ -190,6 +203,8 @@ public function invalidateContentPreviousSecret(Request $request, string $uuid): return response()->json(['error' => 'Webhook endpoint not found'], 404); } + $this->authorize('update', $endpoint); + $this->rotationService->invalidatePreviousSecret($endpoint); return response()->json([ @@ -217,6 +232,8 @@ public function updateSocialGracePeriod(Request $request, string $uuid): JsonRes return response()->json(['error' => 'Webhook not found'], 404); } + $this->authorize('update', $webhook); + $validated = $request->validate([ 'grace_period_seconds' => 'required|integer|min:300|max:604800', ]); @@ -251,6 +268,8 @@ public function updateContentGracePeriod(Request $request, string $uuid): JsonRe return response()->json(['error' => 'Webhook endpoint not found'], 404); } + $this->authorize('update', $endpoint); + $validated = $request->validate([ 'grace_period_seconds' => 'required|integer|min:300|max:604800', ]); diff --git a/src/Api/Policies/WebhookPolicy.php b/src/Api/Policies/WebhookPolicy.php new file mode 100644 index 0000000..523ece5 --- /dev/null +++ b/src/Api/Policies/WebhookPolicy.php @@ -0,0 +1,31 @@ +workspaces() + ->where('workspaces.id', $webhook->workspace_id) + ->wherePivotIn('role', ['owner', 'admin']) + ->exists(); + } +} diff --git a/src/Api/Tests/Feature/WebhookAuthorizationTest.php b/src/Api/Tests/Feature/WebhookAuthorizationTest.php new file mode 100644 index 0000000..9b9d07f --- /dev/null +++ b/src/Api/Tests/Feature/WebhookAuthorizationTest.php @@ -0,0 +1,47 @@ +workspace = Workspace::factory()->create(); + $this->webhook = Webhook::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'uuid' => Str::uuid()->toString(), + ]); + + // Create users with different roles + $this->owner = User::factory()->create(); + $this->workspace->users()->attach($this->owner->id, ['role' => 'owner']); + + $this->admin = User::factory()->create(); + $this->workspace->users()->attach($this->admin->id, ['role' => 'admin']); + + $this->member = User::factory()->create(); + $this->workspace->users()->attach($this->member->id, ['role' => 'member']); + }); + + describe('Webhook Authorization', function () { + it('allows owner to rotate social secret', function () { + $this->actingAs($this->owner) + ->postJson("/api/webhooks/social/{$this->webhook->uuid}/rotate") + ->assertOk(); + }); + + it('denies member from rotating social secret', function () { + $this->actingAs($this->member) + ->postJson("/api/webhooks/social/{$this->webhook->uuid}/rotate") + ->assertStatus(403); + }); + }); +}