#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:
central-worker-4
2026-10-21 06:22:24 -07:00
parent 8b4f788e43
commit 51073fba1e
3 changed files with 161 additions and 9 deletions

View File

@@ -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');
});
}
};