Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ $user->isEditor(); // true if editor
- **Route Constraints**: All slug routes have `[a-z0-9\-]+` regex constraints
- **File Upload Validation**: Featured images require explicit MIME type allowlist
- **Mass Assignment**: `role` field is excluded from `$fillable` on User model to prevent privilege escalation
- **Ownership Authorization**: Articles, Pages and Media now track `user_id` ownership
- **Policy Enforcement**: Laravel Policies ensure only admins or resource owners can update/delete records
- **Query Scoping**: Non-admin users can only view their own resources in Filament admin panel

### Database
- SQLite by default (`database/database.sqlite`)
Expand Down
13 changes: 13 additions & 0 deletions app/Filament/Resources/Articles/ArticleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class ArticleResource extends Resource
{
Expand All @@ -30,6 +31,18 @@ public static function table(Table $table): Table
return ArticlesTable::configure($table);
}

public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();

// Non-admin users can only see their own articles
if (auth()->check() && !auth()->user()->isAdmin()) {
$query->where('user_id', auth()->id());
}

return $query;
}

public static function getRelations(): array
{
return [
Expand Down
13 changes: 13 additions & 0 deletions app/Filament/Resources/Media/MediaResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Spatie\MediaLibrary\MediaCollections\Models\Media;

class MediaResource extends Resource
Expand All @@ -29,6 +30,18 @@ public static function table(Table $table): Table
return MediaTable::configure($table);
}

public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();

// Non-admin users can only see their own media
if (auth()->check() && !auth()->user()->isAdmin()) {
$query->where('user_id', auth()->id());
}

return $query;
}

public static function getPages(): array
{
return [
Expand Down
13 changes: 13 additions & 0 deletions app/Filament/Resources/Pages/PageResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;

class PageResource extends Resource
{
Expand All @@ -30,6 +31,18 @@ public static function table(Table $table): Table
return PagesTable::configure($table);
}

public static function getEloquentQuery(): Builder
{
$query = parent::getEloquentQuery();

// Non-admin users can only see their own pages
if (auth()->check() && !auth()->user()->isAdmin()) {
$query->where('user_id', auth()->id());
}

return $query;
}

public static function getRelations(): array
{
return [
Expand Down
11 changes: 11 additions & 0 deletions app/Models/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class Article extends Model
{
protected $fillable = [
'user_id',
'category_id',
'title',
'slug',
Expand All @@ -29,6 +30,11 @@ class Article extends Model
protected static function booted(): void
{
static::creating(function (Article $article) {
// Auto-assign current user ID when creating
if (empty($article->user_id) && auth()->check()) {
$article->user_id = auth()->id();
}

if (empty($article->slug)) {
$baseSlug = Str::slug($article->title);
$slug = $baseSlug;
Expand All @@ -52,6 +58,11 @@ public function category(): BelongsTo
return $this->belongsTo(Category::class);
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

public function views(): HasMany
{
return $this->hasMany(ArticleView::class);
Expand Down
43 changes: 43 additions & 0 deletions app/Models/Media.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Spatie\MediaLibrary\MediaCollections\Models\Media as BaseMedia;

class Media extends BaseMedia
{
protected $fillable = [
'user_id',
'model_type',
'model_id',
'uuid',
'collection_name',
'name',
'file_name',
'mime_type',
'disk',
'conversions_disk',
'size',
'manipulations',
'custom_properties',
'generated_conversions',
'responsive_images',
'order_column',
];

protected static function booted(): void
{
static::creating(function (Media $media) {
// Auto-assign current user ID when creating
if (empty($media->user_id) && auth()->check()) {
$media->user_id = auth()->id();
}
});
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
11 changes: 11 additions & 0 deletions app/Models/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class Page extends Model
{
protected $fillable = [
'user_id',
'parent_id',
'title',
'slug',
Expand All @@ -28,6 +29,11 @@ class Page extends Model
protected static function booted(): void
{
static::creating(function (Page $page) {
// Auto-assign current user ID when creating
if (empty($page->user_id) && auth()->check()) {
$page->user_id = auth()->id();
}

if (empty($page->slug)) {
$baseSlug = Str::slug($page->title);
$slug = $baseSlug;
Expand All @@ -54,6 +60,11 @@ public function parent(): BelongsTo
return $this->belongsTo(Page::class, 'parent_id');
}

public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}

public function children(): HasMany
{
return $this->hasMany(Page::class, 'parent_id')->orderBy('sort_order');
Expand Down
16 changes: 16 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

Expand Down Expand Up @@ -73,4 +74,19 @@ public function isEditor(): bool
{
return $this->role === UserRole::Editor;
}

public function articles(): HasMany
{
return $this->hasMany(Article::class);
}

public function pages(): HasMany
{
return $this->hasMany(Page::class);
}

public function media(): HasMany
{
return $this->hasMany(Media::class);
}
}
67 changes: 67 additions & 0 deletions app/Policies/ArticlePolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace App\Policies;

use App\Models\Article;
use App\Models\User;

class ArticlePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}

/**
* Determine whether the user can view the model.
*/
public function view(User $user, Article $article): bool
{
return true;
}

/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin() || $user->isEditor();
}

/**
* Determine whether the user can update the model.
*/
public function update(User $user, Article $article): bool
{
// Admin can update anything, Editor can only update their own
return $user->isAdmin() || $article->user_id === $user->id;
}

/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Article $article): bool
{
// Admin can delete anything, Editor can only delete their own
return $user->isAdmin() || $article->user_id === $user->id;
}
Comment on lines +37 to +50
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

New ownership-based authorization is introduced here, but there are no corresponding feature tests asserting that an Editor cannot update/delete an Article owned by an Admin (and can still manage their own). Add coverage (e.g., in tests/Feature/AdminCrudTest.php or a dedicated security test) that exercises the Filament delete/edit actions and verifies 403/denial for non-owners.

Copilot uses AI. Check for mistakes.

/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Article $article): bool
{
return $user->isAdmin() || $article->user_id === $user->id;
}

/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Article $article): bool
{
return $user->isAdmin();
}
}
67 changes: 67 additions & 0 deletions app/Policies/MediaPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php

namespace App\Policies;

use App\Models\Media;
use App\Models\User;

class MediaPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}

/**
* Determine whether the user can view the model.
*/
public function view(User $user, Media $media): bool
{
return true;
}

/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return $user->isAdmin() || $user->isEditor();
}

/**
* Determine whether the user can update the model.
*/
public function update(User $user, Media $media): bool
{
// Admin can update anything, Editor can only update their own
return $user->isAdmin() || $media->user_id === $user->id;
}

/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Media $media): bool
{
// Admin can delete anything, Editor can only delete their own
return $user->isAdmin() || $media->user_id === $user->id;
}
Comment on lines +37 to +50
Copy link

Copilot AI Mar 10, 2026

Choose a reason for hiding this comment

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

Please add feature coverage for the new Media ownership rules (e.g., Editor cannot delete media uploaded by another user/admin, but can delete their own). This is especially important because Media is managed through Filament actions/bulk actions and the vulnerability being fixed is broken object-level authorization.

Copilot uses AI. Check for mistakes.

/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Media $media): bool
{
return $user->isAdmin() || $media->user_id === $user->id;
}

/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Media $media): bool
{
return $user->isAdmin();
}
}
Loading
Loading