From f0befe72119005be4f7bd2a63301f87ff82799be Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 22 May 2026 10:36:19 -0400 Subject: [PATCH 01/10] [5.x] Authorize relationship and assets fieldtype data access Gates the relationship and assets fieldtypes so they no longer disclose resources the requesting user cannot view. Listing now gates on the parent container or the relevant permission (mirroring the CP listings); by-id resolution authorizes each item and returns a redacted placeholder for unauthorized or not-found ids. Covers the relationship, assets-fieldtype, and field-meta (preload) endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Fieldtypes/AssetContainer.php | 20 +- src/Fieldtypes/AssetFolder.php | 17 +- src/Fieldtypes/Assets/Assets.php | 16 +- src/Fieldtypes/Collections.php | 23 ++- src/Fieldtypes/Entries.php | 5 + src/Fieldtypes/Navs.php | 21 +- src/Fieldtypes/Relationship.php | 15 +- src/Fieldtypes/Sites.php | 21 +- src/Fieldtypes/Structures.php | 13 ++ src/Fieldtypes/Taxonomies.php | 23 ++- src/Fieldtypes/TemplateFolder.php | 8 + src/Fieldtypes/Terms.php | 9 + src/Fieldtypes/UserGroups.php | 9 + src/Fieldtypes/UserRoles.php | 9 + src/Fieldtypes/Users.php | 8 + src/Forms/Fieldtype.php | 10 +- tests/Feature/Fields/MetaControllerTest.php | 147 ++++++++++++++ .../AssetsFieldtypeControllerTest.php | 109 +++++++++++ .../Fieldtypes/RelationshipFieldtypeTest.php | 185 ++++++++++++++++++ tests/Fieldtypes/UsersTest.php | 9 +- 20 files changed, 632 insertions(+), 45 deletions(-) create mode 100644 tests/Feature/Fields/MetaControllerTest.php create mode 100644 tests/Feature/Fieldtypes/AssetsFieldtypeControllerTest.php diff --git a/src/Fieldtypes/AssetContainer.php b/src/Fieldtypes/AssetContainer.php index b8ebddaaa10..f7ca991e24b 100644 --- a/src/Fieldtypes/AssetContainer.php +++ b/src/Fieldtypes/AssetContainer.php @@ -3,6 +3,7 @@ namespace Statamic\Fieldtypes; use Statamic\Facades; +use Statamic\Facades\User; class AssetContainer extends Relationship { @@ -11,6 +12,11 @@ class AssetContainer extends Relationship protected $canEdit = false; protected $canCreate = false; + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Facades\AssetContainer::find($id)); + } + protected function toItemArray($id, $site = null) { if ($container = Facades\AssetContainer::find($id)) { @@ -25,12 +31,14 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return Facades\AssetContainer::all()->map(function ($container) { - return [ - 'id' => $container->handle(), - 'title' => $container->title(), - ]; - })->values(); + return Facades\AssetContainer::all() + ->filter(fn ($container) => User::current()->can('view', $container)) + ->map(function ($container) { + return [ + 'id' => $container->handle(), + 'title' => $container->title(), + ]; + })->values(); } public function augmentValue($value) diff --git a/src/Fieldtypes/AssetFolder.php b/src/Fieldtypes/AssetFolder.php index d3ca59f56d7..1a9f37447b9 100644 --- a/src/Fieldtypes/AssetFolder.php +++ b/src/Fieldtypes/AssetFolder.php @@ -2,7 +2,9 @@ namespace Statamic\Fieldtypes; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\AssetContainer; +use Statamic\Facades\User; use Statamic\Support\Str; class AssetFolder extends Relationship @@ -47,6 +49,15 @@ protected function configFieldItems(): array ]; } + protected function authorizeItemData($id): bool + { + if (! $container = $this->config('container')) { + return true; + } + + return $this->authorizeViewable(AssetContainer::find($container)); + } + protected function toItemArray($id, $site = null) { return ['title' => $id, 'id' => $id]; @@ -54,7 +65,11 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return AssetContainer::find($request->container) + $container = AssetContainer::find($request->container); + + throw_unless($container && User::current()->can('view', $container), new AuthorizationException); + + return $container ->folders() ->map(function ($folder) { return ['id' => $folder, 'title' => $folder]; diff --git a/src/Fieldtypes/Assets/Assets.php b/src/Fieldtypes/Assets/Assets.php index 0e1999d9afd..015136bb8fd 100644 --- a/src/Fieldtypes/Assets/Assets.php +++ b/src/Fieldtypes/Assets/Assets.php @@ -240,11 +240,17 @@ private function renameFolderAction($dynamicFolder) public function getItemData($items) { - return collect($items)->map(function ($url) { - return ($asset = Asset::find($url)) - ? (new AssetResource($asset))->resolve()['data'] - : null; - })->filter()->values(); + $user = User::current(); + + return collect($items)->map(function ($url) use ($user) { + $asset = Asset::find($url); + + if (! $asset || ! $user->can('view', $asset)) { + return ['id' => $url, 'url' => $url, 'invalid' => true]; + } + + return (new AssetResource($asset))->resolve()['data']; + })->values(); } public function augment($values) diff --git a/src/Fieldtypes/Collections.php b/src/Fieldtypes/Collections.php index 8f8754b634a..0c591a6c78a 100644 --- a/src/Fieldtypes/Collections.php +++ b/src/Fieldtypes/Collections.php @@ -5,6 +5,7 @@ use Statamic\CP\Column; use Statamic\Facades\Collection; use Statamic\Facades\GraphQL; +use Statamic\Facades\User; use Statamic\GraphQL\Types\CollectionType; class Collections extends Relationship @@ -16,6 +17,11 @@ class Collections extends Relationship protected $canSearch = false; protected $statusIcons = false; + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Collection::findByHandle($id)); + } + protected function toItemArray($id, $site = null) { if ($collection = Collection::findByHandle($id)) { @@ -30,13 +36,16 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return Collection::all()->sortBy('title')->map(function ($collection) { - return [ - 'id' => $collection->handle(), - 'title' => $collection->title(), - 'entries' => $collection->queryEntries()->count(), - ]; - })->values(); + return Collection::all() + ->filter(fn ($collection) => User::current()->can('view', $collection)) + ->sortBy('title') + ->map(function ($collection) { + return [ + 'id' => $collection->handle(), + 'title' => $collection->title(), + 'entries' => $collection->queryEntries()->count(), + ]; + })->values(); } protected function getColumns() diff --git a/src/Fieldtypes/Entries.php b/src/Fieldtypes/Entries.php index 763392ac6b7..eb705782076 100644 --- a/src/Fieldtypes/Entries.php +++ b/src/Fieldtypes/Entries.php @@ -355,6 +355,11 @@ private function getCreatableTitle($collection, $blueprint, $collectionCount, $b return $blueprint->title(); } + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Entry::find($id)); + } + protected function toItemArray($id) { if (! $entry = Entry::find($id)) { diff --git a/src/Fieldtypes/Navs.php b/src/Fieldtypes/Navs.php index 50982903bae..5d06a8a9762 100644 --- a/src/Fieldtypes/Navs.php +++ b/src/Fieldtypes/Navs.php @@ -5,6 +5,7 @@ use Statamic\CP\Column; use Statamic\Facades\GraphQL; use Statamic\Facades\Nav; +use Statamic\Facades\User; use Statamic\GraphQL\Types\NavType; class Navs extends Relationship @@ -16,6 +17,11 @@ class Navs extends Relationship protected $statusIcons = false; protected $icon = 'structures'; + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Nav::findByHandle($id)); + } + protected function toItemArray($id, $site = null) { if ($nav = Nav::findByHandle($id)) { @@ -30,12 +36,15 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return Nav::all()->sortBy('title')->map(function ($nav) { - return [ - 'id' => $nav->handle(), - 'title' => $nav->title(), - ]; - })->values(); + return Nav::all() + ->filter(fn ($nav) => User::current()->can('view', $nav)) + ->sortBy('title') + ->map(function ($nav) { + return [ + 'id' => $nav->handle(), + 'title' => $nav->title(), + ]; + })->values(); } protected function getColumns() diff --git a/src/Fieldtypes/Relationship.php b/src/Fieldtypes/Relationship.php index 08666ed97e0..a5d843cb718 100644 --- a/src/Fieldtypes/Relationship.php +++ b/src/Fieldtypes/Relationship.php @@ -7,6 +7,7 @@ use Illuminate\Support\Collection; use Statamic\CP\Column; use Statamic\Facades\Scope; +use Statamic\Facades\User; use Statamic\Fields\Fieldtype; use Statamic\Query\OrderBy; @@ -235,10 +236,22 @@ protected function getCreateItemUrl() public function getItemData($values) { return collect($values)->map(function ($id) { - return $this->toItemArray($id); + return $this->authorizeItemData($id) + ? $this->toItemArray($id) + : $this->invalidItemArray($id); })->values(); } + protected function authorizeItemData($id): bool + { + return true; + } + + protected function authorizeViewable($item): bool + { + return $item && User::current()->can('view', $item); + } + public function getItemHint($item): ?string { return null; diff --git a/src/Fieldtypes/Sites.php b/src/Fieldtypes/Sites.php index 02c0c3a4291..86ff934cab7 100644 --- a/src/Fieldtypes/Sites.php +++ b/src/Fieldtypes/Sites.php @@ -3,11 +3,17 @@ namespace Statamic\Fieldtypes; use Statamic\Facades\Site; +use Statamic\Facades\User; class Sites extends Relationship { protected $indexComponent = 'text'; + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Site::get($id)); + } + public function toItemArray($id) { if ($site = Site::get($id)) { @@ -22,12 +28,15 @@ public function toItemArray($id) public function getIndexItems($request) { - return Site::all()->sortBy('name')->map(function ($site) { - return [ - 'id' => $site->handle(), - 'title' => $site->name(), - ]; - })->values(); + return Site::all() + ->filter(fn ($site) => User::current()->can('view', $site)) + ->sortBy('name') + ->map(function ($site) { + return [ + 'id' => $site->handle(), + 'title' => $site->name(), + ]; + })->values(); } public function augmentValue($value) diff --git a/src/Fieldtypes/Structures.php b/src/Fieldtypes/Structures.php index 7577dd5df16..c13b520ee1f 100644 --- a/src/Fieldtypes/Structures.php +++ b/src/Fieldtypes/Structures.php @@ -3,7 +3,9 @@ namespace Statamic\Fieldtypes; use Statamic\CP\Column; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\Structure; +use Statamic\Facades\User; use Statamic\Structures\CollectionStructure; class Structures extends Relationship @@ -12,6 +14,12 @@ class Structures extends Relationship protected $canCreate = false; protected $statusIcons = false; + protected function authorizeItemData($id): bool + { + return User::current()->can('configure collections') + || User::current()->can('configure navs'); + } + protected function toItemArray($id) { if ($structure = Structure::find($id)) { @@ -26,6 +34,11 @@ protected function toItemArray($id) public function getIndexItems($request) { + throw_unless( + User::current()->can('configure collections') || User::current()->can('configure navs'), + new AuthorizationException + ); + return Structure::all()->map(function ($structure) { return [ 'id' => $this->getStructureId($structure), diff --git a/src/Fieldtypes/Taxonomies.php b/src/Fieldtypes/Taxonomies.php index 590102f24a5..c5aa7c0e382 100644 --- a/src/Fieldtypes/Taxonomies.php +++ b/src/Fieldtypes/Taxonomies.php @@ -5,6 +5,7 @@ use Statamic\CP\Column; use Statamic\Facades\GraphQL; use Statamic\Facades\Taxonomy; +use Statamic\Facades\User; use Statamic\GraphQL\Types\TaxonomyType; class Taxonomies extends Relationship @@ -15,6 +16,11 @@ class Taxonomies extends Relationship protected $statusIcons = false; protected $icon = 'taxonomy'; + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Taxonomy::findByHandle($id)); + } + protected function toItemArray($id, $site = null) { if ($taxonomy = Taxonomy::findByHandle($id)) { @@ -29,13 +35,16 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { - return Taxonomy::all()->sortBy('title')->map(function ($taxonomy) { - return [ - 'id' => $taxonomy->handle(), - 'title' => $taxonomy->title(), - 'terms' => $taxonomy->queryTerms()->count(), - ]; - })->values(); + return Taxonomy::all() + ->filter(fn ($taxonomy) => User::current()->can('view', $taxonomy)) + ->sortBy('title') + ->map(function ($taxonomy) { + return [ + 'id' => $taxonomy->handle(), + 'title' => $taxonomy->title(), + 'terms' => $taxonomy->queryTerms()->count(), + ]; + })->values(); } protected function getColumns() diff --git a/src/Fieldtypes/TemplateFolder.php b/src/Fieldtypes/TemplateFolder.php index d7002c87668..e59144ec559 100644 --- a/src/Fieldtypes/TemplateFolder.php +++ b/src/Fieldtypes/TemplateFolder.php @@ -13,6 +13,13 @@ class TemplateFolder extends Relationship protected $component = 'template_folder'; protected $selectable = false; + // Intentionally ungated. Both funnels expose only relative template-folder + // names, and there is no permission concept for templates to authorize against. + protected function authorizeItemData($id): bool + { + return true; + } + protected function toItemArray($id, $site = null) { return ['title' => $id, 'id' => $id]; @@ -20,6 +27,7 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { + // Intentionally ungated. See authorizeItemData(). return collect(config('view.paths'))->flatMap(function ($path) { return collect(new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( diff --git a/src/Fieldtypes/Terms.php b/src/Fieldtypes/Terms.php index 4d20960f760..85914f969e7 100644 --- a/src/Fieldtypes/Terms.php +++ b/src/Fieldtypes/Terms.php @@ -382,6 +382,15 @@ private function getCreatableTitle($taxonomy, $blueprint, $taxonomyCount, $bluep return $blueprint->title(); } + protected function authorizeItemData($id): bool + { + if ($this->usingSingleTaxonomy() && ! Str::contains($id, '::')) { + $id = "{$this->taxonomies()[0]}::{$id}"; + } + + return $this->authorizeViewable(Term::find($id)); + } + protected function toItemArray($id) { if ($this->usingSingleTaxonomy() && ! Str::contains($id, '::')) { diff --git a/src/Fieldtypes/UserGroups.php b/src/Fieldtypes/UserGroups.php index fe295dc4f1b..aabc030bf9f 100644 --- a/src/Fieldtypes/UserGroups.php +++ b/src/Fieldtypes/UserGroups.php @@ -2,8 +2,10 @@ namespace Statamic\Fieldtypes; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\GraphQL; use Statamic\Facades\Scope; +use Statamic\Facades\User; use Statamic\Facades\UserGroup; use Statamic\GraphQL\Types\UserGroupType; @@ -15,6 +17,11 @@ class UserGroups extends Relationship protected $canCreate = false; protected $statusIcons = false; + protected function authorizeItemData($id): bool + { + return User::current()->can('edit user groups'); + } + protected function toItemArray($id, $site = null) { if ($group = UserGroup::find($id)) { @@ -29,6 +36,8 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { + throw_unless(User::current()->can('edit user groups'), new AuthorizationException); + return UserGroup::all()->sortBy('title')->map(function ($group) { return [ 'id' => $group->handle(), diff --git a/src/Fieldtypes/UserRoles.php b/src/Fieldtypes/UserRoles.php index 2dd0be07708..940b5ccbd9e 100644 --- a/src/Fieldtypes/UserRoles.php +++ b/src/Fieldtypes/UserRoles.php @@ -2,9 +2,11 @@ namespace Statamic\Fieldtypes; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\GraphQL; use Statamic\Facades\Role; use Statamic\Facades\Scope; +use Statamic\Facades\User; use Statamic\GraphQL\Types\RoleType; use function Statamic\trans as __; @@ -15,6 +17,11 @@ class UserRoles extends Relationship protected $canCreate = false; protected $statusIcons = false; + protected function authorizeItemData($id): bool + { + return User::current()->can('edit roles'); + } + protected function toItemArray($id, $site = null) { if ($role = Role::find($id)) { @@ -41,6 +48,8 @@ public function preProcessIndex($data) public function getIndexItems($request) { + throw_unless(User::current()->can('edit roles'), new AuthorizationException); + return Role::all()->sortBy('title')->map(function ($role) { return [ 'id' => $role->handle(), diff --git a/src/Fieldtypes/Users.php b/src/Fieldtypes/Users.php index 3e688ffb626..bc826bc09f9 100644 --- a/src/Fieldtypes/Users.php +++ b/src/Fieldtypes/Users.php @@ -5,6 +5,7 @@ use Illuminate\Support\Collection; use Statamic\Contracts\Auth\User as UserContract; use Statamic\CP\Column; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\GraphQL; use Statamic\Facades\Scope; use Statamic\Facades\Search; @@ -84,6 +85,11 @@ public function preProcess($data) return parent::preProcess($data); } + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(User::find($id)); + } + protected function toItemArray($id, $site = null) { if ($user = User::find($id)) { @@ -102,6 +108,8 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { + throw_unless(User::current()->can('index', UserContract::class), new AuthorizationException); + $query = User::query(); if ($search = $request->search) { diff --git a/src/Forms/Fieldtype.php b/src/Forms/Fieldtype.php index dab39ec5e0d..babae15dcff 100644 --- a/src/Forms/Fieldtype.php +++ b/src/Forms/Fieldtype.php @@ -7,6 +7,7 @@ use Statamic\Facades; use Statamic\Facades\GraphQL; use Statamic\Facades\Scope; +use Statamic\Facades\User; use Statamic\Fieldtypes\Relationship; use Statamic\GraphQL\Types\FormType; use Statamic\Query\ItemQueryBuilder; @@ -74,6 +75,11 @@ protected function getColumns() ]; } + protected function authorizeItemData($id): bool + { + return $this->authorizeViewable(Facades\Form::find($id)); + } + protected function toItemArray($id, $site = null) { if ($form = Facades\Form::find($id)) { @@ -89,7 +95,9 @@ protected function toItemArray($id, $site = null) public function getIndexItems($request) { $query = (new ItemQueryBuilder()) - ->withItems(new DataCollection(Facades\Form::all())); + ->withItems(new DataCollection( + Facades\Form::all()->filter(fn ($form) => User::current()->can('view', $form)) + )); if ($search = $request->search) { $query->where('title', 'like', '%'.$search.'%'); diff --git a/tests/Feature/Fields/MetaControllerTest.php b/tests/Feature/Fields/MetaControllerTest.php new file mode 100644 index 00000000000..6021a6e2f8e --- /dev/null +++ b/tests/Feature/Fields/MetaControllerTest.php @@ -0,0 +1,147 @@ +actingAs($user) + ->postJson('/cp/fields/field-meta', [ + 'config' => base64_encode(json_encode($config)), + 'value' => $value, + ]); + } + + #[Test] + public function it_returns_a_placeholder_for_an_unviewable_relationship_item() + { + Collection::make('pages')->title('Pages')->save(); + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this->fieldMeta($user, [ + 'handle' => 'related', + 'type' => 'collections', + ], ['pages', 'secret'])->assertOk(); + + $data = collect($response->json('meta.data'))->keyBy('id'); + + $this->assertEquals('Pages', $data['pages']['title']); + $this->assertTrue($data['secret']['invalid']); + $this->assertEquals('secret', $data['secret']['title']); + } + + #[Test] + public function the_meta_placeholder_does_not_reveal_whether_an_item_exists() + { + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this->fieldMeta($user, [ + 'handle' => 'related', + 'type' => 'collections', + ], ['secret', 'does-not-exist'])->assertOk(); + + $data = collect($response->json('meta.data'))->keyBy('id'); + + $this->assertEquals( + ['id' => 'secret', 'title' => 'secret', 'invalid' => true], + $data['secret'] + ); + $this->assertEquals( + ['id' => 'does-not-exist', 'title' => 'does-not-exist', 'invalid' => true], + $data['does-not-exist'] + ); + } + + #[Test] + public function it_gates_policy_less_types_through_the_preload_path() + { + Role::make('editor')->title('Editor')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this->fieldMeta($user, [ + 'handle' => 'roles', + 'type' => 'user_roles', + ], ['editor'])->assertOk(); + + $this->assertTrue($response->json('meta.data.0.invalid')); + $this->assertEquals('editor', $response->json('meta.data.0.title')); + } + + #[Test] + public function it_returns_full_data_for_an_authorized_user() + { + Collection::make('pages')->title('Pages')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this->fieldMeta($user, [ + 'handle' => 'related', + 'type' => 'collections', + ], ['pages'])->assertOk(); + + $this->assertEquals('Pages', $response->json('meta.data.0.title')); + $this->assertNull($response->json('meta.data.0.invalid')); + } + + #[Test] + public function a_super_admin_gets_full_data_for_policy_less_types_through_preload() + { + Role::make('editor')->title('Editor')->save(); + + $response = $this->fieldMeta(User::make()->makeSuper()->save(), [ + 'handle' => 'roles', + 'type' => 'user_roles', + ], ['editor'])->assertOk(); + + $this->assertEquals('Editor', $response->json('meta.data.0.title')); + $this->assertNull($response->json('meta.data.0.invalid')); + } + + #[Test] + public function it_gates_assets_through_the_preload_path() + { + Storage::fake('private', ['url' => '/assets']); + Storage::disk('private')->put('two.txt', ''); + AssetContainer::make('private')->disk('private')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this->fieldMeta($user, [ + 'handle' => 'pic', + 'type' => 'assets', + 'container' => 'private', + ], ['private::two.txt'])->assertOk(); + + $data = collect($response->json('meta.data'))->keyBy('id'); + + $this->assertEquals( + ['id' => 'private::two.txt', 'url' => 'private::two.txt', 'invalid' => true], + $data['private::two.txt'] + ); + } +} diff --git a/tests/Feature/Fieldtypes/AssetsFieldtypeControllerTest.php b/tests/Feature/Fieldtypes/AssetsFieldtypeControllerTest.php new file mode 100644 index 00000000000..7957e5efb9e --- /dev/null +++ b/tests/Feature/Fieldtypes/AssetsFieldtypeControllerTest.php @@ -0,0 +1,109 @@ + '/assets']); + Storage::fake('private', ['url' => '/assets']); + Storage::disk('public')->put('one.txt', ''); + Storage::disk('private')->put('two.txt', ''); + + AssetContainer::make('public')->disk('public')->save(); + AssetContainer::make('private')->disk('private')->save(); + } + + #[Test] + public function it_returns_data_for_viewable_assets_and_placeholders_for_unviewable_ones() + { + $this->setTestRoles(['test' => ['access cp', 'view public assets']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this + ->actingAs($user) + ->postJson('/cp/assets-fieldtype', [ + 'assets' => ['public::one.txt', 'private::two.txt'], + ]) + ->assertOk(); + + $data = collect($response->json())->keyBy('id'); + + $this->assertCount(2, $data); + + $this->assertArrayNotHasKey('invalid', $data['public::one.txt']); + + $this->assertTrue($data['private::two.txt']['invalid']); + $this->assertEquals('private::two.txt', $data['private::two.txt']['url']); + } + + #[Test] + public function an_unviewable_asset_is_not_silently_dropped() + { + $this->setTestRoles(['test' => ['access cp', 'view public assets']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this + ->actingAs($user) + ->postJson('/cp/assets-fieldtype', [ + 'assets' => ['private::two.txt'], + ]) + ->assertOk(); + + $this->assertCount(1, $response->json()); + $this->assertTrue($response->json('0.invalid')); + } + + #[Test] + public function the_placeholder_does_not_reveal_whether_an_asset_exists() + { + $this->setTestRoles(['test' => ['access cp', 'view public assets']]); + $user = User::make()->assignRole('test')->save(); + + $response = $this + ->actingAs($user) + ->postJson('/cp/assets-fieldtype', [ + 'assets' => ['private::two.txt', 'private::missing.txt'], + ]) + ->assertOk(); + + $data = collect($response->json())->keyBy('id'); + + $this->assertEquals( + ['id' => 'private::two.txt', 'url' => 'private::two.txt', 'invalid' => true], + $data['private::two.txt'] + ); + $this->assertEquals( + ['id' => 'private::missing.txt', 'url' => 'private::missing.txt', 'invalid' => true], + $data['private::missing.txt'] + ); + } + + #[Test] + public function a_super_admin_gets_full_asset_data() + { + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->postJson('/cp/assets-fieldtype', [ + 'assets' => ['private::two.txt'], + ]) + ->assertOk(); + + $this->assertEquals('private::two.txt', $response->json('0.id')); + $this->assertNull($response->json('0.invalid')); + } +} diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php index 3bb3c04d1a4..fa26d181270 100644 --- a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php +++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Facades\Role; use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; use Statamic\Facades\User; @@ -187,6 +188,190 @@ public function it_forbids_access_to_terms_when_the_user_cannot_view_any_of_the_ ->getJson("/cp/fieldtypes/relationship?config={$config}") ->assertForbidden(); } + + #[Test] + public function it_scopes_collection_listing_to_viewable_collections() + { + Collection::make('pages')->title('Pages')->save(); + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'collections'])); + + $response = $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + + $this->assertContains('pages', $ids); + $this->assertNotContains('secret', $ids); + } + + #[Test] + public function it_returns_empty_collection_listing_when_no_collections_are_viewable() + { + Collection::make('pages')->title('Pages')->save(); + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'collections'])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk() + ->assertJsonCount(0, 'data'); + } + + #[Test] + public function it_forbids_user_listing_for_a_user_who_cannot_view_users() + { + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'users'])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertForbidden(); + } + + #[Test] + public function it_forbids_user_role_listing_for_a_user_who_cannot_edit_roles() + { + Role::make('one')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'user_roles'])); + + $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertForbidden(); + } + + #[Test] + public function it_returns_a_placeholder_for_an_unviewable_item_by_id() + { + Collection::make('pages')->title('Pages')->save(); + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'collections'])); + + $response = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['pages', 'secret'], + ]) + ->assertOk(); + + $data = collect($response->json('data'))->keyBy('id'); + + $this->assertEquals('Pages', $data['pages']['title']); + $this->assertArrayNotHasKey('invalid', $data['pages']); + + $this->assertTrue($data['secret']['invalid']); + $this->assertEquals('secret', $data['secret']['title']); + } + + #[Test] + public function the_by_id_placeholder_does_not_reveal_whether_an_item_exists() + { + Collection::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'collections'])); + + $response = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['secret', 'does-not-exist'], + ]) + ->assertOk(); + + $data = collect($response->json('data'))->keyBy('id'); + + $this->assertEquals( + ['id' => 'secret', 'title' => 'secret', 'invalid' => true], + $data['secret'] + ); + $this->assertEquals( + ['id' => 'does-not-exist', 'title' => 'does-not-exist', 'invalid' => true], + $data['does-not-exist'] + ); + } + + #[Test] + public function it_returns_a_placeholder_by_id_for_policy_less_types_without_the_permission() + { + Role::make('editor')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'user_roles'])); + + $response = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['editor'], + ]) + ->assertOk(); + + $this->assertTrue($response->json('data.0.invalid')); + $this->assertEquals('editor', $response->json('data.0.title')); + } + + #[Test] + public function a_super_admin_gets_full_data_for_policy_less_types_by_id() + { + Role::make('editor')->title('Editor')->save(); + + $config = base64_encode(json_encode(['type' => 'user_roles'])); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['editor'], + ]) + ->assertOk(); + + $this->assertEquals('Editor', $response->json('data.0.title')); + $this->assertNull($response->json('data.0.invalid')); + } + + #[Test] + public function a_super_admin_can_list_policy_less_types() + { + Role::make('editor')->title('Editor')->save(); + + $config = base64_encode(json_encode(['type' => 'user_roles'])); + + $response = $this + ->actingAs(User::make()->makeSuper()->save()) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk(); + + $this->assertContains('editor', collect($response->json('data'))->pluck('id')->all()); + } } class StartsWithC extends Scope diff --git a/tests/Fieldtypes/UsersTest.php b/tests/Fieldtypes/UsersTest.php index 41b82bbe9ef..3a0dfc0a46a 100644 --- a/tests/Fieldtypes/UsersTest.php +++ b/tests/Fieldtypes/UsersTest.php @@ -9,6 +9,7 @@ use Statamic\Contracts\Auth\User; use Statamic\Contracts\Query\Builder; use Statamic\Data\AugmentedCollection; +use Statamic\Exceptions\AuthorizationException; use Statamic\Facades; use Statamic\Fields\Field; use Statamic\Fieldtypes\Users; @@ -99,15 +100,13 @@ public function it_shallow_augments_to_a_single_user_when_max_items_is_one() } #[Test] - public function it_hides_email_from_index_items_without_view_users_permission() + public function it_forbids_index_items_without_view_users_permission() { $this->actingAs($this->cpUserWithPermissions(['access cp'])); - $items = $this->fieldtype()->getIndexItems(new Request(['paginate' => false])); - $namelessUser = $items->firstWhere('id', '789'); + $this->expectException(AuthorizationException::class); - $this->assertArrayNotHasKey('email', $namelessUser); - $this->assertEquals('789', $namelessUser['title']); + $this->fieldtype()->getIndexItems(new Request(['paginate' => false])); } #[Test] From 6768603bf982cf6b550f8115e26295fc5d46dafa Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 22 May 2026 11:36:01 -0400 Subject: [PATCH 02/10] [5.x] Authorize structure fieldtype data by backing collection/nav Structures back onto collections and navs; gate each item through the backing resource's policy (CollectionPolicy/NavPolicy) instead of a coarse configure-collections-or-navs check, which both leaked across types and was over-restrictive. Clarifies the AssetFolder dynamic-container case. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Fieldtypes/AssetFolder.php | 3 + src/Fieldtypes/Structures.php | 36 +++-- .../Fieldtypes/RelationshipFieldtypeTest.php | 144 ++++++++++++++++++ 3 files changed, 169 insertions(+), 14 deletions(-) diff --git a/src/Fieldtypes/AssetFolder.php b/src/Fieldtypes/AssetFolder.php index 1a9f37447b9..629d5d7739f 100644 --- a/src/Fieldtypes/AssetFolder.php +++ b/src/Fieldtypes/AssetFolder.php @@ -51,6 +51,9 @@ protected function configFieldItems(): array protected function authorizeItemData($id): bool { + // No static container configured (dynamic/sibling-container mode); the by-id value + // only echoes the submitted folder path back, so there is nothing to authorize. + // Folder enumeration is gated separately, on the runtime container, in getIndexItems(). if (! $container = $this->config('container')) { return true; } diff --git a/src/Fieldtypes/Structures.php b/src/Fieldtypes/Structures.php index c13b520ee1f..6b97dee1b18 100644 --- a/src/Fieldtypes/Structures.php +++ b/src/Fieldtypes/Structures.php @@ -3,7 +3,6 @@ namespace Statamic\Fieldtypes; use Statamic\CP\Column; -use Statamic\Exceptions\AuthorizationException; use Statamic\Facades\Structure; use Statamic\Facades\User; use Statamic\Structures\CollectionStructure; @@ -16,8 +15,20 @@ class Structures extends Relationship protected function authorizeItemData($id): bool { - return User::current()->can('configure collections') - || User::current()->can('configure navs'); + return $this->authorizeStructure(Structure::find($id)); + } + + private function authorizeStructure($structure): bool + { + if (! $structure) { + return false; + } + + if ($structure instanceof CollectionStructure) { + return User::current()->can('view', $structure->collection()); + } + + return User::current()->can('view', $structure); } protected function toItemArray($id) @@ -34,17 +45,14 @@ protected function toItemArray($id) public function getIndexItems($request) { - throw_unless( - User::current()->can('configure collections') || User::current()->can('configure navs'), - new AuthorizationException - ); - - return Structure::all()->map(function ($structure) { - return [ - 'id' => $this->getStructureId($structure), - 'title' => $structure->title(), - ]; - })->values(); + return Structure::all() + ->filter(fn ($structure) => $this->authorizeStructure($structure)) + ->map(function ($structure) { + return [ + 'id' => $this->getStructureId($structure), + 'title' => $structure->title(), + ]; + })->values(); } public function augmentValue($value) diff --git a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php index fa26d181270..ab4173aa30d 100644 --- a/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php +++ b/tests/Feature/Fieldtypes/RelationshipFieldtypeTest.php @@ -5,6 +5,7 @@ use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\Entry; +use Statamic\Facades\Nav; use Statamic\Facades\Role; use Statamic\Facades\Taxonomy; use Statamic\Facades\Term; @@ -372,6 +373,149 @@ public function a_super_admin_can_list_policy_less_types() $this->assertContains('editor', collect($response->json('data'))->pluck('id')->all()); } + + #[Test] + public function it_scopes_structure_listing_to_viewable_navs_and_collections() + { + Nav::make('main')->title('Main')->save(); + Nav::make('secret')->title('Secret')->save(); + Collection::make('pages')->title('Pages')->structureContents(['root' => true])->save(); + Collection::make('hidden')->title('Hidden')->structureContents(['root' => true])->save(); + + $this->setTestRoles(['test' => ['access cp', 'view main nav', 'view pages entries']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'structures'])); + + $response = $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk(); + + $ids = collect($response->json('data'))->pluck('id')->all(); + + $this->assertContains('main', $ids); + $this->assertContains('collection::pages', $ids); + $this->assertNotContains('secret', $ids); + $this->assertNotContains('collection::hidden', $ids); + } + + #[Test] + public function it_does_not_leak_structures_across_types() + { + Nav::make('main')->title('Main')->save(); + Collection::make('pages')->title('Pages')->structureContents(['root' => true])->save(); + + // A user with only a nav permission must not see (or resolve) collection-backed structures. + $this->setTestRoles(['test' => ['access cp', 'view main nav']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'structures'])); + + $listing = $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk(); + + $ids = collect($listing->json('data'))->pluck('id')->all(); + $this->assertContains('main', $ids); + $this->assertNotContains('collection::pages', $ids); + + $byId = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['main', 'collection::pages'], + ]) + ->assertOk(); + + $data = collect($byId->json('data'))->keyBy('id'); + $this->assertArrayNotHasKey('invalid', $data['main']); + $this->assertTrue($data['collection::pages']['invalid']); + } + + #[Test] + public function it_returns_a_placeholder_for_an_unviewable_structure_by_id() + { + Nav::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'structures'])); + + $response = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['secret'], + ]) + ->assertOk(); + + $this->assertTrue($response->json('data.0.invalid')); + $this->assertEquals('secret', $response->json('data.0.title')); + } + + #[Test] + public function the_structure_by_id_placeholder_does_not_reveal_whether_a_structure_exists() + { + Nav::make('secret')->title('Secret')->save(); + + $this->setTestRoles(['test' => ['access cp']]); + $user = User::make()->assignRole('test')->save(); + + $config = base64_encode(json_encode(['type' => 'structures'])); + + $response = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['secret', 'does-not-exist'], + ]) + ->assertOk(); + + $data = collect($response->json('data'))->keyBy('id'); + + $this->assertEquals( + ['id' => 'secret', 'title' => 'secret', 'invalid' => true], + $data['secret'] + ); + $this->assertEquals( + ['id' => 'does-not-exist', 'title' => 'does-not-exist', 'invalid' => true], + $data['does-not-exist'] + ); + } + + #[Test] + public function a_super_admin_sees_all_structures() + { + Nav::make('main')->title('Main')->save(); + Collection::make('pages')->title('Pages')->structureContents(['root' => true])->save(); + + $config = base64_encode(json_encode(['type' => 'structures'])); + $user = User::make()->makeSuper()->save(); + + $listing = $this + ->actingAs($user) + ->getJson("/cp/fieldtypes/relationship?config={$config}") + ->assertOk(); + + $ids = collect($listing->json('data'))->pluck('id')->all(); + $this->assertContains('main', $ids); + $this->assertContains('collection::pages', $ids); + + $byId = $this + ->actingAs($user) + ->postJson('/cp/fieldtypes/relationship/data', [ + 'config' => $config, + 'selections' => ['main', 'collection::pages'], + ]) + ->assertOk(); + + $data = collect($byId->json('data'))->keyBy('id'); + $this->assertArrayNotHasKey('invalid', $data['main']); + $this->assertArrayNotHasKey('invalid', $data['collection::pages']); + } } class StartsWithC extends Scope From b0dc5e78a480503ab8ec32901b861a0bd713dfe8 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Fri, 22 May 2026 11:41:47 -0400 Subject: [PATCH 03/10] [5.x] Soften unavailable relationship and asset item presentation Unviewable or missing relationship and asset selections now render as a muted, non-error placeholder with a tooltip covering both the deleted and no-permission cases, instead of the red broken state. Also fixes a crash where an unauthorized asset placeholder hit a TypeError in the asset field. Frontend only; the uniform backend placeholder is unchanged so no existence oracle is introduced. Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/css/components/assets.css | 4 ++++ resources/css/components/items.css | 2 +- resources/js/components/fieldtypes/assets/Asset.js | 9 ++++++++- resources/js/components/fieldtypes/assets/AssetRow.vue | 9 ++++++--- resources/js/components/fieldtypes/assets/AssetTile.vue | 5 +++-- resources/js/components/inputs/relationship/Item.vue | 3 ++- 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/resources/css/components/assets.css b/resources/css/components/assets.css index 458de9a9bfb..ac7a68f7c98 100644 --- a/resources/css/components/assets.css +++ b/resources/css/components/assets.css @@ -142,6 +142,10 @@ box-shadow: 0 0 0 1px theme('colors.blue.DEFAULT'); } +.asset-tile.is-invalid { + @apply bg-gray-100 dark:bg-dark-650 border-gray-300 dark:border-dark-200 text-gray-600 dark:text-gray-400 cursor-default; +} + .asset-thumbnail { @apply border border-white dark:border-dark-950; } diff --git a/resources/css/components/items.css b/resources/css/components/items.css index d5edcf0fb1e..82c6dcc7ad8 100644 --- a/resources/css/components/items.css +++ b/resources/css/components/items.css @@ -18,7 +18,7 @@ &.invalid { .item-inner { - @apply border-red-300 dark:border-dark-red bg-red-100 dark:bg-red-400 text-red-500 dark:text-red-950; + @apply border-gray-300 dark:border-dark-200 bg-gray-100 dark:bg-dark-650 text-gray-600 dark:text-gray-400; } } diff --git a/resources/js/components/fieldtypes/assets/Asset.js b/resources/js/components/fieldtypes/assets/Asset.js index ab72d882c95..05543f0dd90 100644 --- a/resources/js/components/fieldtypes/assets/Asset.js +++ b/resources/js/components/fieldtypes/assets/Asset.js @@ -53,11 +53,17 @@ export default { }, label() { - return this.asset.basename; + return this.asset.invalid ? this.asset.id : this.asset.basename; }, needsAlt() { + if (this.asset.invalid) return false; + return (this.asset.isImage || this.asset.isSvg) && !this.asset.values.alt; + }, + + invalidLabel() { + return __('This item is unavailable. It may have been deleted, or you may not have permission to view it.'); } }, @@ -66,6 +72,7 @@ export default { edit() { if (this.readOnly) return; + if (this.asset.invalid) return; this.editing = true; }, diff --git a/resources/js/components/fieldtypes/assets/AssetRow.vue b/resources/js/components/fieldtypes/assets/AssetRow.vue index 664a1593b2e..7d60eab8ba8 100644 --- a/resources/js/components/fieldtypes/assets/AssetRow.vue +++ b/resources/js/components/fieldtypes/assets/AssetRow.vue @@ -1,5 +1,5 @@