feat: RBAC troubleshooting & tenant UI bugfix pack (spec 067)
This commit is contained in:
parent
d1a9989037
commit
51f0e61be3
4
.github/agents/copilot-instructions.md
vendored
4
.github/agents/copilot-instructions.md
vendored
@ -12,6 +12,8 @@ ## Active Technologies
|
||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||
- PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1 (058-tenant-ui-polish)
|
||||
- PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4 (067-rbac-troubleshooting)
|
||||
- PostgreSQL (via Laravel Sail) (067-rbac-troubleshooting)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -31,9 +33,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 067-rbac-troubleshooting: Added PHP 8.4 (per repo guidelines) + Laravel 12, Filament v5, Livewire v4
|
||||
- 058-tenant-ui-polish: Added PHP 8.4.15 (Laravel 12.47.0) + Filament v5.0.0, Livewire v4.0.1
|
||||
- 058-tenant-ui-polish: Added [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
|
||||
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||
|
||||
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
108
app/Filament/Pages/TenantDiagnostics.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
use App\Services\Auth\TenantMembershipManager;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Pages\Page;
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
|
||||
protected string $view = 'filament.pages.tenant-diagnostics';
|
||||
|
||||
public bool $missingOwner = false;
|
||||
|
||||
public bool $hasDuplicateMembershipsForCurrentUser = false;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('role', 'owner')
|
||||
->exists();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$this->hasDuplicateMembershipsForCurrentUser = app(TenantDiagnosticsService::class)
|
||||
->userHasDuplicateMemberships($tenant, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Action>
|
||||
*/
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
UiEnforcement::forAction(
|
||||
Action::make('bootstrapOwner')
|
||||
->label('Bootstrap owner')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->bootstrapOwner()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->missingOwner),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
Action::make('mergeDuplicateMemberships')
|
||||
->label('Merge duplicate memberships')
|
||||
->requiresConfirmation()
|
||||
->action(fn () => $this->mergeDuplicateMemberships()),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->destructive()
|
||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
||||
->apply()
|
||||
->visible(fn (): bool => $this->hasDuplicateMembershipsForCurrentUser),
|
||||
];
|
||||
}
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantMembershipManager::class)->bootstrapRecover($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
app(TenantDiagnosticsService::class)->mergeDuplicateMembershipsForUser($tenant, $user, $user);
|
||||
|
||||
$this->mount();
|
||||
}
|
||||
}
|
||||
@ -163,7 +163,7 @@ protected function getHeaderActions(): array
|
||||
$user = auth()->user();
|
||||
|
||||
return $tenant instanceof Tenant
|
||||
&& $user instanceof User
|
||||
&& $user instanceof User
|
||||
&& $user->canAccessTenant($tenant)
|
||||
&& $record->status !== 'disabled';
|
||||
})
|
||||
@ -242,9 +242,8 @@ protected function getHeaderActions(): array
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->tooltip('You do not have permission to run provider operations.')
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::PROVIDER_RUN)
|
||||
->apply(),
|
||||
|
||||
UiEnforcement::forAction(
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
@ -28,6 +29,7 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -262,95 +264,57 @@ public static function table(Table $table): Table
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('syncTenant')
|
||||
->label('Sync')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (Tenant $record): bool {
|
||||
if (! $record->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
return $user->canAccessTenant($record);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_SYNC);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_SYNC)
|
||||
? null
|
||||
: 'You do not have permission to sync this tenant.';
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($record)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
$supportedTypes = config('tenantpilot.supported_policy_types', []);
|
||||
$typeNames = array_map(
|
||||
static fn (array $typeConfig): string => (string) $typeConfig['type'],
|
||||
$supportedTypes,
|
||||
);
|
||||
sort($typeNames);
|
||||
|
||||
$inputs = [
|
||||
'scope' => 'full',
|
||||
'types' => $typeNames,
|
||||
];
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
@ -358,295 +322,262 @@ public static function table(Table $table): Table
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
|
||||
$opService->failStaleQueuedRun(
|
||||
$opRun,
|
||||
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
|
||||
);
|
||||
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $record,
|
||||
type: 'policy.sync',
|
||||
inputs: $inputs,
|
||||
initiator: auth()->user()
|
||||
);
|
||||
}
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Policy sync already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
|
||||
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
|
||||
});
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
|
||||
);
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View Run')
|
||||
->url(OperationRunLinks::view($opRun, $record)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->apply(),
|
||||
Actions\Action::make('openTenant')
|
||||
->label('Open')
|
||||
->icon('heroicon-o-arrow-right')
|
||||
->color('primary')
|
||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||
->visible(fn (Tenant $record) => $record->isActive()),
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
||||
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('restore')
|
||||
->label('Restore')
|
||||
->color('success')
|
||||
->icon('heroicon-o-arrow-uturn-left')
|
||||
->successNotificationTitle('Tenant reactivated')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
$record->restore();
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->restore();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
}),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (Tenant $record): ?string {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $record, Capabilities::TENANT_MANAGE)
|
||||
? null
|
||||
: 'You do not have permission to manage tenant consent.';
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.restored',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('open_in_entra')
|
||||
->label('Open in Entra')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->url(fn (Tenant $record) => static::entraUrl($record))
|
||||
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
|
||||
->openUrlInNewTab(),
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('verify')
|
||||
->label('Verify configuration')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('primary')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->action(function (
|
||||
Tenant $record,
|
||||
TenantConfigService $configService,
|
||||
TenantPermissionService $permissionService,
|
||||
RbacHealthService $rbacHealthService,
|
||||
AuditLogger $auditLogger
|
||||
) {
|
||||
$user = auth()->user();
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
static::rbacAction(),
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->disabled(function (Tenant $record): bool {
|
||||
$user = auth()->user();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$user = auth()->user();
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
$record->delete();
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||
->disabled(function (?Tenant $record): bool {
|
||||
if (! $record instanceof Tenant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return ! $resolver->can($user, $record, Capabilities::TENANT_DELETE);
|
||||
})
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('forceDelete')
|
||||
->label('Force delete')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||
->action(function (?Tenant $record, AuditLogger $auditLogger): void {
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$user = auth()->user();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant->forceDelete();
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::withTrashed()->find($record->id);
|
||||
|
||||
if (! $tenant?->trashed()) {
|
||||
Notification::make()
|
||||
->title('Tenant must be archived first')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.force_deleted',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
|
||||
);
|
||||
|
||||
$tenant->forceDelete();
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant permanently deleted')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->apply(),
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
@ -655,27 +586,45 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->visible(function (): bool {
|
||||
->visible(fn (): bool => auth()->user() instanceof User)
|
||||
->authorize(fn (): bool => auth()->user() instanceof User)
|
||||
->disabled(function (Collection $records): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||
->exists();
|
||||
if ($records->isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
})
|
||||
->authorize(function (): bool {
|
||||
->tooltip(function (Collection $records): ?string {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
return UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
return $user->tenants()
|
||||
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||
->exists();
|
||||
if ($records->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
$isDenied = $records
|
||||
->filter(fn ($record) => $record instanceof Tenant)
|
||||
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
|
||||
|
||||
return $isDenied ? UiTooltips::insufficientPermission() : null;
|
||||
})
|
||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||
$user = auth()->user();
|
||||
@ -982,9 +931,7 @@ public static function rbacAction(): Actions\Action
|
||||
return;
|
||||
}
|
||||
|
||||
$actor = auth()->user();
|
||||
|
||||
$result = $service->run($record, $data, $actor, $token);
|
||||
$result = $service->run($record, $data, $user, $token);
|
||||
|
||||
Cache::forget($cacheKey);
|
||||
|
||||
|
||||
@ -3,11 +3,14 @@
|
||||
namespace App\Filament\Resources\TenantResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Filament\Widgets\Tenant\TenantArchivedBanner;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RbacHealthService;
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use Filament\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
@ -16,11 +19,25 @@ class ViewTenant extends ViewRecord
|
||||
{
|
||||
protected static string $resource = TenantResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
TenantArchivedBanner::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ActionGroup::make([
|
||||
Actions\EditAction::make(),
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('edit')
|
||||
->label('Edit')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->url(fn (Tenant $record): string => TenantResource::getUrl('edit', ['record' => $record]))
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->apply(),
|
||||
Actions\Action::make('admin_consent')
|
||||
->label('Admin consent')
|
||||
->icon('heroicon-o-clipboard-document')
|
||||
@ -48,30 +65,40 @@ protected function getHeaderActions(): array
|
||||
TenantResource::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||
}),
|
||||
TenantResource::rbacAction(),
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||
$record->delete();
|
||||
UiEnforcement::forAction(
|
||||
Actions\Action::make('archive')
|
||||
->label('Deactivate')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-archive-box-x-mark')
|
||||
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||
$record->delete();
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||
);
|
||||
$auditLogger->log(
|
||||
tenant: $record,
|
||||
action: 'tenant.archived',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $record->getKey(),
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'internal_tenant_id' => (int) $record->getKey(),
|
||||
'tenant_guid' => (string) $record->tenant_id,
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Notification::make()
|
||||
->title('Tenant deactivated')
|
||||
->body('The tenant has been archived and hidden from lists.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
)
|
||||
->preserveVisibility()
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->destructive()
|
||||
->apply(),
|
||||
])
|
||||
->label('Actions')
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
|
||||
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
28
app/Filament/Widgets/Tenant/TenantArchivedBanner.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets\Tenant;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\Widget;
|
||||
|
||||
class TenantArchivedBanner extends Widget
|
||||
{
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected string $view = 'filament.widgets.tenant.tenant-archived-banner';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
|
||||
return [
|
||||
'tenant' => $tenant instanceof Tenant ? $tenant : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -152,6 +152,19 @@ public static function current(): self
|
||||
return $tenant;
|
||||
}
|
||||
|
||||
public function resolveRouteBinding($value, $field = null): ?Model
|
||||
{
|
||||
$field ??= $this->getRouteKeyName();
|
||||
|
||||
$query = static::query();
|
||||
|
||||
if ($field === 'external_id') {
|
||||
$query = $query->withTrashed();
|
||||
}
|
||||
|
||||
return $query->where($field, $value)->first();
|
||||
}
|
||||
|
||||
public function memberships(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantMembership::class);
|
||||
|
||||
@ -130,8 +130,8 @@ public function canAccessTenant(Model $tenant): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->tenants()
|
||||
->whereKey($tenant->getKey())
|
||||
return $this->tenantMemberships()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->exists();
|
||||
}
|
||||
|
||||
|
||||
114
app/Services/Auth/TenantDiagnosticsService.php
Normal file
114
app/Services/Auth/TenantDiagnosticsService.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@ -13,4 +13,7 @@ enum AuditActionId: string
|
||||
|
||||
// Not part of the v1 contract, but used in codebase.
|
||||
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
|
||||
|
||||
// Diagnostics / repair actions.
|
||||
case TenantMembershipDuplicatesMerged = 'tenant_membership.duplicates_merged';
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Auth\UiTooltips as AuthUiTooltips;
|
||||
use Closure;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkAction;
|
||||
@ -282,7 +283,7 @@ private function applyDisabledState(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tooltip = $this->customTooltip ?? UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
$tooltip = $this->customTooltip ?? AuthUiTooltips::insufficientPermission();
|
||||
|
||||
$this->action->disabled(function (?Model $record = null) {
|
||||
$context = $this->resolveContextWithRecord($record);
|
||||
|
||||
@ -30,4 +30,14 @@ final class UiTooltips
|
||||
* Modal description for destructive action confirmation.
|
||||
*/
|
||||
public const DESTRUCTIVE_CONFIRM_DESCRIPTION = 'This action cannot be undone.';
|
||||
|
||||
/**
|
||||
* Tooltip for actions that are unavailable because the tenant is archived.
|
||||
*/
|
||||
public const TENANT_ARCHIVED = 'This tenant is archived.';
|
||||
|
||||
/**
|
||||
* Tooltip for actions that are unavailable because a tenant must always have an owner.
|
||||
*/
|
||||
public const TENANT_OWNER_REQUIRED = 'This tenant must have at least one Owner.';
|
||||
}
|
||||
|
||||
31
resources/views/filament/pages/tenant-diagnostics.blade.php
Normal file
31
resources/views/filament/pages/tenant-diagnostics.blade.php
Normal file
@ -0,0 +1,31 @@
|
||||
<x-filament-panels::page>
|
||||
<div class="space-y-6">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">Tenant diagnostics</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Identify common tenant configuration issues and apply safe repairs.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@if ($missingOwner)
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="font-semibold">Missing owner</div>
|
||||
<div class="mt-1 text-sm">This tenant currently has no Owner members.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hasDuplicateMembershipsForCurrentUser)
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/40 dark:text-amber-100">
|
||||
<div class="font-semibold">Duplicate memberships</div>
|
||||
<div class="mt-1 text-sm">This tenant has duplicate membership rows for your user.</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! $missingOwner && ! $hasDuplicateMembershipsForCurrentUser)
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 text-gray-700 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300">
|
||||
<div class="font-semibold text-gray-950 dark:text-white">All good</div>
|
||||
<div class="mt-1 text-sm">No known issues detected.</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament-panels::page>
|
||||
@ -0,0 +1,14 @@
|
||||
@php
|
||||
/** @var ?\App\Models\Tenant $tenant */
|
||||
@endphp
|
||||
|
||||
<div>
|
||||
@if ($tenant?->trashed())
|
||||
<div class="rounded-lg border border-amber-200 bg-amber-50 p-4 text-amber-900 dark:border-amber-800/50 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="text-sm font-semibold">Archived</div>
|
||||
<div class="text-sm">{{ \App\Support\Rbac\UiTooltips::TENANT_ARCHIVED }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
46
specs/067-rbac-troubleshooting/checklists/requirements.md
Normal file
46
specs/067-rbac-troubleshooting/checklists/requirements.md
Normal file
@ -0,0 +1,46 @@
|
||||
# Specification Quality Checklist: RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-01-31
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details beyond constitution-required constraints (RBAC semantics, canonical capability registry references)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders (with minimal, clearly-scoped technical constraints where required)
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification (outside the minimal, constitution-required constraints)
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`
|
||||
|
||||
- Open issues identified by consistency analysis:
|
||||
- Diagnostics + duplicates repair lacks explicit test coverage (spec FR-006).
|
||||
- Repair actions require explicit audit trail tasks/tests (spec FR-008).
|
||||
- SC-001 relies on an undefined “standard click paths” set.
|
||||
- Spec/plan include some implementation-detail language that should be reduced.
|
||||
|
||||
- Status:
|
||||
- Duplicate membership coverage is addressed by tasks T019 and T023.
|
||||
- Repair audit trail coverage is addressed by tasks T020 and T023.
|
||||
- SC-001 was reworded to remove the undefined 95% threshold.
|
||||
- Plan template placeholders were removed.
|
||||
@ -0,0 +1,68 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin — Tenant Diagnostics (Conceptual)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Conceptual HTTP contract for tenant-scoped diagnostics and repairs.
|
||||
|
||||
NOTE: These routes are implemented as Filament (Livewire) pages/actions.
|
||||
The exact Livewire request payload is not part of this contract; this captures
|
||||
the user-visible HTTP surfaces and the logical operations.
|
||||
servers:
|
||||
- url: /admin
|
||||
paths:
|
||||
/t/{tenant}/diagnostics:
|
||||
get:
|
||||
summary: View tenant diagnostics page
|
||||
description: |
|
||||
Tenant-scoped diagnostics view. Must be tenant-member-safe:
|
||||
- Non-member: 404
|
||||
- Member: 200 (even if tenant is archived)
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Filament tenancy slug (Tenant.external_id)
|
||||
responses:
|
||||
'200':
|
||||
description: Diagnostics page rendered
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'404':
|
||||
description: Not found (deny-as-not-found for non-members)
|
||||
'403':
|
||||
description: Forbidden (member without capability attempting an action)
|
||||
/t/{tenant}/diagnostics/repairs/bootstrap-owner:
|
||||
post:
|
||||
summary: Repair missing owner by promoting a member
|
||||
description: |
|
||||
Promotes the selected tenant member to owner.
|
||||
Must require confirmation in the UI and enforce authorization server-side.
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [user_id]
|
||||
properties:
|
||||
user_id:
|
||||
type: integer
|
||||
description: Internal users.id
|
||||
responses:
|
||||
'204':
|
||||
description: Repair succeeded
|
||||
'403':
|
||||
description: Forbidden (member lacks capability)
|
||||
'404':
|
||||
description: Not found (non-member)
|
||||
64
specs/067-rbac-troubleshooting/data-model.md
Normal file
64
specs/067-rbac-troubleshooting/data-model.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Data Model — RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
No new persistent tables are required for v1. Diagnostics findings are computed at runtime from existing tables.
|
||||
|
||||
## Entities
|
||||
|
||||
### Tenant (`tenants`)
|
||||
|
||||
**Purpose**: Tenant-plane scope boundary, lifecycle state, and tenant-scoped configuration root.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id` (bigint, PK)
|
||||
- `tenant_id` (string GUID, external Microsoft Entra tenant identifier)
|
||||
- `external_id` (string, used as Filament tenancy slug; often mirrors `tenant_id`)
|
||||
- `status` (string, e.g. `active`, `archived`)
|
||||
- `deleted_at` (nullable timestamp, soft delete)
|
||||
- `name`, `environment`, `is_current`, `metadata` (assorted)
|
||||
|
||||
**Lifecycle rules (existing)**:
|
||||
- Soft delete sets `status='archived'`.
|
||||
- Restore sets `status='active'`.
|
||||
|
||||
### TenantMembership (`tenant_memberships`)
|
||||
|
||||
**Purpose**: Tenant membership boundary + role assignment.
|
||||
|
||||
**Key fields (existing)**:
|
||||
- `id` (uuid, PK)
|
||||
- `tenant_id` (bigint, FK → `tenants.id`)
|
||||
- `user_id` (bigint, FK → `users.id`)
|
||||
- `role` (enum: `owner`, `manager`, `operator`, `readonly`)
|
||||
- `source` / `source_ref` (provenance)
|
||||
- `created_by_user_id` (nullable FK)
|
||||
|
||||
**Constraints (existing)**:
|
||||
- Unique `(tenant_id, user_id)`
|
||||
- Index `(tenant_id, role)`
|
||||
|
||||
### DiagnosticsFinding (computed, not persisted)
|
||||
|
||||
**Purpose**: Represent a detected integrity/operational issue for the current tenant.
|
||||
|
||||
**Proposed shape (runtime DTO / array)**:
|
||||
- `id` (string, stable key like `missing_owner`)
|
||||
- `severity` (string, e.g. `warning`/`critical`)
|
||||
- `title` (string)
|
||||
- `description` (string)
|
||||
- `repair_actions` (array of available actions given actor capabilities)
|
||||
|
||||
## Derived rules / invariants
|
||||
|
||||
- **Missing owner**: `tenant_memberships` has zero rows with `role='owner'` for the tenant.
|
||||
- **Duplicate membership**: more than one membership row exists for a given `(tenant_id, user_id)` (should be prevented by DB uniqueness; diagnostics treats this as “historical/edge-case”).
|
||||
- **Identifier misuse risk**: any query for membership/tenant scoping must use internal tenant key (`tenants.id`) and not the GUID.
|
||||
|
||||
## State transitions
|
||||
|
||||
- Tenant status:
|
||||
- `active` → `archived` on soft delete
|
||||
- `archived` → `active` on restore
|
||||
- Membership role transitions:
|
||||
- Allowed transitions are existing (`owner`, `manager`, `operator`, `readonly`) but last-owner demotion/removal is prohibited.
|
||||
158
specs/067-rbac-troubleshooting/plan.md
Normal file
158
specs/067-rbac-troubleshooting/plan.md
Normal file
@ -0,0 +1,158 @@
|
||||
# Implementation Plan: RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Branch**: `067-rbac-troubleshooting` | **Date**: 2026-01-31 | **Spec**: [spec.md](spec.md)
|
||||
**Input**: Feature specification from [specs/067-rbac-troubleshooting/spec.md](spec.md)
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Tighten tenant-plane (`/admin`) RBAC UX and eliminate sharp edges by:
|
||||
|
||||
- Applying the existing `UiEnforcement` pattern consistently on Tenant admin surfaces (especially Tenant view header actions).
|
||||
- Ensuring archived (soft-deleted) tenants can still resolve in tenant-scoped routes for entitled members (404 only for non-members).
|
||||
- Adding a tenant-scoped diagnostics surface to detect and repair membership invariants (missing owner) and prevent GUID-vs-bigint mistakes.
|
||||
- Adding targeted Pest tests to lock in 404 vs 403 semantics and action disable/tooltip UX, and re-running existing last-owner invariant tests as regression coverage.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (per repo guidelines)
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (via Laravel Sail)
|
||||
**Testing**: Pest v4 (PHPUnit v12 runner)
|
||||
**Target Platform**: Laravel Sail (Docker) on macOS/Linux
|
||||
**Project Type**: Web application (Laravel monolith)
|
||||
**Performance Goals**: N/A (admin UX + correctness)
|
||||
**Constraints**: Tenant-plane only (`/admin`); no `/system` expansion; no new outbound integration calls for diagnostics; must preserve RBAC-UX semantics (404 vs 403)
|
||||
**Scale/Scope**: Touches Tenant admin UI, tenancy binding for archived tenants, membership invariants + tests
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: clarify what is “last observed” vs snapshots/backups
|
||||
- Read/write separation: any writes require preview + confirmation + audit + tests
|
||||
- Graph contract path: Graph calls only via `GraphClientInterface` + `config/graph_contracts.php`
|
||||
- Deterministic capabilities: capability derivation is testable (snapshot/golden tests)
|
||||
- RBAC-UX: two planes (/admin vs /system) remain separated; cross-plane is 404; non-member tenant access is 404; member-but-missing-capability is 403; authorization checks use Gates/Policies + capability registries (no raw strings, no role-string checks)
|
||||
- RBAC-UX: destructive-like actions require `->requiresConfirmation()` and clear warning text
|
||||
- RBAC-UX: global search is tenant-scoped; non-members get no hints; inaccessible results are treated as not found (404 semantics)
|
||||
- Tenant isolation: all reads/writes tenant-scoped; cross-tenant views are explicit and access-checked
|
||||
- Run observability: long-running/remote/queued work creates/reuses `OperationRun`; start surfaces enqueue-only; Monitoring is DB-only; DB-only <2s actions may skip runs but security-relevant ones still audit-log; auth handshake exception OPS-EX-AUTH-001 allows synchronous outbound HTTP on `/auth/*` without `OperationRun`
|
||||
- Automation: queued/scheduled ops use locks + idempotency; handle 429/503 with backoff+jitter
|
||||
- Data minimization: Inventory stores metadata + whitelisted meta; logs contain no secrets/tokens
|
||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||
|
||||
**Gate evaluation**: PASS (no constitution violations intended).
|
||||
|
||||
- No new Microsoft Graph call paths are introduced.
|
||||
- Mutations involved (archive/restore, membership repairs) keep explicit confirmation and server-side enforcement.
|
||||
- Auditing: membership repairs use existing audit logger patterns; tenant lifecycle actions already log.
|
||||
|
||||
**Post-design re-check**: PASS (design artifacts: [research.md](research.md), [data-model.md](data-model.md), [contracts/](contracts/)).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Resources/
|
||||
│ ├── Pages/
|
||||
│ └── Widgets/
|
||||
├── Models/
|
||||
├── Policies/
|
||||
├── Services/
|
||||
└── Support/
|
||||
|
||||
database/
|
||||
├── migrations/
|
||||
└── factories/
|
||||
|
||||
resources/
|
||||
├── views/
|
||||
└── css/
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith. Changes will land in `app/Filament/**` (tenant UI), `app/Models/**` (tenant binding/invariants), `app/Services/**` (membership repairs), and `tests/**` (Pest feature tests).
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
N/A.
|
||||
|
||||
## Phase 0 — Research (repo-backed)
|
||||
|
||||
### Key findings (evidence)
|
||||
|
||||
- Tenancy config: [app/Providers/Filament/AdminPanelProvider.php](../../app/Providers/Filament/AdminPanelProvider.php#L32-L45) uses `->tenant(Tenant::class, slugAttribute: 'external_id')` and `->tenantRoutePrefix('t')`.
|
||||
- Tenant archiving is soft delete + status update: [app/Models/Tenant.php](../../app/Models/Tenant.php#L63-L92) sets `status='archived'` during soft delete and restores back to `active`.
|
||||
- Tenant view header actions currently bypass UI enforcement:
|
||||
- `EditAction::make()` is unconditional (can lead to 403-on-click UX): [app/Filament/Resources/TenantResource/Pages/ViewTenant.php](../../app/Filament/Resources/TenantResource/Pages/ViewTenant.php#L19-L24)
|
||||
- `Deactivate` currently executes without capability checks (security issue): [app/Filament/Resources/TenantResource/Pages/ViewTenant.php](../../app/Filament/Resources/TenantResource/Pages/ViewTenant.php#L51-L74)
|
||||
- Tenant list Restore action lacks icon: [app/Filament/Resources/TenantResource.php](../../app/Filament/Resources/TenantResource.php#L406-L454)
|
||||
- Membership invariant “last owner” is already enforced server-side: [app/Services/Auth/TenantMembershipManager.php](../../app/Services/Auth/TenantMembershipManager.php#L253-L287)
|
||||
- Membership uniqueness is already enforced at DB level: [database/migrations/2026_01_25_022729_create_tenant_memberships_table.php](../../database/migrations/2026_01_25_022729_create_tenant_memberships_table.php#L12-L26)
|
||||
|
||||
### Decisions
|
||||
|
||||
- Archived tenant 404s for members: implement tenant route binding that includes soft-deleted tenants, then rely on membership middleware to enforce deny-as-not-found for non-members.
|
||||
- Replace ad-hoc tooltips on tenant actions with centralized `UiTooltips` constants (add a tenant-archived tooltip string).
|
||||
- Standardize tenant page header actions using `UiEnforcement` so member-without-capability sees disabled actions with tooltip (no normal-click 403).
|
||||
|
||||
**Output**: [research.md](research.md)
|
||||
|
||||
## Phase 1 — Design (data model + contracts)
|
||||
|
||||
### Design outline
|
||||
|
||||
- Tenant status UX
|
||||
- Archived tenants remain viewable for entitled members.
|
||||
- Actions become status-aware (e.g., Deactivate disabled when already archived).
|
||||
- Diagnostics
|
||||
- Tenant-scoped, DB-only page that reports:
|
||||
- Missing owner (0 owners)
|
||||
- Identifier misuse risk (guardrails against using external GUID where an internal FK is expected)
|
||||
- Repair actions are capability-gated and audit-logged.
|
||||
|
||||
### Artifacts
|
||||
|
||||
- [data-model.md](data-model.md)
|
||||
- [contracts/](contracts/)
|
||||
- [quickstart.md](quickstart.md)
|
||||
|
||||
## Phase 2 — Planning (for tasks.md)
|
||||
|
||||
Dependency-ordered implementation outline (will be expanded in `tasks.md`):
|
||||
|
||||
1. Fix tenant view header actions (Edit/Deactivate) to use UI enforcement + tooltips.
|
||||
2. Add Restore icon consistency on tenant list row menu.
|
||||
3. Ensure archived tenant is resolvable in tenant-scoped routes for members (avoid false 404).
|
||||
4. Add tenant status banner on view page.
|
||||
5. Add diagnostics page + safe repair actions for “missing owner”.
|
||||
6. Add regression tests for:
|
||||
- Readonly UX (disabled + tooltip)
|
||||
- Readonly cannot deactivate
|
||||
- Archived tenant view access (member OK, non-member 404)
|
||||
- GUID vs bigint guard test
|
||||
7. Keep the no-ad-hoc authorization guard green.
|
||||
30
specs/067-rbac-troubleshooting/quickstart.md
Normal file
30
specs/067-rbac-troubleshooting/quickstart.md
Normal file
@ -0,0 +1,30 @@
|
||||
# Quickstart — RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Docker running
|
||||
- Laravel Sail dependencies installed
|
||||
|
||||
## Run locally
|
||||
|
||||
- Start containers: `vendor/bin/sail up -d`
|
||||
- Run targeted tests (after implementation):
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Rbac/`
|
||||
- (guard) `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php`
|
||||
- Format: `vendor/bin/sail bin pint --dirty`
|
||||
|
||||
## Manual smoke (after implementation)
|
||||
|
||||
1. Sign in to `/admin`.
|
||||
2. Pick a tenant scope via the tenant menu.
|
||||
3. As a read-only member:
|
||||
- Tenant view page shows Edit/Deactivate disabled with tooltip.
|
||||
- Attempting to force-execute mutations still fails server-side.
|
||||
4. Archive a tenant, then:
|
||||
- Member can still open tenant view and sees an Archived banner.
|
||||
- Non-member gets 404 for tenant scope URLs.
|
||||
|
||||
## Notes
|
||||
|
||||
- Filament v5 requires Livewire v4.0+ (this repo is already on Livewire v4).
|
||||
- Panel providers in Laravel 11+ are registered in `bootstrap/providers.php` (this repo follows that convention).
|
||||
119
specs/067-rbac-troubleshooting/research.md
Normal file
119
specs/067-rbac-troubleshooting/research.md
Normal file
@ -0,0 +1,119 @@
|
||||
# Research — RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Date**: 2026-01-31
|
||||
**Spec**: [spec.md](spec.md)
|
||||
|
||||
This document captures design decisions and supporting rationale. Where applicable, decisions are grounded in the current codebase and the TenantPilot Constitution.
|
||||
|
||||
## Decision 1 — Archived tenants must remain resolvable in tenant-scoped routes
|
||||
|
||||
**Decision**: Make the tenant binding used by Filament tenancy include archived (soft-deleted) tenants so entitled members can view `/admin/t/{tenant}/...` even when archived.
|
||||
|
||||
**Rationale**:
|
||||
- The current tenant lifecycle uses soft deletes + `status='archived'` on delete and restores back to `active`.
|
||||
- If tenancy resolution excludes trashed records, the app will 404 *before* membership middleware can apply “deny-as-not-found for non-members”, causing false 404s for legitimate members.
|
||||
- Constitution RBAC-UX requires:
|
||||
- non-members → 404
|
||||
- members but missing capability → 403
|
||||
- archived is a lifecycle state, not an isolation boundary.
|
||||
|
||||
**Evidence**:
|
||||
- Tenant tenancy config (slug is `external_id`, route prefix `t`): [Admin panel tenancy](../../app/Providers/Filament/AdminPanelProvider.php#L32-L45)
|
||||
- Archived behavior is implemented via soft deletes + status mutations: [Tenant booted hooks](../../app/Models/Tenant.php#L63-L92)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Stop using soft deletes for tenants and represent archived purely via a `status` field.
|
||||
- Rejected: larger behavioral change, wider surface area (queries, “activeQuery”, etc.), higher regression risk.
|
||||
- Keep current 404 and treat “archived” as inaccessible.
|
||||
- Rejected: contradicts spec FR-004 and the operational need to restore.
|
||||
|
||||
## Decision 2 — Standardize Tenant view header actions via `UiEnforcement`
|
||||
|
||||
**Decision**: Wrap Tenant view page header actions (Edit/Deactivate) in the existing UI enforcement helper so tenant members without the capability see disabled actions with tooltips, avoiding “normal click leads to 403 page”.
|
||||
|
||||
**Rationale**:
|
||||
- Constitution RBAC-UX-004 requires visible-but-disabled for members lacking capability.
|
||||
- Existing code already follows this pattern in multiple resources (e.g., tenant list actions, membership manager relation managers).
|
||||
- The current `ViewTenant` header actions include an unconditional edit action and an archive action that executes without capability checks.
|
||||
|
||||
**Evidence**:
|
||||
- Unconditional edit header action: [ViewTenant header action group](../../app/Filament/Resources/TenantResource/Pages/ViewTenant.php#L19-L24)
|
||||
- Deactivate executes without capability gating: [ViewTenant archive action](../../app/Filament/Resources/TenantResource/Pages/ViewTenant.php#L51-L74)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Hide actions entirely for users without capability.
|
||||
- Rejected by default: RBAC-UX prefers visible-but-disabled.
|
||||
- Leave the action visible and rely only on server-side 403.
|
||||
- Rejected: fails the UX requirement (normal click to 403).
|
||||
|
||||
## Decision 3 — Restore icon consistency is a UI-only patch
|
||||
|
||||
**Decision**: Add an icon to the tenant row `Restore` action.
|
||||
|
||||
**Rationale**:
|
||||
- Improves consistency and reduces “is this safe?” uncertainty.
|
||||
- No behavioral/authorization changes; minimal regression risk.
|
||||
|
||||
**Evidence**:
|
||||
- Restore action currently has no icon: [TenantResource restore action](../../app/Filament/Resources/TenantResource.php#L406-L454)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Leave it as-is.
|
||||
- Rejected: explicit FR-001.
|
||||
|
||||
## Decision 4 — Diagnostics is DB-only and uses existing audit logging patterns
|
||||
|
||||
**Decision**: Implement tenant-scoped diagnostics as DB-only rendering and DB-only repairs with explicit confirmation + server-side authorization, recording audit logs.
|
||||
|
||||
**Rationale**:
|
||||
- Constitution requires read/write separation and that monitoring/diagnostics pages do not trigger external calls during render.
|
||||
- Repairs are security-relevant mutations and must be audited.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Attach diagnostics to Monitoring/Operations.
|
||||
- Rejected: these repairs are DB-only and do not need `OperationRun` by default; keep scope small.
|
||||
|
||||
## Decision 5 — Membership invariants
|
||||
|
||||
**Decision**:
|
||||
- Continue using server-side guards preventing last-owner removal/demotion.
|
||||
- Add diagnostics checks for “missing owner” and provide a safe repair (promote chosen member to owner) gated by an existing management capability.
|
||||
|
||||
**Rationale**:
|
||||
- Last-owner guard exists and is correct; the missing-owner case needs a UI recovery path.
|
||||
|
||||
**Evidence**:
|
||||
- Last-owner guard behavior: [TenantMembershipManager last-owner guards](../../app/Services/Auth/TenantMembershipManager.php#L253-L287)
|
||||
|
||||
**Alternatives considered**:
|
||||
- DB constraint enforcing at least one owner.
|
||||
- Rejected: non-trivial, would complicate bulk edits and migrations; better handled at app layer with explicit repair tools.
|
||||
|
||||
## Decision 6 — Duplicate memberships
|
||||
|
||||
**Decision**: Keep the DB-level uniqueness constraint as the primary protection, but still implement diagnostics that can detect historical duplicates and merge them safely (no-op when none exist).
|
||||
|
||||
**Rationale**:
|
||||
- The uniqueness constraint exists today, but the product requirement includes a recovery/repair flow.
|
||||
|
||||
**Evidence**:
|
||||
- Unique constraint in migration: [tenant_memberships unique index](../../database/migrations/2026_01_25_022729_create_tenant_memberships_table.php#L12-L26)
|
||||
|
||||
**Alternatives considered**:
|
||||
- Remove diagnostics for duplicates since the DB constraint exists.
|
||||
- Rejected: conflicts with FR-006 and the “UI-only recovery” goal.
|
||||
|
||||
## Decision 7 — GUID vs bigint guardrails
|
||||
|
||||
**Decision**: Explicitly distinguish:
|
||||
- internal tenant primary key (`tenants.id`, bigint)
|
||||
- external tenant identifiers (`tenant_id` GUID / `external_id` string)
|
||||
|
||||
and ensure code paths that need an internal FK use `$tenant->getKey()` (cast to int), not `$tenant->tenant_id`.
|
||||
|
||||
**Rationale**:
|
||||
- Prevent PostgreSQL `invalid input syntax for type bigint` errors caused by passing the GUID into bigint `tenant_id` columns.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Rename columns.
|
||||
- Rejected (out of scope / migration-heavy).
|
||||
130
specs/067-rbac-troubleshooting/spec.md
Normal file
130
specs/067-rbac-troubleshooting/spec.md
Normal file
@ -0,0 +1,130 @@
|
||||
# Feature Specification: RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Feature Branch**: `067-rbac-troubleshooting`
|
||||
**Created**: 2026-01-31
|
||||
**Status**: Draft
|
||||
**Input**: RBAC troubleshooting + tenant admin UI bugfix pack focused on the tenant-plane admin experience (`/admin`).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Permission-safe tenant UI interactions (Priority: P1)
|
||||
|
||||
A tenant member with read-only permissions can browse tenant pages and menus without being sent to error pages for normal UI clicks.
|
||||
|
||||
**Why this priority**: Eliminates confusing UX and reduces support load; also prevents “permission leak” regressions in high-risk actions.
|
||||
|
||||
**Independent Test**: As a read-only member, verify restricted actions are visible-but-disabled with an explanatory tooltip and cannot be executed.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user is a member of a tenant but lacks tenant management capability, **When** they view tenant lists and pages, **Then** management actions (e.g. Edit, Deactivate) are disabled and show an explanatory tooltip.
|
||||
2. **Given** a user is a member but lacks tenant management capability, **When** they attempt to execute a blocked mutation (direct request / forced execution), **Then** the server denies it.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Archived tenant remains viewable for entitled members (Priority: P1)
|
||||
|
||||
A tenant member who is entitled to view a tenant can still access the tenant view even if the tenant is archived, and the UI clearly indicates status.
|
||||
|
||||
**Why this priority**: Prevents accidental “false 404” for legitimate members; enables safe lifecycle operations like restore.
|
||||
|
||||
**Independent Test**: Mark a tenant archived, ensure a viewing member can load it and sees an “Archived” banner; ensure non-members still receive 404.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant is archived and a user is a member with tenant view capability, **When** they navigate to the tenant view, **Then** the page loads and shows an “Archived” status banner.
|
||||
2. **Given** a tenant is archived and a user is not a member, **When** they navigate to the tenant scope URL, **Then** they receive a 404 (deny-as-not-found).
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Diagnose & repair tenant membership invariants (Priority: P2)
|
||||
|
||||
An authorized tenant owner/manager can identify and fix membership integrity issues (missing owner, duplicates) via a dedicated troubleshooting screen.
|
||||
|
||||
**Why this priority**: Provides a recovery path for real-world “sharp edges” without requiring direct database intervention.
|
||||
|
||||
**Independent Test**: Seed broken states (0 owners, duplicate memberships), confirm diagnostics flag them and repair actions resolve them.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant has no owners, **When** an authorized user opens diagnostics, **Then** the issue is surfaced and a repair action can promote a member to owner.
|
||||
2. **Given** a tenant has duplicate memberships for the same user, **When** an authorized user runs “merge duplicates”, **Then** duplicates are removed and exactly one membership remains.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A tenant member without management capability attempts to execute a blocked action via a forged request.
|
||||
- A non-member attempts to access a tenant-scoped page (must be deny-as-not-found).
|
||||
- Tenant is archived: view allowed for entitled members; lifecycle actions are status-appropriate.
|
||||
- Tenant has 0 owners; tenant has 1 owner and an action would demote/remove the last owner.
|
||||
- Membership duplicates exist and are resolved; ensure resulting owner count remains valid.
|
||||
- A tenant has an external GUID identifier; ensure no internal lookups treat that GUID as the internal tenant primary key.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any Microsoft Graph calls, any write/change behavior,
|
||||
or any long-running/queued/scheduled work, the spec MUST describe contract registry updates, safety gates
|
||||
(preview/confirmation/audit), tenant isolation, run observability (operation run record: type/identity/visibility), and tests.
|
||||
If security-relevant DB-only actions intentionally skip an operation run record, the spec MUST describe audit log entries.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** If this feature introduces or changes authorization behavior, the spec MUST:
|
||||
- state which authorization plane(s) are involved (tenant `/admin/t/{tenant}` vs platform `/system`),
|
||||
- ensure any cross-plane access is deny-as-not-found (404),
|
||||
- explicitly define 404 vs 403 semantics:
|
||||
- non-member / not entitled to tenant scope → 404 (deny-as-not-found)
|
||||
- member but missing capability → 403
|
||||
- describe how authorization is enforced server-side (Gates/Policies) for every mutation/operation-start/credential change,
|
||||
- reference the canonical capability registry (no raw capability strings; no role-string checks in feature code),
|
||||
- ensure global search is tenant-scoped and non-member-safe (no hints; inaccessible results treated as 404 semantics),
|
||||
- ensure destructive-like actions require an explicit confirmation step,
|
||||
- include at least one positive and one negative authorization test, and note any RBAC regression tests added/updated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** OIDC/SAML login handshakes may perform synchronous outbound HTTP (e.g., token exchange)
|
||||
on `/auth/*` endpoints without an operation run record. This MUST NOT be used for Monitoring/Operations pages.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature changes status-like badges (status/outcome/severity/risk/availability/boolean),
|
||||
the spec MUST describe how badge semantics stay centralized (no ad-hoc mappings) and which tests cover any new/changed values.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Restore action icon)**: The tenant list row menu MUST display a consistent icon for the Restore action.
|
||||
- **FR-002 (Readonly edit UX)**: For a tenant member lacking tenant management capability, the UI MUST present Edit as disabled with an explanatory tooltip and MUST avoid normal-click navigation leading to an error page.
|
||||
- **FR-003 (Readonly cannot deactivate)**: Deactivate MUST be treated as a protected mutation and MUST NOT be executable by a tenant member lacking tenant management capability.
|
||||
- **FR-004 (Archived tenant access)**: An entitled tenant member (tenant view capability) MUST be able to open archived tenants. A non-member MUST receive a 404 for tenant scope URLs.
|
||||
- **FR-005 (Owner invariant)**: The system MUST prevent removing/demoting the last remaining tenant owner and MUST surface “missing owner” situations in diagnostics.
|
||||
- **FR-006 (Membership integrity)**: The system MUST prevent duplicate memberships per (tenant, user). Diagnostics MUST identify duplicates and provide a safe repair flow for authorized users.
|
||||
- **FR-007 (GUID vs internal ID)**: The system MUST treat external tenant identifiers (GUIDs) as external-only identifiers and MUST NOT use them where an internal tenant primary key is required.
|
||||
- **FR-008 (Diagnostics access + safety)**: Diagnostics MUST not expose cross-tenant data to non-members. Repair actions MUST be permission-gated and MUST record an auditable trail.
|
||||
|
||||
#### Authorization & Error Semantics (explicit)
|
||||
|
||||
- Tenant-scope URLs: non-member / not entitled MUST be deny-as-not-found (404).
|
||||
- Member but missing capability: server-side MUST deny execution (403).
|
||||
- UI behavior: member-without-capability SHOULD see actions visible-but-disabled with tooltip to prevent normal-click 403 pages.
|
||||
|
||||
#### Out of Scope
|
||||
|
||||
- No new “Workspace” model in this spec.
|
||||
- No platform-plane (`/system`) expansion.
|
||||
- No outbound integration calls are introduced as part of diagnostics/repairs.
|
||||
|
||||
#### Assumptions
|
||||
|
||||
- “Archived” is treated as an archived/lifecycle state that remains viewable to entitled members.
|
||||
- Deactivate (archive) uses the existing tenant lifecycle capability `Capabilities::TENANT_DELETE`.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Tenant**: The internal customer/workspace entity users are members of; has an internal primary key and an external identifier.
|
||||
- **Tenant Membership**: Links a user to a tenant with one or more capability grants or derived entitlements.
|
||||
- **Capability / Entitlement**: A named permission used to determine visibility/execution of actions within a tenant scope.
|
||||
- **Diagnostics Finding**: A detected issue such as “missing owner”, “duplicate membership”, or “identifier misuse risk”.
|
||||
- **Repair Action**: A controlled, permission-gated remediation step that fixes a finding and records an audit entry.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: Read-only members can navigate all tenant UI flows covered by automated tests without encountering an error page during normal click paths.
|
||||
- **SC-002**: Protected tenant mutations (e.g., deactivate) have 0 successful executions by users lacking the required capability across automated tests.
|
||||
- **SC-003**: Archived tenant access works as defined: entitled members can view; non-members get 404, verified by automated tests.
|
||||
- **SC-004**: Membership integrity can be restored via UI-only repair actions for seeded broken states, verified by automated tests.
|
||||
156
specs/067-rbac-troubleshooting/tasks.md
Normal file
156
specs/067-rbac-troubleshooting/tasks.md
Normal file
@ -0,0 +1,156 @@
|
||||
---
|
||||
|
||||
description: "Task list for feature implementation"
|
||||
---
|
||||
|
||||
# Tasks: RBAC Troubleshooting & Tenant UI Bugfix Pack v1
|
||||
|
||||
**Input**: Design documents from `/specs/067-rbac-troubleshooting/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), checklists/requirements.md (required), research.md, data-model.md, contracts/, quickstart.md
|
||||
|
||||
**Tests**: Required (Pest) for all runtime behavior changes.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Ensure local environment is ready for implementation + testing.
|
||||
|
||||
- [x] T001 Start Sail services and ensure DB is ready using vendor/bin/sail (vendor/bin/sail)
|
||||
- [x] T002 [P] Run baseline tests to capture current behavior (tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives that user stories depend on.
|
||||
|
||||
- [x] T003 [P] Review and, if needed, extend standardized RBAC tooltip strings in app/Support/Rbac/UiTooltips.php (app/Support/Rbac/UiTooltips.php)
|
||||
- [x] T004 [P] Confirm the tenant lifecycle capability used for archive/restore is Capabilities::TENANT_DELETE (app/Support/Auth/Capabilities.php)
|
||||
|
||||
**Checkpoint**: Foundation ready — user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Permission-safe tenant UI interactions (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Readonly tenant members can browse tenant pages without normal-click navigation leading to error pages; protected actions are disabled with tooltips and blocked server-side.
|
||||
|
||||
**Independent Test**: As a `readonly` member, `ViewTenant` shows Edit/Deactivate as disabled+tooltip and Deactivate cannot be executed.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T005 [P] [US1] Add ViewTenant header action UX tests for readonly vs owner in tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php (tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php)
|
||||
- [x] T006 [P] [US1] Add regression test that readonly cannot execute the archive action (silently blocked by Filament; ensure no state change) in tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php (tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T007 [US1] Replace unconditional EditAction in ViewTenant header with a visible-but-disabled URL action + tooltip in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (app/Filament/Resources/TenantResource/Pages/ViewTenant.php)
|
||||
- [x] T008 [US1] Wrap Deactivate (archive) header action with UiEnforcement + Capabilities::TENANT_DELETE + destructive confirmation, and enforce server-side authorization (Gate/Policy) in the mutation handler, in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (app/Filament/Resources/TenantResource/Pages/ViewTenant.php)
|
||||
- [x] T009 [US1] Ensure no ad-hoc auth patterns were added to app/Filament/** and update allowlist only if unavoidable (tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Archived tenant remains viewable for entitled members (Priority: P1)
|
||||
|
||||
**Goal**: Archived (soft-deleted) tenants remain resolvable in tenant-scoped routes for entitled members; UI clearly indicates archived status; non-members still get 404.
|
||||
|
||||
**Independent Test**: Archive a tenant; member can access `/admin/t/{external_id}` and can mount `ViewTenant`; non-member receives 404.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T010 [P] [US2] Add archived-tenant access test for members vs non-members in tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php (tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php)
|
||||
- [x] T011 [P] [US2] Add UI assertion that archived tenants show an explicit archived indicator on ViewTenant in tests/Feature/Filament/ArchivedTenantViewTest.php (tests/Feature/Filament/ArchivedTenantViewTest.php)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T012 [US2] Update Tenant model route binding to resolve archived tenants by external_id using withTrashed() override in app/Models/Tenant.php (app/Models/Tenant.php)
|
||||
- [x] T013 [US2] Add an “Archived” indicator on the tenant view header (e.g., subheading or banner) in app/Filament/Resources/TenantResource/Pages/ViewTenant.php (app/Filament/Resources/TenantResource/Pages/ViewTenant.php)
|
||||
- [x] T014 [US2] Add a consistent icon for the Restore action in tenant list row menu in app/Filament/Resources/TenantResource.php (app/Filament/Resources/TenantResource.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Diagnose & repair tenant membership invariants (Priority: P2)
|
||||
|
||||
**Goal**: Authorized tenant members can see diagnostics for membership integrity (missing owner, duplicates) and run safe repair actions.
|
||||
|
||||
**Independent Test**: Seed a missing-owner scenario; diagnostics reports it; authorized user can repair by promoting a member to owner; unauthorized user is blocked.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T015 [P] [US3] Add tenant diagnostics access tests (member OK, non-member 404) in tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php (tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php)
|
||||
- [x] T016 [P] [US3] Add repair action test for missing owner (promote member to owner) in tests/Feature/Filament/TenantDiagnosticsRepairsTest.php (tests/Feature/Filament/TenantDiagnosticsRepairsTest.php)
|
||||
- [x] T017 [P] [US3] Add negative authorization test: member without required capability sees repair action disabled + tooltip (and cannot execute; silently blocked by Filament) in tests/Feature/Filament/TenantDiagnosticsRepairsTest.php (tests/Feature/Filament/TenantDiagnosticsRepairsTest.php)
|
||||
- [x] T018 [P] [US3] Add guardrail test that diagnostics/repairs never use external GUID where internal tenant PK is required (prevents bigint cast errors) in tests/Feature/TenantRBAC/TenantGuidVsBigintGuardTest.php (tests/Feature/TenantRBAC/TenantGuidVsBigintGuardTest.php)
|
||||
- [x] T019 [P] [US3] Add duplicate membership diagnostics + merge repair test (seed duplicates, assert finding, execute merge, assert exactly 1 membership remains) in tests/Feature/Filament/TenantDiagnosticsRepairsTest.php (tests/Feature/Filament/TenantDiagnosticsRepairsTest.php)
|
||||
- [x] T020 [P] [US3] Add audit trail test: each repair action writes an AuditLog entry with stable action id + tenant + actor in tests/Feature/Filament/TenantDiagnosticsRepairsTest.php (tests/Feature/Filament/TenantDiagnosticsRepairsTest.php)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T021 [US3] Implement tenant-scoped diagnostics Filament page route under `/admin/t/{tenant}/diagnostics` in app/Filament/Pages/TenantDiagnostics.php (app/Filament/Pages/TenantDiagnostics.php)
|
||||
- [x] T022 [US3] Implement a diagnostics service to compute findings (missing owner, duplicates) in app/Services/Auth/TenantDiagnosticsService.php (app/Services/Auth/TenantDiagnosticsService.php)
|
||||
- [x] T023 [US3] Implement diagnostics page actions with UiEnforcement + confirmation + server-side authorization enforcement + audit logging (bootstrap owner, merge duplicates) in app/Filament/Pages/TenantDiagnostics.php (app/Filament/Pages/TenantDiagnostics.php)
|
||||
- [x] T024 [US3] Ensure all tenant membership queries in new/changed code use the internal tenant key `(int) $tenant->getKey()` (never `$tenant->tenant_id`) and refactor any offenders found during implementation (app/Services/Auth/TenantDiagnosticsService.php)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T025 [P] Run formatter on touched files (dirty only) via vendor/bin/sail (vendor/bin/sail)
|
||||
- [x] T026 Run targeted test suite covering all new tests + auth guard (tests/Feature/Guards/NoAdHocFilamentAuthPatternsTest.php)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Phase 1 (Setup)** → blocks everything else
|
||||
- **Phase 2 (Foundational)** → blocks all user stories
|
||||
- **US1 / US2 / US3** can start after Phase 2, but US1 and US2 both touch `ViewTenant`, so coordinate to avoid merge conflicts
|
||||
- **Polish** after all desired stories are complete
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: independent after Phase 2
|
||||
- **US2 (P1)**: independent after Phase 2
|
||||
- **US3 (P2)**: independent after Phase 2 (reuses shared auth + tenant scoping)
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- In parallel:
|
||||
- T005 (tests) and T006 (tests)
|
||||
- Then:
|
||||
- T007 + T008 (implementation)
|
||||
|
||||
### US2
|
||||
|
||||
- In parallel:
|
||||
- T010 (route access tests) and T011 (UI indicator test)
|
||||
- Then:
|
||||
- T012 (route binding) and T013/T014 (UI fixes)
|
||||
|
||||
### US3
|
||||
|
||||
- In parallel:
|
||||
- T015–T020 (tests)
|
||||
- Then:
|
||||
- T021–T024 (implementation)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Suggested MVP Scope
|
||||
|
||||
- MVP = **User Story 1** only (T005–T009), because it fixes the most user-visible and security-adjacent UX issues and is easiest to validate independently.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. US1 → validate UX + server-side enforcement
|
||||
2. US2 → validate archived access + banner + restore icon
|
||||
3. US3 → add diagnostics + repairs with strict authorization
|
||||
25
tests/Feature/Filament/ArchivedTenantViewTest.php
Normal file
25
tests/Feature/Filament/ArchivedTenantViewTest.php
Normal file
@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('archived tenants show an explicit archived indicator on the tenant view page', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertSee('Archived')
|
||||
->assertSee(UiTooltips::TENANT_ARCHIVED);
|
||||
});
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
|
||||
116
tests/Feature/Filament/TenantDiagnosticsRepairsTest.php
Normal file
116
tests/Feature/Filament/TenantDiagnosticsRepairsTest.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Tenant diagnostics repairs', function () {
|
||||
it('allows an authorized member to bootstrap an owner when a tenant has no owners', function () {
|
||||
[$manager, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$this->actingAs($manager);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
expect(TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count())->toBe(0);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertSee('Missing owner')
|
||||
->assertActionVisible('bootstrapOwner')
|
||||
->assertActionEnabled('bootstrapOwner')
|
||||
->mountAction('bootstrapOwner')
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
expect(TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('role', 'owner')
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantMembershipBootstrapRecover->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('shows repair actions as disabled for readonly members', function () {
|
||||
[$readonly, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($readonly);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
// Force missing-owner state.
|
||||
TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->update(['role' => 'readonly']);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertActionVisible('bootstrapOwner')
|
||||
->assertActionDisabled('bootstrapOwner')
|
||||
->assertActionExists('bootstrapOwner', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
});
|
||||
});
|
||||
|
||||
it('merges duplicate memberships for the current user (diagnostics repair)', function () {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($owner);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
// Intentionally create a broken state by temporarily dropping the unique uniqueness enforcement.
|
||||
// Tests typically run on SQLite, which uses a unique index rather than a named table constraint.
|
||||
$driver = DB::getDriverName();
|
||||
if ($driver === 'sqlite') {
|
||||
DB::statement('DROP INDEX tenant_memberships_tenant_id_user_id_unique');
|
||||
} else {
|
||||
DB::statement('ALTER TABLE tenant_memberships DROP CONSTRAINT tenant_memberships_tenant_id_user_id_unique');
|
||||
}
|
||||
|
||||
TenantMembership::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'role' => 'readonly',
|
||||
'source' => 'manual',
|
||||
'created_by_user_id' => (int) $owner->getKey(),
|
||||
]);
|
||||
|
||||
expect(TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('user_id', (int) $owner->getKey())
|
||||
->count())->toBeGreaterThan(1);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertActionVisible('mergeDuplicateMemberships')
|
||||
->assertActionEnabled('mergeDuplicateMemberships')
|
||||
->mountAction('mergeDuplicateMemberships')
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
expect(TenantMembership::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('user_id', (int) $owner->getKey())
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
->where('action', AuditActionId::TenantMembershipDuplicatesMerged->value)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
});
|
||||
@ -232,7 +232,9 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->callAction('archive');
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$this->assertSoftDeleted('tenants', ['id' => $tenant->id]);
|
||||
});
|
||||
|
||||
68
tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php
Normal file
68
tests/Feature/Filament/TenantViewHeaderUiEnforcementTest.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Rbac\UiTooltips;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
describe('Tenant View header action UI enforcement', function () {
|
||||
it('shows edit and deactivate actions as visible but disabled for readonly members', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('edit')
|
||||
->assertActionDisabled('edit')
|
||||
->assertActionExists('edit', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
})
|
||||
->assertActionVisible('archive')
|
||||
->assertActionDisabled('archive')
|
||||
->assertActionExists('archive', function (Action $action): bool {
|
||||
return $action->getTooltip() === UiTooltips::INSUFFICIENT_PERMISSION;
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit and deactivate actions as enabled for owner members', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionVisible('edit')
|
||||
->assertActionEnabled('edit')
|
||||
->assertActionVisible('archive')
|
||||
->assertActionEnabled('archive');
|
||||
});
|
||||
|
||||
it('does not execute the deactivate action for readonly members (silently blocked by Filament)', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(ViewTenant::class, ['record' => $tenant->getRouteKey()])
|
||||
->assertActionDisabled('archive')
|
||||
->mountAction('archive')
|
||||
->callMountedAction()
|
||||
->assertSuccessful();
|
||||
|
||||
expect(Tenant::withTrashed()->find($tenant->getKey())?->trashed())->toBeFalse();
|
||||
});
|
||||
});
|
||||
28
tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php
Normal file
28
tests/Feature/TenantRBAC/ArchivedTenantRouteAccessTest.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows members to access the tenant dashboard route for archived tenants', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$tenant->delete();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the tenant dashboard route for archived tenants', function () {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'archived-tenant-a']);
|
||||
$tenant->delete();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertNotFound();
|
||||
});
|
||||
24
tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php
Normal file
24
tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('allows members to access the tenant diagnostics page', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('returns 404 for non-members on the tenant diagnostics page', function () {
|
||||
$tenant = Tenant::factory()->create(['external_id' => 'tenant-diag-a']);
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get("/admin/t/{$tenant->external_id}/diagnostics")
|
||||
->assertNotFound();
|
||||
});
|
||||
28
tests/Feature/TenantRBAC/TenantGuidVsBigintGuardTest.php
Normal file
28
tests/Feature/TenantRBAC/TenantGuidVsBigintGuardTest.php
Normal file
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\TenantDiagnostics;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not use external GUID where internal tenant id is required (diagnostics page loads)', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
// Ensure the tenant has a GUID-like external identifier (this is the tenant route key).
|
||||
$tenant->forceFill([
|
||||
'external_id' => '00000000-0000-0000-0000-000000000123',
|
||||
'tenant_id' => '00000000-0000-0000-0000-000000000123',
|
||||
])->save();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
Livewire::test(TenantDiagnostics::class)
|
||||
->assertSuccessful();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user