From e6dc271f27c868229b7607129dba58aa9c6f81ff Mon Sep 17 00:00:00 2001 From: denkfabrik-li <274324701+denkfabrik-li@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:45:37 +0200 Subject: [PATCH] Land a successful create where a create-only role can actually go Four create flows redirected to the new record's edit page on success, but store is gated by create_* while the edit page is gated by edit_*, and PermissionChecker has no create-implies-edit rule. A role holding create_* without edit_* would create the record -- write, activity log and notifications all run -- and then meet a 403 on the success redirect, with no way to tell the action worked and every reason to submit a duplicate. Categories is reachable with plain UI clicks, since the sidebar shows it from create_categories alone. Keep landing on the edit page for anyone who may edit, and divert only those who can't -- to the create form, which shares store's own gate and is therefore reachable by exactly whoever just created the record; the success toast shows there. The index would not do: Clients/Groups lists are gated by manage_*, which store itself does not require. Implying edit_* from create_* would not do either -- edit has no own/others split here, so it would silently hand a deliberately narrow create-only role edit (two-factor reset included) on every existing record. --- .../Http/Controllers/ClientsController.php | 12 +- .../Http/Controllers/CategoriesController.php | 9 +- .../Http/Controllers/GroupsController.php | 7 +- .../Http/Controllers/UsersController.php | 7 +- .../CreateWithoutEditRedirectTest.php | 152 ++++++++++++++++++ 5 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/Identity/CreateWithoutEditRedirectTest.php diff --git a/app/Modules/Clients/Http/Controllers/ClientsController.php b/app/Modules/Clients/Http/Controllers/ClientsController.php index b3d58555..67eb57be 100644 --- a/app/Modules/Clients/Http/Controllers/ClientsController.php +++ b/app/Modules/Clients/Http/Controllers/ClientsController.php @@ -130,7 +130,17 @@ class ClientsController extends Controller $client->notify(new ClientWelcomeNotification); } - return redirect()->route('clients.edit', $client)->with('success', __('Client created.')); + // A role can hold create_clients without edit_clients, and the edit + // page this used to land on unconditionally answers such a role + // with a 403 — after the client was created, logged and welcomed. + // Fall back to the create form: it shares this route's own gate, so + // it is reachable by exactly whoever just created the record, and + // the success toast shows there. + $target = $request->user()?->can('edit_clients') + ? redirect()->route('clients.edit', $client) + : redirect()->route('clients.create'); + + return $target->with('success', __('Client created.')); } public function edit(User $client): Response diff --git a/app/Modules/Files/Http/Controllers/CategoriesController.php b/app/Modules/Files/Http/Controllers/CategoriesController.php index bc5a9041..cd76f529 100644 --- a/app/Modules/Files/Http/Controllers/CategoriesController.php +++ b/app/Modules/Files/Http/Controllers/CategoriesController.php @@ -81,7 +81,14 @@ class CategoriesController extends Controller $this->activity->log(Action::CategoryCreated, subject: $category); - return redirect()->route('categories.edit', $category)->with('success', __('Category created.')); + // Same create-without-edit rule as ClientsController::store() — and + // the most reachable case of it: the sidebar shows Categories from + // create_categories alone, with no manage tier in between. + $target = $request->user()?->can('edit_categories') + ? redirect()->route('categories.edit', $category) + : redirect()->route('categories.create'); + + return $target->with('success', __('Category created.')); } public function edit(Category $category): Response diff --git a/app/Modules/Groups/Http/Controllers/GroupsController.php b/app/Modules/Groups/Http/Controllers/GroupsController.php index 282f93c2..c1a62d7e 100644 --- a/app/Modules/Groups/Http/Controllers/GroupsController.php +++ b/app/Modules/Groups/Http/Controllers/GroupsController.php @@ -92,7 +92,12 @@ class GroupsController extends Controller $this->activity->log(Action::GroupMadePublic, subject: $group, context: ['slug' => $group->slug]); } - return redirect()->route('groups.edit', $group)->with('success', __('Group created.')); + // Same create-without-edit rule as ClientsController::store(). + $target = $request->user()?->can('edit_groups') + ? redirect()->route('groups.edit', $group) + : redirect()->route('groups.create'); + + return $target->with('success', __('Group created.')); } public function edit(Group $group): Response diff --git a/app/Modules/Identity/Http/Controllers/UsersController.php b/app/Modules/Identity/Http/Controllers/UsersController.php index b09c438a..e513f6f9 100644 --- a/app/Modules/Identity/Http/Controllers/UsersController.php +++ b/app/Modules/Identity/Http/Controllers/UsersController.php @@ -126,7 +126,12 @@ class UsersController extends Controller 'password' => $validated['password'], ], $validated['assigned_clients'] ?? []); - return redirect()->route('users.edit', $user)->with('success', __('User created.')); + // Same create-without-edit rule as ClientsController::store(). + $target = $this->actor()->can('edit_users') + ? redirect()->route('users.edit', $user) + : redirect()->route('users.create'); + + return $target->with('success', __('User created.')); } public function edit(User $user): Response diff --git a/tests/Feature/Identity/CreateWithoutEditRedirectTest.php b/tests/Feature/Identity/CreateWithoutEditRedirectTest.php new file mode 100644 index 00000000..d3b778cd --- /dev/null +++ b/tests/Feature/Identity/CreateWithoutEditRedirectTest.php @@ -0,0 +1,152 @@ +create(['name' => 'Holder '.uniqid()]); + + foreach ($permissions as $permission) { + RolePermission::query()->create(['role_id' => $role->id, 'permission' => $permission]); + } + + return User::factory()->create(['role_id' => $role->id]); +} + +/** A role with no permissions at all — grantable by any staff actor. */ +function grantableEmptyRole(): Role +{ + return Role::query()->create(['name' => 'Empty '.uniqid()]); +} + +test('a create-only clients role lands back on the create form, success flashed', function () { + $creator = roleHolding(['create_clients']); + + $response = $this->actingAs($creator)->post('/clients', [ + 'name' => 'New Client', + 'email' => 'create-only-client@example.com', + 'password' => 'Sup3r-secret!22', + 'password_confirmation' => 'Sup3r-secret!22', + ]); + + expect(User::query()->where('email', 'create-only-client@example.com')->exists())->toBeTrue(); + + $response->assertRedirect(route('clients.create'))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('clients.create'))->assertOk(); +}); + +test('a clients role that may edit keeps landing on the edit page', function () { + $creator = roleHolding(['create_clients', 'edit_clients']); + + $response = $this->actingAs($creator)->post('/clients', [ + 'name' => 'Editable Client', + 'email' => 'editable-client@example.com', + 'password' => 'Sup3r-secret!22', + 'password_confirmation' => 'Sup3r-secret!22', + ]); + + $client = User::query()->where('email', 'editable-client@example.com')->firstOrFail(); + + $response->assertRedirect(route('clients.edit', $client))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('clients.edit', $client))->assertOk(); +}); + +test('a create-only users role lands back on the create form, success flashed', function () { + // manage_users guards the whole route group, store included, so a + // users creator always holds it alongside create_users. + $creator = roleHolding(['manage_users', 'create_users']); + + $response = $this->actingAs($creator)->post('/users', [ + 'name' => 'New Staffer', + 'email' => 'create-only-staffer@example.com', + 'role_id' => grantableEmptyRole()->id, + 'password' => 'Sup3r-secret!22', + 'password_confirmation' => 'Sup3r-secret!22', + ]); + + expect(User::query()->where('email', 'create-only-staffer@example.com')->exists())->toBeTrue(); + + $response->assertRedirect(route('users.create'))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('users.create'))->assertOk(); +}); + +test('a users role that may edit keeps landing on the edit page', function () { + $creator = roleHolding(['manage_users', 'create_users', 'edit_users']); + + $response = $this->actingAs($creator)->post('/users', [ + 'name' => 'Editable Staffer', + 'email' => 'editable-staffer@example.com', + 'role_id' => grantableEmptyRole()->id, + 'password' => 'Sup3r-secret!22', + 'password_confirmation' => 'Sup3r-secret!22', + ]); + + $user = User::query()->where('email', 'editable-staffer@example.com')->firstOrFail(); + + $response->assertRedirect(route('users.edit', $user))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('users.edit', $user))->assertOk(); +}); + +test('a create-only groups role lands back on the create form, success flashed', function () { + $creator = roleHolding(['create_groups']); + + $response = $this->actingAs($creator)->post('/groups', ['name' => 'New Group', 'public' => false]); + + expect(Group::query()->where('name', 'New Group')->exists())->toBeTrue(); + + $response->assertRedirect(route('groups.create'))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('groups.create'))->assertOk(); +}); + +test('a groups role that may edit keeps landing on the edit page', function () { + $creator = roleHolding(['create_groups', 'edit_groups']); + + $response = $this->actingAs($creator)->post('/groups', ['name' => 'Editable Group', 'public' => false]); + + $group = Group::query()->where('name', 'Editable Group')->firstOrFail(); + + $response->assertRedirect(route('groups.edit', $group))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('groups.edit', $group))->assertOk(); +}); + +test('a create-only categories role lands back on the create form, success flashed', function () { + // The most reachable case: the sidebar shows Categories from + // create_categories alone, so this whole flow is plain UI clicks. + $creator = roleHolding(['create_categories']); + + $response = $this->actingAs($creator)->post('/categories', ['name' => 'New Category']); + + expect(Category::query()->where('name', 'New Category')->exists())->toBeTrue(); + + $response->assertRedirect(route('categories.create'))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('categories.create'))->assertOk(); +}); + +test('a categories role that may edit keeps landing on the edit page', function () { + $creator = roleHolding(['create_categories', 'edit_categories']); + + $response = $this->actingAs($creator)->post('/categories', ['name' => 'Editable Category']); + + $category = Category::query()->where('name', 'Editable Category')->firstOrFail(); + + $response->assertRedirect(route('categories.edit', $category))->assertSessionHas('success'); + $this->actingAs($creator)->get(route('categories.edit', $category))->assertOk(); +});