#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');
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user