diff --git a/src/Auth/Permission.php b/src/Auth/Permission.php index 905ca852c4d..f2fe111019a 100644 --- a/src/Auth/Permission.php +++ b/src/Auth/Permission.php @@ -105,6 +105,50 @@ public function permissions() })->values(); } + public function flattened() + { + if (! $this->callback) { + return [ + $this, + ...$this->children()->map(function ($child) { + return (new self) + ->value($child->value()) + ->label($child->label()) + ->placeholder($this->placeholder) + ->placeholderLabel($this->placeholderLabel) + ->placeholderValue($this->placeholderValue) + ->children($child->children()->all()) + ->group($this->group()); + })->flatMap->flattened()->all(), + ]; + } + + $items = call_user_func($this->callback); + + return collect($items)->flatMap(function ($replacement) { + $replaced = (new self) + ->value($this->value) + ->label($this->label) + ->placeholder($this->placeholder) + ->placeholderLabel($replacement['label']) + ->placeholderValue($replacement['value']) + ->group($this->group()); + + $children = $this->children()->map(function ($child) use ($replacement) { + return (new self) + ->value($child->originalValue()) + ->label($child->originalLabel()) + ->placeholder($this->placeholder) + ->placeholderLabel($replacement['label']) + ->placeholderValue($replacement['value']) + ->children($child->children()->all()) + ->group($this->group()); + }); + + return [$replaced, ...$children->flatMap->flattened()->all()]; + })->values(); + } + public function children(?array $children = null) { return $this diff --git a/src/Auth/Permissions.php b/src/Auth/Permissions.php index 7d20b524f22..c3885d0e308 100644 --- a/src/Auth/Permissions.php +++ b/src/Auth/Permissions.php @@ -10,9 +10,14 @@ class Permissions protected $permissions = []; protected $groups = []; protected $pendingGroup = null; + protected $booted = false; public function boot() { + if ($this->booted) { + return $this; + } + $early = $this->permissions; $this->permissions = []; @@ -23,6 +28,9 @@ public function boot() } $this->permissions = array_merge($this->permissions, $early); + $this->booted = true; + + return $this; } public function extend($callback) @@ -125,4 +133,9 @@ public function group($name, $label, $permissions = null) $this->pendingGroup = null; } + + public function flattened() + { + return collect($this->permissions)->flatMap->flattened(); + } } diff --git a/src/Http/Middleware/CP/Authorize.php b/src/Http/Middleware/CP/Authorize.php index 336acc232f8..17d21efd299 100644 --- a/src/Http/Middleware/CP/Authorize.php +++ b/src/Http/Middleware/CP/Authorize.php @@ -18,7 +18,6 @@ public function handle($request, Closure $next) } if ($user->cant('access cp')) { - // dd('theres a user but they are unauthorized', $user); throw new AuthorizationException('Unauthorized.'); } diff --git a/src/Policies/AssetContainerPolicy.php b/src/Policies/AssetContainerPolicy.php index 2daa4455388..2444b563daa 100644 --- a/src/Policies/AssetContainerPolicy.php +++ b/src/Policies/AssetContainerPolicy.php @@ -9,7 +9,9 @@ class AssetContainerPolicy { public function before($user, $ability) { - if (User::fromUser($user)->hasPermission('configure asset containers')) { + $user = User::fromUser($user); + + if ($user->isSuper() || $user->hasPermission('configure asset containers')) { return true; } } diff --git a/src/Policies/AssetFolderPolicy.php b/src/Policies/AssetFolderPolicy.php index f977508f84d..bd8b9dcd5a3 100644 --- a/src/Policies/AssetFolderPolicy.php +++ b/src/Policies/AssetFolderPolicy.php @@ -8,6 +8,13 @@ class AssetFolderPolicy { + public function before($user) + { + if (User::fromUser($user)->isSuper()) { + return true; + } + } + public function create($user, $assetContainer) { $user = User::fromUser($user); diff --git a/src/Policies/AssetPolicy.php b/src/Policies/AssetPolicy.php index 2892deed267..f15ca620b51 100644 --- a/src/Policies/AssetPolicy.php +++ b/src/Policies/AssetPolicy.php @@ -10,7 +10,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure asset containers')) { + if ($user->isSuper() || $user->hasPermission('configure asset containers')) { return true; } } diff --git a/src/Policies/CollectionPolicy.php b/src/Policies/CollectionPolicy.php index d17a9682cec..094096051e6 100644 --- a/src/Policies/CollectionPolicy.php +++ b/src/Policies/CollectionPolicy.php @@ -13,7 +13,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure collections')) { + if ($user->isSuper() || $user->hasPermission('configure collections')) { return true; } } diff --git a/src/Policies/EntryPolicy.php b/src/Policies/EntryPolicy.php index ba6f7ab9e57..c6cc1caaf71 100644 --- a/src/Policies/EntryPolicy.php +++ b/src/Policies/EntryPolicy.php @@ -12,7 +12,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure collections')) { + if ($user->isSuper() || $user->hasPermission('configure collections')) { return true; } } diff --git a/src/Policies/FieldsetPolicy.php b/src/Policies/FieldsetPolicy.php index dcdc22d0502..ed9ecb1d7bb 100644 --- a/src/Policies/FieldsetPolicy.php +++ b/src/Policies/FieldsetPolicy.php @@ -10,7 +10,7 @@ public function before($user, $ability, $fieldset) { $user = User::fromUser($user); - if ($user->hasPermission('configure fields')) { + if ($user->isSuper() || $user->hasPermission('configure fields')) { return true; } } diff --git a/src/Policies/FormPolicy.php b/src/Policies/FormPolicy.php index 13358aba186..21348d97b41 100644 --- a/src/Policies/FormPolicy.php +++ b/src/Policies/FormPolicy.php @@ -11,7 +11,7 @@ public function before($user, $ability) { $user = User::fromUser($user); - if ($user->hasPermission('configure forms')) { + if ($user->isSuper() || $user->hasPermission('configure forms')) { return true; } } diff --git a/src/Policies/FormSubmissionPolicy.php b/src/Policies/FormSubmissionPolicy.php index e5d4e7b318e..123ce290c9c 100644 --- a/src/Policies/FormSubmissionPolicy.php +++ b/src/Policies/FormSubmissionPolicy.php @@ -10,7 +10,7 @@ public function before($user, $ability) { $user = User::fromUser($user); - if ($user->hasPermission('configure forms')) { + if ($user->isSuper() || $user->hasPermission('configure forms')) { return true; } } diff --git a/src/Policies/GlobalSetPolicy.php b/src/Policies/GlobalSetPolicy.php index 6f48eb36b49..d0710caf1a3 100644 --- a/src/Policies/GlobalSetPolicy.php +++ b/src/Policies/GlobalSetPolicy.php @@ -13,7 +13,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure globals')) { + if ($user->isSuper() || $user->hasPermission('configure globals')) { return true; } } diff --git a/src/Policies/NavPolicy.php b/src/Policies/NavPolicy.php index 9b63a6ad26e..02d55bdd82c 100644 --- a/src/Policies/NavPolicy.php +++ b/src/Policies/NavPolicy.php @@ -13,7 +13,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure navs')) { + if ($user->isSuper() || $user->hasPermission('configure navs')) { return true; } } diff --git a/src/Policies/NavTreePolicy.php b/src/Policies/NavTreePolicy.php index 2caa568e0af..1fbdfc8dcef 100644 --- a/src/Policies/NavTreePolicy.php +++ b/src/Policies/NavTreePolicy.php @@ -8,6 +8,13 @@ class NavTreePolicy extends NavPolicy { use Concerns\HasMultisitePolicy; + public function before($user) + { + if (User::fromUser($user)->isSuper()) { + return true; + } + } + public function view($user, $nav) { $user = User::fromUser($user); diff --git a/src/Policies/SitePolicy.php b/src/Policies/SitePolicy.php index 5981a5c2e90..61cab2040b1 100644 --- a/src/Policies/SitePolicy.php +++ b/src/Policies/SitePolicy.php @@ -7,6 +7,13 @@ class SitePolicy { + public function before($user) + { + if (User::fromUser($user)->isSuper()) { + return true; + } + } + public function view($user, $site) { if (! Site::multiEnabled()) { diff --git a/src/Policies/TaxonomyPolicy.php b/src/Policies/TaxonomyPolicy.php index ed91bd4c8bc..3161bfb7969 100644 --- a/src/Policies/TaxonomyPolicy.php +++ b/src/Policies/TaxonomyPolicy.php @@ -13,7 +13,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure taxonomies')) { + if ($user->isSuper() || $user->hasPermission('configure taxonomies')) { return true; } } diff --git a/src/Policies/TermPolicy.php b/src/Policies/TermPolicy.php index b823b87743d..706a4f5e13d 100644 --- a/src/Policies/TermPolicy.php +++ b/src/Policies/TermPolicy.php @@ -12,7 +12,7 @@ public function before($user) { $user = User::fromUser($user); - if ($user->hasPermission('configure taxonomies')) { + if ($user->isSuper() || $user->hasPermission('configure taxonomies')) { return true; } } diff --git a/src/Policies/UserPolicy.php b/src/Policies/UserPolicy.php index 3bf5380ba11..c691d2256ba 100644 --- a/src/Policies/UserPolicy.php +++ b/src/Policies/UserPolicy.php @@ -6,6 +6,13 @@ class UserPolicy { + public function before($user) + { + if (User::fromUser($user)->isSuper()) { + return true; + } + } + public function index($authed) { $authed = User::fromUser($authed); diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index c37bb1b03af..dd7c5c9d50b 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -14,6 +14,7 @@ use Statamic\Contracts\Auth\RoleRepository; use Statamic\Contracts\Auth\UserGroupRepository; use Statamic\Contracts\Auth\UserRepository; +use Statamic\Facades\Permission; use Statamic\Facades\User; use Statamic\Policies; @@ -83,12 +84,21 @@ public function boot() return new UserProvider; }); - Gate::before(function ($user, $ability) { - return optional(User::fromUser($user))->isSuper() ? true : null; - }); - Gate::after(function ($user, $ability) { - return optional(User::fromUser($user))->hasPermission($ability) === true ? true : null; + // If the ability isn't a Statamic permission, we don't want to get involved. 🙈 + if (! Permission::boot()->flattened()->map->value()->contains($ability)) { + return null; + } + + $user = User::fromUser($user); + + if ($user->isSuper()) { + return true; + } + + if ($user->hasPermission($ability)) { + return true; + } }); foreach ($this->policies as $key => $policy) { diff --git a/src/Sites/Site.php b/src/Sites/Site.php index 9e81178e7b1..cc1f6f365aa 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -31,7 +31,7 @@ public function handle() public function name() { - return $this->config['name']; + return $this->config['name'] ?? $this->handle(); } public function locale() diff --git a/tests/CP/Navigation/ActiveNavItemTest.php b/tests/CP/Navigation/ActiveNavItemTest.php index 5f344c902c8..3da17b568a0 100644 --- a/tests/CP/Navigation/ActiveNavItemTest.php +++ b/tests/CP/Navigation/ActiveNavItemTest.php @@ -222,8 +222,8 @@ public function it_can_check_if_parent_extension_with_array_based_children_item_ $nav->tools('SEO Pro') ->url('/cp/seo-pro') ->children([ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]); }); @@ -247,8 +247,8 @@ public function it_can_check_when_parent_and_array_based_child_extension_items_a $nav->tools('SEO Pro') ->url('/cp/seo-pro') ->children([ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]); }); @@ -272,8 +272,8 @@ public function it_can_check_when_parent_and_array_based_descendant_of_child_ext $nav->tools('SEO Pro') ->url('/cp/seo-pro') ->children([ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]); }); @@ -298,9 +298,9 @@ public function it_builds_extension_children_closure_when_not_active() ->url('/cp/seo-pro') ->children(function () use ($nav) { return [ - $nav->item('Reports')->url('/cp/seo-pro/')->can('view seo reports'), - $nav->item('Site Defaults')->url('/cp/seo-pro/site-defaults')->can('edit seo site defaults'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/'), + $nav->item('Site Defaults')->url('/cp/seo-pro/site-defaults'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]; }); }); @@ -324,8 +324,8 @@ public function it_resolves_extension_children_closure_and_can_check_when_parent ->url('/cp/seo-pro') ->children(function () use ($nav) { return [ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]; }); }); @@ -351,8 +351,8 @@ public function it_resolves_extension_children_closure_and_can_check_when_parent ->url('/cp/seo-pro') ->children(function () use ($nav) { return [ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]; }); }); @@ -378,8 +378,8 @@ public function it_resolves_extension_children_closure_and_can_check_when_parent ->url('/cp/seo-pro') ->children(function () use ($nav) { return [ - $nav->item('Reports')->url('/cp/seo-pro/reports')->can('view seo reports'), - $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults')->can('edit seo section defaults'), + $nav->item('Reports')->url('/cp/seo-pro/reports'), + $nav->item('Section Defaults')->url('/cp/seo-pro/section-defaults'), ]; }); }); diff --git a/tests/CP/Navigation/NavTest.php b/tests/CP/Navigation/NavTest.php index 91834f1d861..4f5fd952300 100644 --- a/tests/CP/Navigation/NavTest.php +++ b/tests/CP/Navigation/NavTest.php @@ -2,6 +2,7 @@ namespace Tests\CP\Navigation; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Route; use PHPUnit\Framework\Attributes\Test; use Statamic\CP\Navigation\NavItem; @@ -71,6 +72,8 @@ public function it_can_more_explicitly_create_a_nav_item() #[Test] public function it_can_create_a_nav_item_with_a_more_custom_config() { + Gate::policy(DroidsClass::class, DroidsPolicy::class); + $this->actingAs(tap(User::make()->makeSuper())->save()); Nav::droids('C-3PO') @@ -78,7 +81,7 @@ public function it_can_create_a_nav_item_with_a_more_custom_config() ->active('threepio*') ->url('/human-cyborg-relations') ->view('cp.nav.importer') - ->can('index', 'DroidsClass') + ->can('index', DroidsClass::class) ->attributes(['target' => '_blank', 'class' => 'red']); $item = $this->build()->get('Droids')->first(); @@ -90,7 +93,7 @@ public function it_can_create_a_nav_item_with_a_more_custom_config() $this->assertEquals('cp.nav.importer', $item->view()); $this->assertEquals('threepio*', $item->active()); $this->assertEquals('index', $item->authorization()->ability); - $this->assertEquals('DroidsClass', $item->authorization()->arguments); + $this->assertEquals(DroidsClass::class, $item->authorization()->arguments); $this->assertEquals(' target="_blank" class="red"', $item->attributes()); } @@ -260,6 +263,13 @@ public function it_sets_parent_icon_on_children() #[Test] public function it_doesnt_build_children_that_the_user_is_not_authorized_to_see() { + // Assume we're dealing with Statamic permissions. Technically nav items + // could use arbitrary ability strings that correspond to Gate::define(). + Facades\Permission::register('view jedi diaries'); + Facades\Permission::register('view jedi logs'); + Facades\Permission::register('view sith diaries'); + Facades\Permission::register('view sith logs'); + $this->setTestRoles(['sith' => ['view sith diaries']]); $this->actingAs(tap(User::make()->assignRole('sith'))->save()); @@ -720,3 +730,15 @@ protected function build() return Nav::build()->pluck('items', 'display'); } } + +class DroidsClass +{ +} + +class DroidsPolicy +{ + public function index() + { + return true; + } +} diff --git a/tests/Permissions/GateTest.php b/tests/Permissions/GateTest.php new file mode 100644 index 00000000000..89a15263bc9 --- /dev/null +++ b/tests/Permissions/GateTest.php @@ -0,0 +1,124 @@ +delete(); + + User::all()->each->delete(); + + parent::tearDown(); + } + + #[Test] + #[DataProvider('gateProvider')] + public function gate_checks($userCallback, $permission, $expectsToBeAllowed) + { + // Add a Statamic permission. By adding a custom one it proves + // that it's not just "core" permissions that will work, but + // also any permission registered into Statamic. + Permission::extend(function () { + Permission::register('statamic'); + }); + + Collection::make('blog')->save(); + + // Add a role that has the permission since permissions + // cannot be applied directly to users. + Role::make('test') + ->addPermission('statamic') + ->addPermission('gate') + ->addPermission('edit blog entries') + ->save(); + + // Add a gate, which is how someone would define + // something completely separate from Statamic. + Gate::define('gate', fn ($user) => $user->email === 'allowed@domain.com' ? true : null); + + $this->actingAs($userCallback()->save()); + + $this->assertEquals( + $expectsToBeAllowed, + Gate::allows($permission), + 'User should '.($expectsToBeAllowed ? '' : 'not ').'be allowed.' + ); + } + + public static function gateProvider() + { + return [ + 'statamic permission, super user' => [ + fn () => User::make()->makeSuper(), + 'statamic', + true, + ], + 'statamic permission, user with permission' => [ + fn () => User::make()->assignRole('test'), + 'statamic', + true, + ], + 'statamic permission, user without permission' => [ + fn () => User::make(), + 'statamic', + false, + ], + 'statamic policy permission, super user' => [ + fn () => User::make()->makeSuper(), + 'edit blog entries', + true, + ], + 'statamic policy permission, user with permission' => [ + fn () => User::make()->assignRole('test'), + 'edit blog entries', + true, + ], + 'statamic policy permission, user without permission' => [ + fn () => User::make(), + 'edit blog entries', + false, + ], + 'non-statamic permission, super user' => [ + fn () => User::make()->makeSuper(), + 'gate', + false, + ], + 'non-statamic permission, user with permission' => [ + fn () => User::make()->email('allowed@domain.com'), + 'gate', + true, + ], + 'non-statamic permission, user without permission' => [ + fn () => User::make()->email('denied@domain.com'), + 'gate', + false, + ], + 'non-statamic permission, user has permission in role' => [ + fn () => User::make()->assignRole('test'), + 'gate', + // Even though the role has the permission, we should not be + // checking it if it's not registered as a Statamic permission. + false, + ], + ]; + } +} diff --git a/tests/Permissions/PermissionsTest.php b/tests/Permissions/PermissionsTest.php index 914416508d3..e396e78b2f3 100644 --- a/tests/Permissions/PermissionsTest.php +++ b/tests/Permissions/PermissionsTest.php @@ -2,6 +2,7 @@ namespace Tests\Permissions; +use Facades\Statamic\Auth\CorePermissions; use Illuminate\Support\Collection; use PHPUnit\Framework\Attributes\Test; use Statamic\Auth\Permissions; @@ -136,6 +137,25 @@ public function it_places_any_permissions_registered_early_without_extend_callba $this->assertEquals(['three', 'one', 'two'], $names); } + #[Test] + public function booting_is_only_done_once() + { + CorePermissions::shouldReceive('boot')->once(); + + $permissions = new Permissions; + + $callbackCount = 0; + $permissions->extend(function ($arg) use ($permissions, &$callbackCount) { + $this->assertEquals($permissions, $arg); + $callbackCount = true; + }); + + $returned = $permissions->boot()->boot()->boot()->boot(); + + $this->assertSame($permissions, $returned); + $this->assertEquals(1, $callbackCount); + } + #[Test] public function it_makes_a_tree() { @@ -285,6 +305,34 @@ public function it_gets_all_permissions_in_a_flattened_structure() ])->sort()->values()->all(), $all->keys()->sort()->values()->all()); } + #[Test] + public function it_gets_all_permissions_with_placeholders_resolved_in_a_flat_array() + { + $this->setupComplicatedTest($permissions = new Permissions); + + $resolved = $permissions->flattened(); + + $this->assertEquals(collect([ + 'one', + 'child-one', + 'child-two', + + 'two', + 'child-three', + 'nested-child', + + 'three', + + 'four first', + 'replaced child first', + 'replaced nested child first', + + 'four second', + 'replaced child second', + 'replaced nested child second', + ])->all(), $resolved->map->value()->all()); + } + #[Test] public function existing_permissions_can_be_modified() { diff --git a/tests/Sites/SiteTest.php b/tests/Sites/SiteTest.php index a420833d49d..6244b306ca8 100644 --- a/tests/Sites/SiteTest.php +++ b/tests/Sites/SiteTest.php @@ -33,6 +33,14 @@ public function gets_name() $this->assertEquals('English', $site->name()); } + #[Test] + public function name_falls_back_to_handle() + { + $site = new Site('en', []); + + $this->assertEquals('en', $site->name()); + } + #[Test] public function gets_locale() { diff --git a/tests/Sites/SitesTest.php b/tests/Sites/SitesTest.php index 39897b12396..807f043885e 100644 --- a/tests/Sites/SitesTest.php +++ b/tests/Sites/SitesTest.php @@ -61,6 +61,7 @@ public function gets_authorized_sites() $this->actingAs(tap(User::make()->assignRole('test'))->save()); \Statamic\Facades\Site::shouldReceive('multiEnabled')->andReturnTrue(); + \Statamic\Facades\Site::shouldReceive('all')->andReturn(collect()); // CorePermissions calls this. It's irrelevant to this test. tap($this->sites->authorized(), function ($sites) { $this->assertInstanceOf(Collection::class, $sites);