Skip to content
Draft
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
4 changes: 1 addition & 3 deletions app-modules/events/tests/Feature/EventResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
use He4rt\Identity\User\Models\User;
use He4rt\PanelAdmin\Filament\Resources\Events\EventResource;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\CreateEvent;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\EditEvent;
Expand All @@ -26,9 +25,8 @@
use function Pest\Livewire\livewire;

beforeEach(function (): void {
$admin = User::factory()->create(['username' => 'events-test-admin']);
$admin = panelAdminUser();

config(['he4rt.admins' => 'events-test-admin']);
$this->actingAs($admin);

Filament::setCurrentPanel(Filament::getPanel('admin'));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<?php

declare(strict_types=1);

use Illuminate\Contracts\Cache\Factory;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';

throw_if(blank($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && blank($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');

/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table): void {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestampsTz();

$table->unique(['name', 'guard_name']);
});

/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames): void {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}

$table->string('name');
$table->string('guard_name');
$table->timestampsTz();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});

Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams): void {
$table->unsignedBigInteger($pivotPermission);

$table->string('model_type');
$table->uuid($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');

$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');

$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});

Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams): void {
$table->unsignedBigInteger($pivotRole);

$table->string('model_type');
$table->uuid($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');

$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');

$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});

Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);

$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();

$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();

$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});

resolve(Factory::class)
->store(config('permission.cache.store') !== 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}

public function down(): void
{
$tableNames = config('permission.table_names');

throw_if(blank($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');

Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};
11 changes: 4 additions & 7 deletions app-modules/identity/src/User/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Illuminate\Notifications\Notifiable;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\Permission\Traits\HasRoles;

/**
* @property string $id
Expand All @@ -47,15 +48,11 @@ final class User extends Authenticatable implements FilamentUser, HasMedia, HasN
use HasAddress;
/** @use HasFactory<UserFactory> */
use HasFactory;
use HasRoles;
use HasUuids;
use InteractsWithMedia;
use Notifiable;

public function isAdmin(): bool
{
return in_array($this->username, str(config('he4rt.admins'))->explode(',')->toArray(), strict: true);
}

/**
* @return MorphMany<ExternalIdentity, $this>
*/
Expand Down Expand Up @@ -91,8 +88,8 @@ public function registerMediaCollections(): void
public function canAccessPanel(Panel $panel): bool
{
return match ($panel->getId()) {
'admin' => app()->isProduction() ? $this->isAdmin() : true,
default => true
'admin' => $this->roles()->exists(),
default => true,
};
}

Expand Down
49 changes: 49 additions & 0 deletions app-modules/identity/tests/Feature/Access/PermissionTablesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

use He4rt\Identity\User\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Spatie\Permission\Models\Role;

test('permission tables exist', function (): void {
expect(Schema::hasTable('permissions'))->toBeTrue()
->and(Schema::hasTable('roles'))->toBeTrue()
->and(Schema::hasTable('model_has_roles'))->toBeTrue()
->and(Schema::hasTable('model_has_permissions'))->toBeTrue()
->and(Schema::hasTable('role_has_permissions'))->toBeTrue();
});

test('model_morph_key stores a uuid, not a bigint', function (): void {
expect(Schema::getColumnType('model_has_roles', 'model_id'))->toBe('uuid')
->and(Schema::getColumnType('model_has_permissions', 'model_id'))->toBe('uuid');
});

test('role timestamps are timezone aware', function (): void {
expect(Schema::getColumnType('roles', 'created_at', fullDefinition: true))
->toContain('with time zone')
->and(Schema::getColumnType('permissions', 'updated_at', fullDefinition: true))
->toContain('with time zone');
});

test('assigning a role to a uuid user keeps the uuid intact', function (): void {
$user = User::factory()->create();
$role = Role::create(['name' => 'moderation:viewer', 'guard_name' => 'web']);

$user->assignRole($role);

$pivot = DB::table('model_has_roles')
->where('role_id', $role->id)
->sole();

expect($pivot->model_id)->toBe($user->id)
->and($user->fresh()->hasRole('moderation:viewer'))->toBeTrue();
});

test('the pivot stores the morph alias instead of the fqcn', function (): void {
$user = User::factory()->create();
$user->assignRole(Role::create(['name' => 'discord:viewer', 'guard_name' => 'web']));

expect(DB::table('model_has_roles')->value('model_type'))->toBe('user');
});
30 changes: 30 additions & 0 deletions app-modules/panel-admin/src/PanelAdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace He4rt\PanelAdmin;

use BezhanSalleh\FilamentShield\Facades\FilamentShield;
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
Expand All @@ -18,7 +19,9 @@
use He4rt\PanelAdmin\Moderation\ModerationCluster;
use He4rt\PanelAdmin\Pages\Dashboard;
use He4rt\PanelAdmin\Twitch\TwitchCluster;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Livewire\Livewire;

class PanelAdminServiceProvider extends ServiceProvider
Expand Down Expand Up @@ -86,6 +89,8 @@ public function register(): void

public function boot(): void
{
$this->configureShield();

$this->loadViewsFrom(__DIR__.'/../resources/views', 'panel-admin');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'panel-admin');

Expand All @@ -94,6 +99,31 @@ public function boot(): void
Livewire::component('moderation-dashboard', ModerationDashboardLivewire::class);
}

private function configureShield(): void
{
// Shield refuses the '_' separator alongside snake case, so the config carries a
// placeholder separator and this builder joins the parts itself.
FilamentShield::buildPermissionKeyUsing(
fn (string $affix, string $subject): string => Str::snake($affix).'_'.Str::snake($subject),
);

$this->registerPanelPolicies();
}

/**
* Authorising a panel screen is a presentation concern, so the policies live in this
* module instead of beside each domain model. Neither Laravel's discovery nor
* Shield's looks here, so teach the Gate where to look. Names that do not resolve
* are discarded by the Gate, and the framework default stays as a fallback.
*/
private function registerPanelPolicies(): void
{
Gate::guessPolicyNamesUsing(fn (string $model): array => [
'He4rt\\PanelAdmin\\Policies\\'.class_basename($model).'Policy',
'App\\Policies\\'.class_basename($model).'Policy',
]);
}

private function buildNavigation(NavigationBuilder $builder): NavigationBuilder
{

Expand Down
74 changes: 74 additions & 0 deletions app-modules/panel-admin/src/Policies/DiscordChannelPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace He4rt\PanelAdmin\Policies;

use He4rt\IntegrationDiscord\Models\DiscordChannel;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Foundation\Auth\User as AuthUser;

class DiscordChannelPolicy
{
use HandlesAuthorization;

public function viewAny(AuthUser $authUser): bool
{
return $authUser->can('view_any_discord_channel');
}

public function view(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('view_discord_channel');
}

public function create(AuthUser $authUser): bool
{
return $authUser->can('create_discord_channel');
}

public function update(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('update_discord_channel');
}

public function delete(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('delete_discord_channel');
}

public function deleteAny(AuthUser $authUser): bool
{
return $authUser->can('delete_any_discord_channel');
}

public function restore(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('restore_discord_channel');
}

public function forceDelete(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('force_delete_discord_channel');
}

public function forceDeleteAny(AuthUser $authUser): bool
{
return $authUser->can('force_delete_any_discord_channel');
}

public function restoreAny(AuthUser $authUser): bool
{
return $authUser->can('restore_any_discord_channel');
}

public function replicate(AuthUser $authUser, DiscordChannel $discordChannel): bool
{
return $authUser->can('replicate_discord_channel');
}

public function reorder(AuthUser $authUser): bool
{
return $authUser->can('reorder_discord_channel');
}
}
Loading