Summary
Implements Spec 067 “RBAC Troubleshooting & Tenant UI Bugfix Pack v1” for the tenant admin plane (/admin) with strict RBAC UX semantics:
Non-member tenant scope ⇒ 404 (deny-as-not-found)
Member lacking capability ⇒ 403 server-side, while the UI stays visible-but-disabled with standardized tooltips
What changed
Tenant view header actions now use centralized UI enforcement (no “normal click → error page” for readonly members).
Archived tenants remain resolvable in tenant-scoped routes for entitled members; an “Archived” banner is shown.
Adds tenant-scoped diagnostics page (/admin/t/{tenant}/diagnostics) with safe repair actions (confirmation + authorization + audit log).
Adds/updates targeted Pest tests to lock the 404 vs 403 semantics and action UX.
Implementation notes
Livewire v4.0+ compliance: Uses Filament v5 + Livewire v4 conventions; widget Blade views render a single root element.
Provider registration: Laravel 11+ providers stay in providers.php (no changes required).
Global search: No global search behavior/resources changed in this PR.
Destructive actions:
Tenant archive/restore/force delete and diagnostics repairs execute via ->action(...) and include ->requiresConfirmation().
Server-side authorization is enforced (non-members 404, insufficient capability 403).
Assets: No new assets. No change to php artisan filament:assets expectations.
Tests
Ran:
vendor/bin/sail bin pint --dirty
vendor/bin/sail artisan test --compact (focused files for Spec 067)
Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #84
115 lines
3.6 KiB
PHP
115 lines
3.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\Auth;
|
|
|
|
use App\Models\Tenant;
|
|
use App\Models\TenantMembership;
|
|
use App\Models\User;
|
|
use App\Services\Intune\AuditLogger;
|
|
use App\Support\Audit\AuditActionId;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class TenantDiagnosticsService
|
|
{
|
|
public function __construct(public AuditLogger $auditLogger) {}
|
|
|
|
public function tenantHasNoOwners(Tenant $tenant): bool
|
|
{
|
|
return ! TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('role', 'owner')
|
|
->exists();
|
|
}
|
|
|
|
public function userHasDuplicateMemberships(Tenant $tenant, User $user): bool
|
|
{
|
|
return TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('user_id', (int) $user->getKey())
|
|
->count() > 1;
|
|
}
|
|
|
|
public function mergeDuplicateMembershipsForUser(Tenant $tenant, User $actor, User $member): void
|
|
{
|
|
DB::transaction(function () use ($tenant, $actor, $member): void {
|
|
$memberships = TenantMembership::query()
|
|
->where('tenant_id', (int) $tenant->getKey())
|
|
->where('user_id', (int) $member->getKey())
|
|
->orderBy('created_at')
|
|
->get();
|
|
|
|
if ($memberships->count() <= 1) {
|
|
return;
|
|
}
|
|
|
|
$roles = $memberships->pluck('role')->all();
|
|
$roleToKeep = $this->highestRole($roles);
|
|
|
|
$membershipToKeep = $memberships->firstWhere('role', $roleToKeep) ?? $memberships->first();
|
|
if (! $membershipToKeep instanceof TenantMembership) {
|
|
return;
|
|
}
|
|
|
|
$idsToDelete = $memberships
|
|
->reject(fn (TenantMembership $m): bool => $m->getKey() === $membershipToKeep->getKey())
|
|
->pluck($membershipToKeep->getKeyName())
|
|
->all();
|
|
|
|
$membershipToKeep->forceFill([
|
|
'role' => $roleToKeep,
|
|
])->save();
|
|
|
|
TenantMembership::query()
|
|
->whereIn($membershipToKeep->getKeyName(), $idsToDelete)
|
|
->delete();
|
|
|
|
$this->auditLogger->log(
|
|
tenant: $tenant,
|
|
action: AuditActionId::TenantMembershipDuplicatesMerged->value,
|
|
context: [
|
|
'metadata' => [
|
|
'member_user_id' => (int) $member->getKey(),
|
|
'kept_membership_id' => (string) $membershipToKeep->getKey(),
|
|
'deleted_membership_ids' => array_values(array_map('strval', $idsToDelete)),
|
|
'result_role' => $roleToKeep,
|
|
'source_roles' => $roles,
|
|
],
|
|
],
|
|
actorId: (int) $actor->getKey(),
|
|
actorEmail: $actor->email,
|
|
actorName: $actor->name,
|
|
status: 'success',
|
|
resourceType: 'tenant',
|
|
resourceId: (string) $tenant->getKey(),
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string|null> $roles
|
|
*/
|
|
private function highestRole(array $roles): string
|
|
{
|
|
$priority = [
|
|
'owner' => 3,
|
|
'manager' => 2,
|
|
'readonly' => 1,
|
|
];
|
|
|
|
$bestRole = 'readonly';
|
|
$bestScore = 0;
|
|
|
|
foreach ($roles as $role) {
|
|
$score = $priority[$role] ?? 0;
|
|
if ($score > $bestScore) {
|
|
$bestScore = $score;
|
|
$bestRole = (string) $role;
|
|
}
|
|
}
|
|
|
|
return $bestRole;
|
|
}
|
|
}
|