#5002: the orphan recovery goes both ways
#4806 gave a site admin one write on a help desk group nobody is left to run, and #4926 made the Person field of that form a people search. Neither closed the other half: `storeAgent()` was gated on requireManagerAccessOrOrphanRecovery(), `destroyAgent()` on plain requireManagerAccess(). So the rescue could appoint and never un-appoint — the row it had just written sat in the rescuer's own Agents list marked "Not yours to manage", with no Remove control and a 403 behind the DELETE. A `manager` press was worse: the write ends the orphan state, so the group left `orphanedGroupIds` and the admin could not remove the row, appoint anybody else, or appoint themselves. The only person who could undo it was the person wrongly appointed — #4806's frozen group, reached through #4806's cure. destroyAgent() now asks requireRemoveAccess(): a manager of the group, or a site admin while the group is STILL orphaned (symmetric with the write and strictly narrower — on such a group they could appoint themselves and then remove anything anyway), or a site admin undoing a row their own rescue wrote. That last arm needs provenance, hence `recovered_by`: "the group would be orphaned without this row" cannot tell a mistaken appointment from the sole working manager of a healthy group, and admitting the second would let any admin strip a manager and seize the desk (#4710). The stamp is cleared the moment a manager of the group writes the row themselves. `removableAgentIds` is that gate in prop form, so the Remove button and the DELETE cannot disagree, and the confirmation says when a removal hands the group back to having nobody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WHO WROTE THIS ROW AS A RESCUE — [central-ticketing] #5002.
|
||||||
|
*
|
||||||
|
* #4806 let a site admin appoint somebody to a help desk group that has no
|
||||||
|
* manager left who could still act. That write is the only way back into such
|
||||||
|
* a group, and it ENDS the condition that allowed it: appoint a `manager` and
|
||||||
|
* the group has a manager again, so the recovery arm shuts behind the press.
|
||||||
|
* With `destroyAgent()` gated on plain manager rights, a rescuer who named the
|
||||||
|
* wrong person could not take the row back — the only person who could was the
|
||||||
|
* person wrongly appointed.
|
||||||
|
*
|
||||||
|
* `orphanedGroupIds()` cannot tell that case from a healthy group with one
|
||||||
|
* working manager: both are "orphaned if you disregard this row", and allowing
|
||||||
|
* the second would let any site admin strip a manager from a group that is
|
||||||
|
* none of their business (#4710). So the row has to say where it came from.
|
||||||
|
*
|
||||||
|
* Nullable, and null on every row written the ordinary way: this is not
|
||||||
|
* "created_by", it is "this row exists because <user> rescued the group, and
|
||||||
|
* it is theirs to undo". No foreign key — `user_id` on this table has none
|
||||||
|
* either, and a deleted rescuer should leave the row alone, not take it.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('recovered_by')->nullable()->after('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('ticketing_agent_access', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('recovered_by');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -79,11 +79,15 @@ class TicketingSettingsController extends Controller
|
|||||||
* manager rights over every group. Seeing a group you do not manage is the
|
* manager rights over every group. Seeing a group you do not manage is the
|
||||||
* fix; acting on it is a separate decision for a human to make.
|
* fix; acting on it is a separate decision for a human to make.
|
||||||
*
|
*
|
||||||
* ONE write does not come through here: `storeAgent()` uses
|
* TWO writes do not come through here, and they are the same write in
|
||||||
* requireManagerAccessOrOrphanRecovery() instead, because a group with no
|
* both directions. `storeAgent()` uses
|
||||||
* manager left has nobody this gate could ever admit, and the group and
|
* requireManagerAccessOrOrphanRecovery(), because a group with no manager
|
||||||
* its tickets are frozen for good (#4806). That is the whole exception —
|
* left has nobody this gate could ever admit, and the group and its
|
||||||
* every other write on somebody else's group still ends up here.
|
* tickets are frozen for good (#4806); `destroyAgent()` uses
|
||||||
|
* requireRemoveAccess(), so that recovery is not one-way (#5002) — an
|
||||||
|
* appoint that could never be un-appointed froze the group again through
|
||||||
|
* its own cure. Every other write on somebody else's group still ends up
|
||||||
|
* here.
|
||||||
*/
|
*/
|
||||||
private function requireManagerAccess(int $groupId): void
|
private function requireManagerAccess(int $groupId): void
|
||||||
{
|
{
|
||||||
@@ -259,6 +263,27 @@ class TicketingSettingsController extends Controller
|
|||||||
$agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null);
|
$agents->each(fn($a) => $a->user = $agentUsers[$a->user_id] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #4806 — the groups with no manager left who could still use this
|
||||||
|
// page. Computed once: the page names them in the add form, and #5002
|
||||||
|
// asks the same question again for every Remove button below.
|
||||||
|
$orphanedGroupIds = $isSiteAdmin && !$isBootstrap ? $this->orphanedGroupIds() : [];
|
||||||
|
|
||||||
|
// #5002 — requireRemoveAccess() in prop form, the way myManagedGroupIds
|
||||||
|
// is requireManagerAccess()'s. It cannot be `canManageGroupId(row.group)`
|
||||||
|
// any more: a rescuer may also remove a row on a group that is still
|
||||||
|
// orphaned, and the row their own rescue wrote even after that rescue
|
||||||
|
// un-orphaned the group. A row an administrator can create has to be a
|
||||||
|
// row they can see the Remove on — the list said "Not yours to manage"
|
||||||
|
// over a row the reader had written seconds earlier.
|
||||||
|
$orphanedSet = array_map('intval', $orphanedGroupIds);
|
||||||
|
$removableAgentIds = $agents
|
||||||
|
->filter(fn ($a) => $myManagedGroupIds->contains($a->group_id)
|
||||||
|
|| ($isSiteAdmin && (in_array((int) $a->group_id, $orphanedSet, true)
|
||||||
|
|| ($a->recovered_by !== null && (int) $a->recovered_by === (int) $userId))))
|
||||||
|
->pluck('id')
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
$priorities = $isBootstrap
|
$priorities = $isBootstrap
|
||||||
? collect()
|
? collect()
|
||||||
: PriorityLevel::orderBy('sort_order')->get();
|
: PriorityLevel::orderBy('sort_order')->get();
|
||||||
@@ -291,7 +316,10 @@ class TicketingSettingsController extends Controller
|
|||||||
// so the Agents tab can offer the recovery instead of printing
|
// so the Agents tab can offer the recovery instead of printing
|
||||||
// "ask a manager of the group" about a group that has none.
|
// "ask a manager of the group" about a group that has none.
|
||||||
// Empty for a non-admin: the recovery is not theirs to make.
|
// Empty for a non-admin: the recovery is not theirs to make.
|
||||||
'orphanedGroupIds' => $isSiteAdmin && !$isBootstrap ? $this->orphanedGroupIds() : [],
|
'orphanedGroupIds' => $orphanedGroupIds,
|
||||||
|
// #5002 — the access rows this user may actually remove. Superset
|
||||||
|
// of "rows on a group I manage": see requireRemoveAccess().
|
||||||
|
'removableAgentIds' => $removableAgentIds,
|
||||||
'isBootstrap' => $isBootstrap,
|
'isBootstrap' => $isBootstrap,
|
||||||
'isSiteAdmin' => $isSiteAdmin,
|
'isSiteAdmin' => $isSiteAdmin,
|
||||||
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
|
'autoCloseDays' => (int) Setting::get('ticketing.auto_close_days', 0),
|
||||||
@@ -552,7 +580,20 @@ class TicketingSettingsController extends Controller
|
|||||||
|
|
||||||
TicketingAgentAccess::updateOrCreate(
|
TicketingAgentAccess::updateOrCreate(
|
||||||
['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']],
|
['user_id' => $validated['user_id'], 'group_id' => $validated['group_id']],
|
||||||
['role' => $validated['role']]
|
[
|
||||||
|
'role' => $validated['role'],
|
||||||
|
// WHO RESCUED THIS GROUP — #5002. A recovery write is the one
|
||||||
|
// write here that can lock its own author out: appoint a
|
||||||
|
// `manager` and the group is no longer orphaned, so the arm
|
||||||
|
// that allowed the press is shut by the press. Stamping the
|
||||||
|
// row is what lets destroyAgent() hand it back.
|
||||||
|
//
|
||||||
|
// Set to null on an ordinary manager's write, including a
|
||||||
|
// manager editing a row an earlier rescue wrote: once the
|
||||||
|
// group has somebody of its own touching the row, it is
|
||||||
|
// theirs and the rescuer has no further claim on it.
|
||||||
|
'recovered_by' => $isRecovery ? Auth::id() : null,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -597,15 +638,77 @@ class TicketingSettingsController extends Controller
|
|||||||
: "{$who} added to “{$name}” as {$asWhat}.");
|
: "{$who} added to “{$name}” as {$asWhat}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REMOVE IS THE SAME DOOR AS ADD — [central-ticketing] #5002.
|
||||||
|
*
|
||||||
|
* This was `requireManagerAccess()` while `storeAgent()` was
|
||||||
|
* requireManagerAccessOrOrphanRecovery(), so the recovery was ONE-WAY: the
|
||||||
|
* site admin who rescued a group could appoint and never un-appoint. The
|
||||||
|
* row they had just written appeared in their own Agents list marked "Not
|
||||||
|
* yours to manage", with no Remove control, and the DELETE answered 403 —
|
||||||
|
* the same person, the same second, the same row.
|
||||||
|
*
|
||||||
|
* Worse for a `manager` press, which is the one the card about the people
|
||||||
|
* search (#4926) called out as the expensive mistake: the write ends the
|
||||||
|
* orphan state, so the admin could not remove the row, could not appoint
|
||||||
|
* anyone else, and could not appoint themselves. The only person left who
|
||||||
|
* could undo it was the person wrongly appointed. That is #4806's frozen
|
||||||
|
* group again, reached through #4806's own cure.
|
||||||
|
*
|
||||||
|
* So two arms beyond an ordinary manager, both narrower than the write
|
||||||
|
* they undo:
|
||||||
|
*/
|
||||||
public function destroyAgent(TicketingAgentAccess $access)
|
public function destroyAgent(TicketingAgentAccess $access)
|
||||||
{
|
{
|
||||||
$this->requireManagerAccess($access->group_id);
|
$this->requireRemoveAccess($access);
|
||||||
|
|
||||||
$access->delete();
|
$access->delete();
|
||||||
|
|
||||||
return back()->with('success', 'Agent removed.');
|
return back()->with('success', 'Agent removed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* May the signed-in user take this agent access row away?
|
||||||
|
*
|
||||||
|
* 1. A manager of the group — the ordinary rule, unchanged.
|
||||||
|
* 2. A site admin, while the group is STILL orphaned. Exactly symmetric
|
||||||
|
* with storeAgent()'s arm and strictly narrower than it: on a group
|
||||||
|
* with no manager left, an admin can already appoint themselves
|
||||||
|
* manager and then remove anything, so this grants nothing new — it
|
||||||
|
* just does not make them take the group over first to clear a
|
||||||
|
* dangling row.
|
||||||
|
* 3. A site admin undoing a row THEY wrote as a rescue (`recovered_by`).
|
||||||
|
* This is the one that reopens the door a mistaken `manager` press
|
||||||
|
* closed, and it is deliberately not "the group would be orphaned
|
||||||
|
* without this row": that test cannot tell a mistaken appointment
|
||||||
|
* from the sole working manager of a healthy group, and admitting the
|
||||||
|
* second would let any site admin strip a manager from a group that
|
||||||
|
* is none of their business, then appoint themselves (#4710). A
|
||||||
|
* rescuer taking back their own appointment claims nothing they did
|
||||||
|
* not already hold at the moment they made it.
|
||||||
|
*
|
||||||
|
* Removing the last manager re-orphans the group, which is the correct
|
||||||
|
* end state: it is recoverable again, by whoever gets there next.
|
||||||
|
*/
|
||||||
|
private function requireRemoveAccess(TicketingAgentAccess $access): void
|
||||||
|
{
|
||||||
|
if ($this->isManagerOf($access->group_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isSiteAdmin()) {
|
||||||
|
if ($this->orphanedGroupIds([$access->group_id]) !== []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($access->recovered_by !== null && (int) $access->recovered_by === (int) Auth::id()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
public function storePriority(Request $request)
|
public function storePriority(Request $request)
|
||||||
{
|
{
|
||||||
if (!$this->isSiteAdmin()) {
|
if (!$this->isSiteAdmin()) {
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ class TicketingAgentAccess extends Model
|
|||||||
{
|
{
|
||||||
protected $table = 'ticketing_agent_access';
|
protected $table = 'ticketing_agent_access';
|
||||||
|
|
||||||
protected $fillable = ['user_id', 'group_id', 'role'];
|
/**
|
||||||
|
* `recovered_by` is #5002: the site admin who wrote this row as an orphan
|
||||||
|
* recovery (#4806), and may therefore take it back. Null on every row
|
||||||
|
* written by an ordinary manager — see the migration for why this is not
|
||||||
|
* a general-purpose `created_by`.
|
||||||
|
*/
|
||||||
|
protected $fillable = ['user_id', 'group_id', 'role', 'recovered_by'];
|
||||||
|
|
||||||
public function group(): BelongsTo
|
public function group(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user