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
14 changes: 14 additions & 0 deletions src/CachetCoreServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use Cachet\Models\Schedule;
use Cachet\Models\Subscriber;
use Cachet\Models\WebhookAttempt;
use Cachet\Policies\SubscriberPolicy;
use Cachet\Policies\UserPolicy;
use Cachet\Settings\AppSettings;
use Cachet\Settings\MailSettings;
use Cachet\View\Composers\MailThemeComposer;
Expand All @@ -44,6 +46,7 @@
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
Expand Down Expand Up @@ -106,6 +109,7 @@ public function boot(): void
}

$this->configureTrustedProxies();
$this->registerPolicies();

Route::middlewareGroup('cachet', config('cachet.middleware', []));
Route::middlewareGroup('cachet:api', config('cachet.api_middleware', []));
Expand Down Expand Up @@ -141,6 +145,16 @@ public function boot(): void
$this->configureScramble();
}

/**
* Register policies for Cachet's models and the configured user model.
*/
private function registerPolicies(): void
{
Gate::policy(Models\User::class, UserPolicy::class);
Gate::policy(config('cachet.user_model'), UserPolicy::class);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the host application's user policy

When cachet.user_model points to a consumer's existing user model, this unconditionally installs Cachet's policy into Laravel's global Gate and takes precedence over an auto-discovered host UserPolicy. Consequently, authorization elsewhere in the host for that model starts using Cachet's admin/self rules—or, if the host later replaces the mapping, Cachet loses these rules—so the package cannot safely coexist with the consumer policy. Keep Cachet authorization scoped to its resources or use distinct abilities rather than claiming the configured host model's policy mapping.

AGENTS.md reference: AGENTS.md:L106-L109

Useful? React with 👍 / 👎.

Gate::policy(Subscriber::class, SubscriberPolicy::class);
}

/**
* Register the package's resources such as routes, migrations, etc.
*/
Expand Down
6 changes: 6 additions & 0 deletions src/Filament/Resources/ApiKeys/ApiKeyResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Cachet\Cachet;
use Cachet\Filament\Resources\ApiKeys\Pages\CreateApiKey;
use Cachet\Filament\Resources\ApiKeys\Pages\ListApiKeys;
use Cachet\Models\Subscriber;
use Filament\Actions\BulkAction;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\CheckboxList;
Expand All @@ -19,6 +20,7 @@
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Laravel\Sanctum\PersonalAccessToken;

Expand Down Expand Up @@ -145,6 +147,10 @@ private static function getAbilities(): array
$abilities = [];

