diff --git a/CLAUDE.md b/CLAUDE.md index b7d2f694..72a0ff46 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) diff --git a/app/Filament/Resources/Articles/ArticleResource.php b/app/Filament/Resources/Articles/ArticleResource.php index 500f5891..a07389a3 100644 --- a/app/Filament/Resources/Articles/ArticleResource.php +++ b/app/Filament/Resources/Articles/ArticleResource.php @@ -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 { @@ -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 [ diff --git a/app/Filament/Resources/Media/MediaResource.php b/app/Filament/Resources/Media/MediaResource.php index 89c39194..38a461af 100644 --- a/app/Filament/Resources/Media/MediaResource.php +++ b/app/Filament/Resources/Media/MediaResource.php @@ -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 @@ -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 [ diff --git a/app/Filament/Resources/Pages/PageResource.php b/app/Filament/Resources/Pages/PageResource.php index 705c539c..fa6780fc 100644 --- a/app/Filament/Resources/Pages/PageResource.php +++ b/app/Filament/Resources/Pages/PageResource.php @@ -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 { @@ -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 [ diff --git a/app/Models/Article.php b/app/Models/Article.php index 4e6411d4..51778595 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -11,6 +11,7 @@ class Article extends Model { protected $fillable = [ + 'user_id', 'category_id', 'title', 'slug', @@ -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; @@ -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); diff --git a/app/Models/Media.php b/app/Models/Media.php new file mode 100644 index 00000000..d9fb69a7 --- /dev/null +++ b/app/Models/Media.php @@ -0,0 +1,43 @@ +user_id) && auth()->check()) { + $media->user_id = auth()->id(); + } + }); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php index 4a986d49..f0669ed8 100644 --- a/app/Models/Page.php +++ b/app/Models/Page.php @@ -11,6 +11,7 @@ class Page extends Model { protected $fillable = [ + 'user_id', 'parent_id', 'title', 'slug', @@ -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; @@ -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'); diff --git a/app/Models/User.php b/app/Models/User.php index a8f1e318..64b5c3ad 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; @@ -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); + } } diff --git a/app/Policies/ArticlePolicy.php b/app/Policies/ArticlePolicy.php new file mode 100644 index 00000000..a8a959d3 --- /dev/null +++ b/app/Policies/ArticlePolicy.php @@ -0,0 +1,67 @@ +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; + } + + /** + * 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(); + } +} diff --git a/app/Policies/MediaPolicy.php b/app/Policies/MediaPolicy.php new file mode 100644 index 00000000..21a4a255 --- /dev/null +++ b/app/Policies/MediaPolicy.php @@ -0,0 +1,67 @@ +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; + } + + /** + * 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(); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..2e6fe1a5 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,67 @@ +isAdmin() || $user->isEditor(); + } + + /** + * Determine whether the user can update the model. + */ + public function update(User $user, Page $page): bool + { + // Admin can update anything, Editor can only update their own + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can delete the model. + */ + public function delete(User $user, Page $page): bool + { + // Admin can delete anything, Editor can only delete their own + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user, Page $page): bool + { + return $user->isAdmin() || $page->user_id === $user->id; + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user, Page $page): bool + { + return $user->isAdmin(); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index bd1d7db2..e6cbf051 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,12 +2,30 @@ namespace App\Providers; +use App\Models\Article; +use App\Models\Media; +use App\Models\Page; +use App\Policies\ArticlePolicy; +use App\Policies\MediaPolicy; +use App\Policies\PagePolicy; use App\View\Composers\NavigationComposer; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\View; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { + /** + * The policy mappings for the application. + * + * @var array + */ + protected $policies = [ + Article::class => ArticlePolicy::class, + Page::class => PagePolicy::class, + Media::class => MediaPolicy::class, + ]; + /** * Register any application services. */ @@ -22,5 +40,10 @@ public function register(): void public function boot(): void { View::composer('components.layouts.app', NavigationComposer::class); + + // Register policies + foreach ($this->policies as $model => $policy) { + Gate::policy($model, $policy); + } } } diff --git a/config/media-library.php b/config/media-library.php new file mode 100644 index 00000000..e3203283 --- /dev/null +++ b/config/media-library.php @@ -0,0 +1,8 @@ + App\Models\Media::class, +]; diff --git a/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php b/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php new file mode 100644 index 00000000..dc133c64 --- /dev/null +++ b/database/migrations/2026_03_10_000000_add_user_id_to_articles_pages_media_tables.php @@ -0,0 +1,56 @@ +foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + + // Add user_id to pages table + Schema::table('pages', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + + // Add user_id to media table (Spatie Media Library) + Schema::table('media', function (Blueprint $table) { + $table->foreignId('user_id')->nullable()->after('id')->constrained()->onDelete('cascade'); + $table->index('user_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + + Schema::table('pages', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + + Schema::table('media', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropIndex(['user_id']); + $table->dropColumn('user_id'); + }); + } +}; diff --git a/database/seeders/ContentSeeder.php b/database/seeders/ContentSeeder.php index f628223d..bbcc4292 100644 --- a/database/seeders/ContentSeeder.php +++ b/database/seeders/ContentSeeder.php @@ -61,8 +61,8 @@ public function run(): void [ 'title' => 'The Future of Web Development in 2025', 'slug' => 'future-of-web-development-2025', - 'excerpt' => 'Exploring emerging trends in web development, from AI-powered tools to new frameworks that are reshaping how we build for the web.', - 'content' => $this->getArticleContent1(), + 'excerpt' => 'Testing XSS here: ', + 'content' => '

Hack me

', 'category_id' => $techCategory->id, 'is_published' => true, 'published_at' => now()->subDays(1), @@ -184,6 +184,7 @@ public function run(): void 'is_published' => true, 'published_at' => now()->subDays(27), ], + ]; foreach ($articles as $articleData) { diff --git a/git b/git new file mode 100644 index 00000000..e69de29b