Skip to content
Open
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 composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/",
Expand Down
8 changes: 8 additions & 0 deletions src/Api/Boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}

/**
Expand Down
19 changes: 19 additions & 0 deletions src/Api/Controllers/Api/WebhookSecretController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,8 @@
*/
class WebhookSecretController extends Controller
{
use AuthorizesRequests;

public function __construct(
protected WebhookSecretRotationService $rotationService
) {}
Expand All @@ -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
]);
Expand Down Expand Up @@ -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',
]);
Expand Down Expand Up @@ -115,6 +122,8 @@ public function socialSecretStatus(Request $request, string $uuid): JsonResponse
return response()->json(['error' => 'Webhook not found'], 404);
}

$this->authorize('update', $webhook);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better semantic clarity and maintainability, it's recommended to use a view authorization policy for read-only actions like socialSecretStatus. While using the update policy works, it can be confusing as this method doesn't modify any data.

Consider adding a view method to WebhookPolicy and calling $this->authorize('view', $webhook); here.

Example in WebhookPolicy.php:

/**
 * Determine if the user can view the webhook.
 */
public function view(User $user, mixed $webhook): bool
{
    // If view and update permissions are the same, you can just call the update method.
    return $this->update($user, $webhook);
}

This would make the authorization intent clearer. The same applies to contentSecretStatus.

        $this->authorize('view', $webhook);


return response()->json([
'data' => $this->rotationService->getSecretStatus($webhook),
]);
Expand All @@ -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);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to socialSecretStatus, it's recommended to use a view authorization policy for this read-only action for better semantic clarity. This makes the authorization intent clearer and improves maintainability.

Consider adding a view method to WebhookPolicy (if not already done based on other feedback) and changing this call to $this->authorize('view', $endpoint);.

        $this->authorize('view', $endpoint);


return response()->json([
'data' => $this->rotationService->getSecretStatus($endpoint),
]);
Expand All @@ -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([
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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',
]);
Expand Down Expand Up @@ -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',
]);
Expand Down
31 changes: 31 additions & 0 deletions src/Api/Policies/WebhookPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Core\Api\Policies;

use Core\Tenant\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class WebhookPolicy
{
use HandlesAuthorization;

/**
* Determine if the user can update the webhook.
*
* Only workspace owners and admins can manage webhook secrets
* and rotation settings.
*
* @param User $user
* @param mixed $webhook Social Webhook or Content Webhook Endpoint
* @return bool
*/
public function update(User $user, mixed $webhook): bool
Comment on lines +21 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For improved type safety and code clarity, consider using a union type for the $webhook parameter instead of mixed. Since this policy applies to both Webhook and ContentWebhookEndpoint models, a union type makes the expected types explicit. This is supported in PHP 8.0+.

Using fully qualified names here makes the code a bit verbose. You could also add use statements at the top of the file for Core\Social\Models\Webhook and Core\Content\Models\ContentWebhookEndpoint to use shorter class names.

     * @param  \Core\Social\Models\Webhook|\Core\Content\Models\ContentWebhookEndpoint  $webhook  Social Webhook or Content Webhook Endpoint
     * @return bool
     */
    public function update(User $user, \Core\Social\Models\Webhook|\Core\Content\Models\ContentWebhookEndpoint $webhook): bool

{
return $user->workspaces()
->where('workspaces.id', $webhook->workspace_id)
->wherePivotIn('role', ['owner', 'admin'])
->exists();
}
}
47 changes: 47 additions & 0 deletions src/Api/Tests/Feature/WebhookAuthorizationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Core\Api\Tests\Feature;

use Core\Social\Models\Webhook;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Str;

uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

// Skip if models don't exist in local sandbox environment
if (class_exists('Core\Tenant\Models\Workspace')) {
beforeEach(function () {
$this->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);
});
});
}
Loading