foreach (Cachet::getResourceApiAbilities() as $resource => $apiAbilities) {
if ($resource === 'subscribers' && Gate::denies('viewAny', Subscriber::class)) {
continue;
}

foreach ($apiAbilities as $ability) {
$key = "{$resource}.{$ability}";
$abilities[$key] = Str::headline(__('cachet::api_key.abilities_label', [
Expand Down
14 changes: 13 additions & 1 deletion src/Filament/Resources/ApiKeys/Pages/CreateApiKey.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
namespace Cachet\Filament\Resources\ApiKeys\Pages;

use Cachet\Filament\Resources\ApiKeys\ApiKeyResource;
use Cachet\Models\Subscriber;
use Cachet\Models\User;
use Carbon\Carbon;
use Filament\Facades\Filament;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;

class CreateApiKey extends CreateRecord
{
Expand All @@ -21,10 +23,20 @@ public function handleRecordCreation(array $data): Model
{
/** @var User $user */
$user = Filament::auth()->user();
$abilities = $data['abilities'] ?? [];

if ($abilities === [] || in_array('*', $abilities, true)) {
Gate::forUser($user)->authorize('issueFullAccessApiToken', $user::class);
$abilities = ['*'];
}

if (collect($abilities)->contains(fn (string $ability): bool => str_starts_with($ability, 'subscribers.'))) {
Gate::forUser($user)->authorize('viewAny', Subscriber::class);
}

$token = $user->createToken(
name: $data['name'],
abilities: empty($data['abilities']) ? ['*'] : $data['abilities'],
abilities: $abilities,
expiresAt: filled($data['expires_at']) ? Carbon::parse($data['expires_at']) : null,
);

Expand Down
2 changes: 2 additions & 0 deletions src/Filament/Resources/Subscribers/SubscriberResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,14 @@ public static function table(Table $table): Table
])
->recordActions([
Action::make('verify')
->authorize('update')
->label(__('cachet::subscriber.list.actions.verify_label'))
->color('warning')
->action(fn (Subscriber $record) => $record->verify())
->requiresConfirmation()
->hidden(fn (Subscriber $record): bool => $record->hasVerifiedEmail()),
Action::make('resend-verification')
->authorize('update')
->label(__('cachet::subscriber.list.actions.resend_verification_label'))
->color('gray')
->action(function (Subscriber $record) {
Expand Down
45 changes: 2 additions & 43 deletions src/Filament/Resources/Users/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,56 +21,13 @@
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Illuminate\Auth\Access\Response;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Hash;

class UserResource extends Resource
{
protected static string|\BackedEnum|null $navigationIcon = Heroicon::OutlinedUsers;

public static function canAccess(): bool
{
return auth()->user()->isAdmin();
}

public static function getEditAuthorizationResponse(Model $record): Response
{
if (Cachet::demoMode()) {
return Response::deny();
}

if (auth()->user()->is($record)) {
return Response::allow();
}

if (auth()->user()->isAdmin()) {
return Response::allow();
}

return Response::deny();
}

public static function getDeleteAuthorizationResponse(Model $record): Response
{
$response = parent::getDeleteAuthorizationResponse($record);

if ($response->denied()) {
return $response;
}

if (! $record instanceof User || auth()->user()->is($record)) {
return Response::deny();
}

if ($record->isAdmin() && static::getModel()::query()->where('is_admin', true)->count() <= 1) {
return Response::deny();
}

return $response;
}

public static function form(Schema $schema): Schema
{
return $schema
Expand Down Expand Up @@ -147,11 +104,13 @@ public static function table(Table $table): Table
->recordActions([
EditAction::make(),
Action::make('verify-email')
->authorize('update')
->label(__('cachet::user.list.actions.verify_email'))
->icon(Heroicon::OutlinedCheckBadge)
->disabled(fn (User $record): bool => $record->hasVerifiedEmail())
->action(fn (Builder $query, User $record) => $record->sendEmailVerificationNotification()),
Action::make('reset-two-factor')
->authorize('update')
->label(__('cachet::user.list.actions.reset_two_factor'))
->icon(Heroicon::OutlinedShieldExclamation)
->requiresConfirmation()
Expand Down
32 changes: 20 additions & 12 deletions src/Filament/Widgets/Overview.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;

class Overview extends BaseWidget
{
protected static ?int $sort = 1;

protected function getColumns(): int
{
return 3;
return Gate::allows('viewAny', Subscriber::class) ? 3 : 2;
}

protected function getStats(): array
Expand All @@ -29,7 +30,7 @@ protected function getStats(): array
$operationalComponents = (int) $components->operational;
$allOperational = $totalComponents === $operationalComponents;

return [
$stats = [
Stat::make('open_incidents', $openIncidents)
->label(__('cachet::incident.overview.open_incidents_label'))
->description(__('cachet::incident.overview.open_incidents_description'))
Expand All @@ -46,17 +47,24 @@ protected function getStats(): array
->color($allOperational ? 'success' : 'warning')
->url(ComponentResource::getUrl('index')),

Stat::make('total_subscribers', Subscriber::count())
->label(__('cachet::subscriber.overview.total_subscribers_label'))
->description(__('cachet::subscriber.overview.verified_subscribers_description', [
'count' => Subscriber::query()->whereNotNull('email_verified_at')->count(),
]))
->chart($this->dailyCounts('subscribers'))
->icon('cachet-subscribers')
->chartColor('info')
->color('gray')
->url(SubscriberResource::getUrl('index')),
];

if (Gate::denies('viewAny', Subscriber::class)) {
return $stats;
}

$stats[] = Stat::make('total_subscribers', Subscriber::count())
->label(__('cachet::subscriber.overview.total_subscribers_label'))
->description(__('cachet::subscriber.overview.verified_subscribers_description', [
'count' => Subscriber::query()->whereNotNull('email_verified_at')->count(),
]))
->chart($this->dailyCounts('subscribers'))
->icon('cachet-subscribers')
->chartColor('info')
->color('gray')
->url(SubscriberResource::getUrl('index'));

return $stats;
}

/**
Expand Down
6 changes: 6 additions & 0 deletions src/Http/Controllers/Api/SubscriberController.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Number;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\QueryBuilder;
Expand All @@ -37,6 +38,7 @@ class SubscriberController extends Controller
public function index(Request $request)
{
$this->guard('subscribers.manage');
Gate::authorize('viewAny', Subscriber::class);

$subscribers = QueryBuilder::for(Subscriber::class)
->allowedIncludes(['components', 'meta'])
Expand All @@ -57,6 +59,7 @@ public function index(Request $request)
public function store(CreateSubscriberRequestData $data, CreateSubscriber $createSubscriberAction)
{
$this->guard('subscribers.manage');
Gate::authorize('create', Subscriber::class);

$subscriber = $createSubscriberAction->handle(
$data->email,
Expand All @@ -80,6 +83,7 @@ public function store(CreateSubscriberRequestData $data, CreateSubscriber $creat
public function show(Subscriber $subscriber)
{
$this->guard('subscribers.manage');
Gate::authorize('view', $subscriber);

$subscriberQuery = QueryBuilder::for(Subscriber::class)
->allowedIncludes(['components', 'meta'])
Expand All @@ -96,6 +100,7 @@ public function show(Subscriber $subscriber)
public function update(UpdateSubscriberRequestData $data, Subscriber $subscriber, UpdateSubscriber $updateSubscriberAction)
{
$this->guard('subscribers.manage');
Gate::authorize('update', $subscriber);

$updateSubscriberAction->handle(
$subscriber,
Expand All @@ -114,6 +119,7 @@ public function update(UpdateSubscriberRequestData $data, Subscriber $subscriber
public function destroy(Subscriber $subscriber, UnsubscribeSubscriber $unsubscribeSubscriberAction)
{
$this->guard('subscribers.delete');
Gate::authorize('delete', $subscriber);

$unsubscribeSubscriberAction->handle($subscriber);

Expand Down
13 changes: 13 additions & 0 deletions src/Mcp/Concerns/GuardsMcpAbilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Cachet\Mcp\Concerns;

use Illuminate\Support\Facades\Gate;
use Laravel\Mcp\Response;

trait GuardsMcpAbilities
Expand All @@ -16,6 +17,18 @@ protected function tokenCan(string $ability): bool
return $user !== null && $user->tokenCan($ability);
}

/**
* Determine whether the token ability and its resource policy both allow access.
*/
protected function tokenCanAnd(string $tokenAbility, string $policyAbility, mixed $arguments): bool
{
$user = auth('sanctum')->user();

return $user !== null
&& $user->tokenCan($tokenAbility)
&& Gate::forUser($user)->allows($policyAbility, $arguments);
}

/**
* Create the error response returned when the required token ability is missing.
*/
Expand Down
5 changes: 3 additions & 2 deletions src/Mcp/Tools/Subscribers/CreateSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Cachet\Data\Requests\Subscriber\CreateSubscriberRequestData;
use Cachet\Mcp\Concerns\GuardsMcpAbilities;
use Cachet\Mcp\Concerns\PresentsResources;
use Cachet\Models\Subscriber;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
Expand Down Expand Up @@ -38,7 +39,7 @@ public function schema(JsonSchema $schema): array

public function handle(Request $request, CreateSubscriberAction $action): Response|ResponseFactory
{
if (! $this->tokenCan('subscribers.manage')) {
if (! $this->tokenCanAnd('subscribers.manage', 'create', Subscriber::class)) {
return $this->missingAbility('subscribers.manage');
}

Expand All @@ -60,6 +61,6 @@ public function handle(Request $request, CreateSubscriberAction $action): Respon

public function shouldRegister(): bool
{
return $this->tokenCan('subscribers.manage');
return $this->tokenCanAnd('subscribers.manage', 'viewAny', Subscriber::class);
}
}
4 changes: 2 additions & 2 deletions src/Mcp/Tools/Subscribers/ListSubscribers.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ public function schema(JsonSchema $schema): array

public function handle(Request $request): Response|ResponseFactory
{
if (! $this->tokenCan('subscribers.manage')) {
if (! $this->tokenCanAnd('subscribers.manage', 'viewAny', Subscriber::class)) {
return $this->missingAbility('subscribers.manage');
}

Expand All @@ -57,6 +57,6 @@ public function handle(Request $request): Response|ResponseFactory

public function shouldRegister(): bool
{
return $this->tokenCan('subscribers.manage');
return $this->tokenCanAnd('subscribers.manage', 'viewAny', Subscriber::class);
}
}
8 changes: 6 additions & 2 deletions src/Mcp/Tools/Subscribers/UnsubscribeSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public function schema(JsonSchema $schema): array

public function handle(Request $request, UnsubscribeSubscriberAction $action): Response
{
if (! $this->tokenCan('subscribers.delete')) {
if (! $this->tokenCanAnd('subscribers.delete', 'viewAny', Subscriber::class)) {
return $this->missingAbility('subscribers.delete');
}

Expand All @@ -42,13 +42,17 @@ public function handle(Request $request, UnsubscribeSubscriberAction $action): R
return Response::error("Subscriber [{$id}] not found.");
}

if (! $this->tokenCanAnd('subscribers.delete', 'delete', $subscriber)) {
return $this->missingAbility('subscribers.delete');
}

$action->handle($subscriber);

return Response::text("Subscriber [{$id}] unsubscribed.");
}

public function shouldRegister(): bool
{
return $this->tokenCan('subscribers.delete');
return $this->tokenCanAnd('subscribers.delete', 'viewAny', Subscriber::class);
}
}
Loading
Loading