feat: complete admin canonical tenant rollout #165
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -65,6 +65,7 @@ ## Active Technologies
|
||||
- PostgreSQL via Laravel Sail; existing `audit_logs` table expanded in place; JSON context payload remains application-shaped rather than raw archival payloads (134-audit-log-foundation)
|
||||
- PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database (135-canonical-tenant-context-resolution)
|
||||
- PostgreSQL application database and session-backed Filament table state (136-admin-canonical-tenant)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -84,8 +85,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 136-admin-canonical-tenant: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
|
||||
- 135-canonical-tenant-context-resolution: Added PHP 8.4 on Laravel 12 + Filament v5, Livewire v4, Pest v4, Laravel Sail
|
||||
- 134-audit-log-foundation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
- 133-detail-page-template: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Clusters\Cluster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Enums\SubNavigationPosition;
|
||||
use UnitEnum;
|
||||
|
||||
@ -18,4 +19,13 @@ class InventoryCluster extends Cluster
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Items';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
}
|
||||
|
||||
37
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
37
app/Filament/Concerns/ResolvesPanelTenantContext.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use Filament\Facades\Filament;
|
||||
use RuntimeException;
|
||||
|
||||
trait ResolvesPanelTenantContext
|
||||
{
|
||||
protected static function resolveTenantContextForCurrentPanel(): ?Tenant
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
$tenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
return $tenant instanceof Tenant ? $tenant : null;
|
||||
}
|
||||
|
||||
protected static function resolveTenantContextForCurrentPanelOrFail(): Tenant
|
||||
{
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('No tenant context selected.');
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -28,6 +29,8 @@
|
||||
|
||||
class BaselineCompareLanding extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-scale';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
||||
@ -94,7 +97,7 @@ public static function canAccess(): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
@ -112,7 +115,7 @@ public function mount(): void
|
||||
|
||||
public function refreshStats(): void
|
||||
{
|
||||
$stats = BaselineCompareStats::forTenant(Tenant::current());
|
||||
$stats = BaselineCompareStats::forTenant(static::resolveTenantContextForCurrentPanel());
|
||||
|
||||
$this->state = $stats->state;
|
||||
$this->message = $stats->message;
|
||||
@ -292,10 +295,10 @@ private function compareNowAction(): Action
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()->title('No tenant context')->danger()->send();
|
||||
Notification::make()->title('Select a tenant to compare baselines')->danger()->send();
|
||||
|
||||
return;
|
||||
}
|
||||
@ -340,7 +343,7 @@ private function compareNowAction(): Action
|
||||
|
||||
public function getFindingsUrl(): ?string
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
@ -355,7 +358,7 @@ public function getRunUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return null;
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -20,6 +21,7 @@
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Pages\Page;
|
||||
use Filament\Support\Enums\FontFamily;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
@ -36,6 +38,7 @@
|
||||
class InventoryCoverage extends Page implements HasTable
|
||||
{
|
||||
use InteractsWithTable;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-table-cells';
|
||||
|
||||
@ -49,9 +52,18 @@ class InventoryCoverage extends Page implements HasTable
|
||||
|
||||
protected string $view = 'filament.pages.inventory-coverage';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
|
||||
@ -201,7 +201,7 @@ public function table(Table $table): Table
|
||||
])
|
||||
->bulkActions([])
|
||||
->emptyStateHeading('No audit events match this view')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the workspace audit history.')
|
||||
->emptyStateDescription('Clear the current search or filters to return to the workspace-wide audit history.')
|
||||
->emptyStateIcon('heroicon-o-funnel')
|
||||
->emptyStateActions([
|
||||
Action::make('clear_filters')
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TenantDiagnosticsService;
|
||||
@ -17,6 +17,8 @@
|
||||
|
||||
class TenantDiagnostics extends Page
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static ?string $slug = 'diagnostics';
|
||||
@ -29,7 +31,7 @@ class TenantDiagnostics extends Page
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = (int) $tenant->getKey();
|
||||
|
||||
$this->missingOwner = ! TenantMembership::query()
|
||||
@ -80,7 +82,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
public function bootstrapOwner(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
@ -94,7 +96,7 @@ public function bootstrapOwner(): void
|
||||
|
||||
public function mergeDuplicateMemberships(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
@ -38,6 +39,7 @@
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\CheckboxList;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
@ -62,6 +64,8 @@
|
||||
|
||||
class BackupScheduleResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSchedule::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -70,9 +74,18 @@ class BackupScheduleResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -88,7 +101,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -112,7 +125,7 @@ public static function canView(Model $record): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -128,7 +141,7 @@ public static function canCreate(): bool
|
||||
|
||||
public static function canEdit(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -144,7 +157,7 @@ public static function canEdit(Model $record): bool
|
||||
|
||||
public static function canDelete(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -160,7 +173,7 @@ public static function canDelete(Model $record): bool
|
||||
|
||||
public static function canDeleteAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -421,7 +434,7 @@ public static function table(Table $table): Table
|
||||
->color('success')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -492,7 +505,7 @@ public static function table(Table $table): Table
|
||||
->color('warning')
|
||||
->visible(fn (BackupSchedule $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -706,7 +719,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-play')
|
||||
->color('success')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -721,7 +734,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -803,7 +816,7 @@ public static function table(Table $table): Table
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
Notification::make()
|
||||
@ -818,7 +831,7 @@ public static function table(Table $table): Table
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$userId = auth()->id();
|
||||
$user = $userId ? User::query()->find($userId) : null;
|
||||
/** @var OperationRunService $operationRunService */
|
||||
@ -906,7 +919,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->where('tenant_id', $tenantId)
|
||||
@ -1027,7 +1040,7 @@ public static function ensurePolicyTypes(array $data): array
|
||||
|
||||
public static function assignTenant(array $data): array
|
||||
{
|
||||
$data['tenant_id'] = Tenant::currentOrFail()->getKey();
|
||||
$data['tenant_id'] = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@ -3,12 +3,24 @@
|
||||
namespace App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSchedules extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupScheduleResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource\Pages;
|
||||
use App\Filament\Resources\BackupSetResource\RelationManagers\BackupItemsRelationManager;
|
||||
use App\Jobs\BulkBackupSetDeleteJob;
|
||||
@ -55,6 +56,8 @@
|
||||
|
||||
class BackupSetResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = BackupSet::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -63,6 +66,15 @@ class BackupSetResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::CrudListAndView)
|
||||
@ -76,7 +88,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -92,7 +104,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -108,7 +120,7 @@ public static function canCreate(): bool
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return parent::getEloquentQuery()->whereRaw('1 = 0');
|
||||
@ -182,7 +194,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$record->restore();
|
||||
$record->items()->withTrashed()->restore();
|
||||
@ -215,7 +227,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$record->delete();
|
||||
|
||||
@ -247,7 +259,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||
Notification::make()
|
||||
@ -317,7 +329,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -387,7 +399,7 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} backup sets?")
|
||||
->modalDescription('Archived backup sets will be restored back to the active list. Active backup sets will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -472,7 +484,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -622,7 +634,7 @@ private static function primaryRelatedEntry(BackupSet $record): ?RelatedContextE
|
||||
public static function createBackupSet(array $data): BackupSet
|
||||
{
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
/** @var BackupService $service */
|
||||
$service = app(BackupService::class);
|
||||
|
||||
@ -3,12 +3,24 @@
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBackupSets extends ListRecords
|
||||
{
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Resources\BackupSetResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -19,6 +20,8 @@
|
||||
|
||||
class ViewBackupSet extends ViewRecord
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = BackupSetResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
@ -79,7 +82,7 @@ private function restoreAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('view', ['record' => $record], tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
@ -120,7 +123,7 @@ private function archiveAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
@ -172,7 +175,7 @@ private function forceDeleteAction(): Action
|
||||
->success()
|
||||
->send();
|
||||
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: Tenant::current()), navigate: true);
|
||||
$this->redirect(BackupSetResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()), navigate: true);
|
||||
});
|
||||
|
||||
UiEnforcement::forAction($action)
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -23,6 +24,12 @@ class ListEntraGroups extends ListRecords
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
if (
|
||||
Filament::getCurrentPanel()?->getId() === 'admin'
|
||||
&& ! EntraGroupResource::panelTenantContext() instanceof Tenant
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
@ -55,6 +56,8 @@
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -65,9 +68,18 @@ class FindingResource extends Resource
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -84,7 +96,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -302,7 +314,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -336,7 +348,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.scope-tags-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -356,7 +368,7 @@ public static function infolist(Schema $schema): Schema
|
||||
->label('')
|
||||
->view('filament.infolists.entries.assignments-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant) {
|
||||
return static::unavailableDiffState('No tenant context');
|
||||
}
|
||||
@ -725,7 +737,7 @@ public static function table(Table $table): Table
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -806,7 +818,7 @@ public static function table(Table $table): Table
|
||||
->searchable(),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -881,7 +893,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -955,7 +967,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -1029,7 +1041,7 @@ public static function table(Table $table): Table
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -1097,7 +1109,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with(['assigneeUser', 'ownerUser', 'closedByUser'])
|
||||
@ -1415,7 +1427,7 @@ public static function reopenAction(): Actions\Action
|
||||
*/
|
||||
private static function runWorkflowMutation(Finding $record, string $successTitle, callable $callback): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -1454,7 +1466,7 @@ private static function runWorkflowMutation(Finding $record, string $successTitl
|
||||
*/
|
||||
private static function tenantMemberOptions(): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Widgets\Tenant\BaselineCompareCoverageBanner;
|
||||
use App\Jobs\BackfillFindingLifecycleJob;
|
||||
@ -11,6 +12,7 @@
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -26,8 +28,22 @@
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
tenantSensitiveFilters: ['scope_key', 'run_ids'],
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
@ -55,7 +71,7 @@ protected function getHeaderActions(): array
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
abort(404);
|
||||
@ -161,7 +177,7 @@ protected function getHeaderActions(): array
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
$tenant = \Filament\Facades\Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
@ -232,7 +248,7 @@ protected function buildAllMatchingQuery(): Builder
|
||||
{
|
||||
$query = Finding::query();
|
||||
|
||||
$tenantId = \Filament\Facades\Filament::getTenant()?->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
if (! is_numeric($tenantId)) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource\Pages;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\Tenant;
|
||||
@ -23,6 +24,7 @@
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
@ -36,6 +38,8 @@
|
||||
|
||||
class InventoryItemResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = InventoryItem::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -48,6 +52,15 @@ class InventoryItemResource extends Resource
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::ListOnlyReadOnly)
|
||||
@ -60,7 +73,7 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -75,7 +88,7 @@ public static function canViewAny(): bool
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -175,7 +188,7 @@ public static function infolist(Schema $schema): Schema
|
||||
|
||||
$service = app(DependencyQueryService::class);
|
||||
$resolver = app(DependencyTargetResolver::class);
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$edges = collect();
|
||||
if ($direction === 'inbound' || $direction === 'all') {
|
||||
@ -321,7 +334,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\InventoryItemResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use App\Jobs\RunInventorySyncJob;
|
||||
@ -11,6 +12,7 @@
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -28,8 +30,21 @@
|
||||
|
||||
class ListInventoryItems extends ListRecords
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = InventoryItemResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
@ -103,7 +118,7 @@ protected function getHeaderActions(): array
|
||||
->rules(['boolean'])
|
||||
->columnSpanFull(),
|
||||
Hidden::make('tenant_id')
|
||||
->default(fn (): ?string => Tenant::current()?->getKey())
|
||||
->default(fn (): ?string => static::resolveTenantContextForCurrentPanel()?->getKey())
|
||||
->dehydrated(),
|
||||
])
|
||||
->visible(function (): bool {
|
||||
@ -112,7 +127,7 @@ protected function getHeaderActions(): array
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return false;
|
||||
}
|
||||
@ -120,7 +135,7 @@ protected function getHeaderActions(): array
|
||||
return $user->canAccessTenant($tenant);
|
||||
})
|
||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\PolicyResource\Pages;
|
||||
use App\Filament\Resources\PolicyResource\RelationManagers\VersionsRelationManager;
|
||||
use App\Jobs\BulkPolicyDeleteJob;
|
||||
@ -34,6 +35,7 @@
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
@ -52,17 +54,30 @@
|
||||
|
||||
class PolicyResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = Policy::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shield-check';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -98,7 +113,7 @@ public static function makeSyncAction(string $name = 'sync'): Actions\Action
|
||||
->modalHeading('Sync policies from Intune')
|
||||
->modalDescription('This queues a background sync operation for supported policy types in the current tenant.')
|
||||
->action(function (Pages\ListPolicies $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||
@ -488,7 +503,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to ignore policies.')
|
||||
@ -509,7 +524,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->tooltip('You do not have permission to restore policies.')
|
||||
@ -523,7 +538,7 @@ public static function table(Table $table): Table
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||
->action(function (Policy $record, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -569,7 +584,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_SYNC)
|
||||
->preserveVisibility()
|
||||
@ -586,7 +601,7 @@ public static function table(Table $table): Table
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Policy $record, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -639,7 +654,7 @@ public static function table(Table $table): Table
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -678,7 +693,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -761,7 +776,7 @@ public static function table(Table $table): Table
|
||||
return ! in_array($value, [null, 'ignored'], true);
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -843,7 +858,7 @@ public static function table(Table $table): Table
|
||||
return $value === 'ignored';
|
||||
})
|
||||
->action(function (Collection $records, HasTable $livewire): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
|
||||
@ -914,7 +929,7 @@ public static function table(Table $table): Table
|
||||
->default(fn () => 'Backup '.now()->toDateTimeString()),
|
||||
])
|
||||
->action(function (Collection $records, array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -995,7 +1010,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\PolicyResource\RelationManagers;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -26,6 +27,8 @@
|
||||
|
||||
class VersionsRelationManager extends RelationManager
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $relationship = 'versions';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
@ -53,7 +56,7 @@ public function table(Table $table): Table
|
||||
->default(true),
|
||||
])
|
||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -110,7 +113,7 @@ public function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -130,7 +133,7 @@ public function table(Table $table): Table
|
||||
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
use App\Jobs\BulkPolicyVersionForceDeleteJob;
|
||||
use App\Jobs\BulkPolicyVersionPruneJob;
|
||||
@ -39,6 +40,7 @@
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -57,17 +59,30 @@
|
||||
|
||||
class PolicyVersionResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = PolicyVersion::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $isGloballySearchable = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-list-bullet';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function shouldRegisterNavigation(): bool
|
||||
{
|
||||
if (Filament::getCurrentPanel()?->getId() === 'admin') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return parent::shouldRegisterNavigation();
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -274,7 +289,7 @@ public static function table(Table $table): Table
|
||||
return $fields;
|
||||
})
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -353,7 +368,7 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -437,7 +452,7 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records, array $data) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -546,7 +561,7 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||
->visible(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -563,7 +578,7 @@ public static function table(Table $table): Table
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -580,7 +595,7 @@ public static function table(Table $table): Table
|
||||
return ! $resolver->can($user, $tenant, Capabilities::TENANT_MANAGE);
|
||||
})
|
||||
->tooltip(function (PolicyVersion $record): ?string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -605,7 +620,7 @@ public static function table(Table $table): Table
|
||||
return null;
|
||||
})
|
||||
->action(function (PolicyVersion $record) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -877,7 +892,7 @@ public static function table(Table $table): Table
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::currentOrFail();
|
||||
$tenant = static::resolveTenantContextForCurrentPanelOrFail();
|
||||
$tenantId = $tenant->getKey();
|
||||
$user = auth()->user();
|
||||
|
||||
|
||||
@ -3,12 +3,24 @@
|
||||
namespace App\Filament\Resources\PolicyVersionResource\Pages;
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListPolicyVersions extends ListRecords
|
||||
{
|
||||
protected static string $resource = PolicyVersionResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||
use App\Jobs\ProviderInventorySyncJob;
|
||||
@ -30,7 +31,6 @@
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -49,6 +49,8 @@
|
||||
|
||||
class ProviderConnectionResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isDiscovered = false;
|
||||
|
||||
protected static bool $isScopedToTenant = false;
|
||||
@ -147,9 +149,7 @@ protected static function resolveScopedTenant(): ?Tenant
|
||||
return static::resolveTenantByExternalId($contextTenantExternalId);
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
return $filamentTenant instanceof Tenant ? $filamentTenant : null;
|
||||
return static::resolveTenantContextForCurrentPanel();
|
||||
}
|
||||
|
||||
public static function resolveTenantForRecord(?ProviderConnection $record = null): ?Tenant
|
||||
@ -196,10 +196,10 @@ public static function resolveContextTenantExternalId(): ?string
|
||||
}
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($filamentTenant instanceof Tenant) {
|
||||
return (string) $filamentTenant->external_id;
|
||||
if ($tenant instanceof Tenant) {
|
||||
return (string) $tenant->external_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -329,10 +329,10 @@ private static function applyMembershipScope(Builder $query): Builder
|
||||
$user = auth()->user();
|
||||
|
||||
if (! is_int($workspaceId)) {
|
||||
$filamentTenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if ($filamentTenant instanceof Tenant) {
|
||||
$workspaceId = (int) $filamentTenant->workspace_id;
|
||||
if ($tenant instanceof Tenant) {
|
||||
$workspaceId = (int) $tenant->workspace_id;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,14 +7,26 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProviderConnections extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProviderConnectionResource::class;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$this->getTableFiltersSessionKey(),
|
||||
request: request(),
|
||||
tenantFilterName: 'tenant',
|
||||
tenantAttribute: 'external_id',
|
||||
);
|
||||
|
||||
parent::mount();
|
||||
}
|
||||
|
||||
private function tableHasRecords(): bool
|
||||
{
|
||||
return $this->getTableRecords()->count() > 0;
|
||||
@ -207,9 +219,7 @@ private function resolveTenantExternalIdForCreateAction(): ?string
|
||||
return $requested;
|
||||
}
|
||||
|
||||
$filamentTenant = Filament::getTenant();
|
||||
|
||||
return $filamentTenant instanceof Tenant ? (string) $filamentTenant->external_id : null;
|
||||
return ProviderConnectionResource::resolveContextTenantExternalId();
|
||||
}
|
||||
|
||||
private function resolveTenantForCreateAction(): ?Tenant
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Contracts\Hardening\WriteGateInterface;
|
||||
use App\Exceptions\Hardening\ProviderAccessHardeningRequired;
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages;
|
||||
use App\Jobs\BulkRestoreRunDeleteJob;
|
||||
use App\Jobs\BulkRestoreRunForceDeleteJob;
|
||||
@ -65,6 +66,8 @@
|
||||
|
||||
class RestoreRunResource extends Resource
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static ?string $model = RestoreRun::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
@ -84,7 +87,7 @@ public static function shouldRegisterNavigation(): bool
|
||||
|
||||
public static function canCreate(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -105,7 +108,7 @@ public static function form(Schema $schema): Schema
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
@ -145,7 +148,7 @@ public static function form(Schema $schema): Schema
|
||||
->schema(function (Get $get): array {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -199,7 +202,7 @@ public static function form(Schema $schema): Schema
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
@ -207,7 +210,7 @@ public static function form(Schema $schema): Schema
|
||||
->visible(function (Get $get): bool {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return false;
|
||||
@ -239,7 +242,7 @@ public static function makeCreateAction(): Actions\CreateAction
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanel()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('backupSet')
|
||||
@ -265,7 +268,7 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Select::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->options(function () {
|
||||
$tenantId = Tenant::currentOrFail()->getKey();
|
||||
$tenantId = static::resolveTenantContextForCurrentPanelOrFail()->getKey();
|
||||
|
||||
return BackupSet::query()
|
||||
->when($tenantId, fn ($query) => $query->where('tenant_id', $tenantId))
|
||||
@ -290,7 +293,7 @@ public static function getWizardSteps(): array
|
||||
backupSetId: $get('backup_set_id'),
|
||||
scopeMode: 'all',
|
||||
selectedItemIds: null,
|
||||
tenant: Tenant::current(),
|
||||
tenant: static::resolveTenantContextForCurrentPanel(),
|
||||
));
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
@ -317,7 +320,7 @@ public static function getWizardSteps(): array
|
||||
->reactive()
|
||||
->afterStateUpdated(function (Set $set, Get $get, $state): void {
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$set('is_dry_run', true);
|
||||
$set('acknowledged_impact', false);
|
||||
$set('tenant_confirm', null);
|
||||
@ -357,7 +360,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$selectedItemIds = is_array($selectedItemIds) ? $selectedItemIds : null;
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$set('group_mapping', static::groupMappingPlaceholders(
|
||||
backupSetId: $backupSetId,
|
||||
@ -408,7 +411,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$scopeMode = $get('scope_mode') ?? 'all';
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -477,7 +480,7 @@ public static function getWizardSteps(): array
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: static::resolveTenantContextForCurrentPanel()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
}, $unresolved);
|
||||
@ -486,7 +489,7 @@ public static function getWizardSteps(): array
|
||||
$backupSetId = $get('backup_set_id');
|
||||
$scopeMode = $get('scope_mode') ?? 'all';
|
||||
$selectedItemIds = $get('backup_item_ids');
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return false;
|
||||
@ -527,7 +530,7 @@ public static function getWizardSteps(): array
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||
->action(function (Get $get, Set $set): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
@ -625,7 +628,7 @@ public static function getWizardSteps(): array
|
||||
->color('gray')
|
||||
->visible(fn (Get $get): bool => filled($get('backup_set_id')))
|
||||
->action(function (Get $get, Set $set): void {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
@ -704,7 +707,7 @@ public static function getWizardSteps(): array
|
||||
Forms\Components\Placeholder::make('confirm_tenant_label')
|
||||
->label('Tenant hard-confirm label')
|
||||
->content(function (): string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return '';
|
||||
@ -741,7 +744,7 @@ public static function getWizardSteps(): array
|
||||
->required(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||
->visible(fn (Get $get): bool => $get('is_dry_run') === false)
|
||||
->in(function (): array {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return [];
|
||||
@ -755,7 +758,7 @@ public static function getWizardSteps(): array
|
||||
'in' => 'Tenant hard-confirm does not match.',
|
||||
])
|
||||
->helperText(function (): string {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return '';
|
||||
@ -861,7 +864,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -902,7 +905,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -933,7 +936,7 @@ public static function table(Table $table): Table
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_DELETE)
|
||||
->preserveVisibility()
|
||||
@ -972,7 +975,7 @@ public static function table(Table $table): Table
|
||||
return [];
|
||||
})
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -1042,7 +1045,7 @@ public static function table(Table $table): Table
|
||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} restore runs?")
|
||||
->modalDescription('Archived runs will be restored back to the active list. Active runs will be skipped.')
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -1132,7 +1135,7 @@ public static function table(Table $table): Table
|
||||
]),
|
||||
])
|
||||
->action(function (Collection $records) {
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
@ -1276,7 +1279,7 @@ private static function typeMeta(?string $type): array
|
||||
*/
|
||||
private static function restoreItemOptionData(?int $backupSetId): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [
|
||||
@ -1347,7 +1350,7 @@ private static function restoreItemOptionData(?int $backupSetId): array
|
||||
*/
|
||||
private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant || ! $backupSetId) {
|
||||
return [];
|
||||
@ -1399,7 +1402,7 @@ private static function restoreItemGroupedOptions(?int $backupSetId): array
|
||||
|
||||
public static function createRestoreRun(array $data): RestoreRun
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
@ -2165,7 +2168,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
OperationUxPresenter::queuedToast('restore.execute')
|
||||
->send();
|
||||
}),
|
||||
fn () => Tenant::current(),
|
||||
fn () => static::resolveTenantContextForCurrentPanel(),
|
||||
)
|
||||
->requireCapability(Capabilities::TENANT_MANAGE)
|
||||
->preserveVisibility()
|
||||
@ -2181,7 +2184,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
return true;
|
||||
}
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return true;
|
||||
@ -2205,7 +2208,7 @@ private static function rerunActionWithGate(): Actions\Action|BulkAction
|
||||
return \App\Support\Auth\UiTooltips::insufficientPermission();
|
||||
}
|
||||
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : Tenant::current();
|
||||
$tenant = $record instanceof RestoreRun ? $record->tenant : static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return 'Tenant unavailable';
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Filament\Resources\RestoreRunResource\Pages;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
@ -17,12 +18,13 @@
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
{
|
||||
use HasWizard;
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static string $resource = RestoreRunResource::class;
|
||||
|
||||
protected function authorizeAccess(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
@ -60,7 +62,7 @@ protected function afterFill(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant) {
|
||||
return;
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Concerns\ResolvesPanelTenantContext;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
@ -14,7 +15,6 @@
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
@ -22,6 +22,8 @@
|
||||
|
||||
class InventoryKpiHeader extends StatsOverviewWidget
|
||||
{
|
||||
use ResolvesPanelTenantContext;
|
||||
|
||||
protected static bool $isLazy = false;
|
||||
|
||||
protected int|string|array $columnSpan = 'full';
|
||||
@ -36,15 +38,15 @@ class InventoryKpiHeader extends StatsOverviewWidget
|
||||
*/
|
||||
protected function getStats(): array
|
||||
{
|
||||
$tenant = Filament::getTenant();
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [
|
||||
Stat::make('Total items', 0),
|
||||
Stat::make('Coverage', '0%')->description('Restorable 0 • Partial 0'),
|
||||
Stat::make('Last inventory sync', '—'),
|
||||
Stat::make('Coverage', '0%')->description('Select a tenant to load coverage.'),
|
||||
Stat::make('Last inventory sync', '—')->description('Select a tenant to see the latest sync.'),
|
||||
Stat::make('Active ops', 0),
|
||||
Stat::make('Inventory ops', 0)->description('Dependencies 0 • Risk 0'),
|
||||
Stat::make('Inventory ops', 0)->description('Select a tenant to load dependency and risk counts.'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -1674,9 +1674,8 @@ private function resolveRoleDefinitionVersion(Tenant $tenant, ?int $policyVersio
|
||||
private function fallbackRoleDefinitionNormalized(?PolicyVersion $version, array $meta): array
|
||||
{
|
||||
if ($version instanceof PolicyVersion) {
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->flattenForDiff(
|
||||
return app(IntuneRoleDefinitionNormalizer::class)->buildEvidenceMap(
|
||||
is_array($version->snapshot) ? $version->snapshot : [],
|
||||
'intuneRoleDefinition',
|
||||
is_string($version->platform ?? null) ? (string) $version->platform : null,
|
||||
);
|
||||
}
|
||||
|
||||
@ -108,6 +108,14 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildEvidenceMap(?array $snapshot, ?string $platform = null): array
|
||||
{
|
||||
return $this->flattenForDiff($snapshot, 'intuneRoleDefinition', $platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* baseline: array<string, mixed>,
|
||||
@ -121,8 +129,8 @@ public function flattenForDiff(?array $snapshot, string $policyType, ?string $pl
|
||||
*/
|
||||
public function classifyDiff(?array $baselineSnapshot, ?array $currentSnapshot, ?string $platform = null): array
|
||||
{
|
||||
$baseline = $this->flattenForDiff($baselineSnapshot, 'intuneRoleDefinition', $platform);
|
||||
$current = $this->flattenForDiff($currentSnapshot, 'intuneRoleDefinition', $platform);
|
||||
$baseline = $this->buildEvidenceMap($baselineSnapshot, $platform);
|
||||
$current = $this->buildEvidenceMap($currentSnapshot, $platform);
|
||||
|
||||
$keys = array_values(array_unique(array_merge(array_keys($baseline), array_keys($current))));
|
||||
sort($keys, SORT_STRING);
|
||||
|
||||
@ -153,12 +153,12 @@ public function capture(
|
||||
fn ($query) => $query->where('baseline_profile_id', $baselineProfileId),
|
||||
)
|
||||
->get()
|
||||
->first(function ($version) use ($snapshotHash) {
|
||||
return $this->snapshotContractHash(
|
||||
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
|
||||
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
|
||||
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
|
||||
) === $snapshotHash;
|
||||
->first(function (PolicyVersion $version) use ($protectedSnapshot, $snapshotHash) {
|
||||
return $this->matchesExistingVersionSnapshot(
|
||||
version: $version,
|
||||
snapshot: $protectedSnapshot->snapshot,
|
||||
snapshotHash: $snapshotHash,
|
||||
);
|
||||
});
|
||||
|
||||
if ($existingVersion) {
|
||||
@ -511,6 +511,29 @@ private function fingerprintBucket(PolicyVersion $version, string $bucket): arra
|
||||
return is_array($bucketFingerprints) ? $bucketFingerprints : [];
|
||||
}
|
||||
|
||||
private function matchesExistingVersionSnapshot(PolicyVersion $version, array $snapshot, string $snapshotHash): bool
|
||||
{
|
||||
$currentHash = $this->snapshotContractHash(
|
||||
snapshot: is_array($version->snapshot) ? $version->snapshot : [],
|
||||
snapshotFingerprints: $this->fingerprintBucket($version, 'snapshot'),
|
||||
redactionVersion: is_numeric($version->redaction_version) ? (int) $version->redaction_version : null,
|
||||
);
|
||||
|
||||
if ($currentHash === $snapshotHash) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$hasLegacySnapshotContract = $this->fingerprintBucket($version, 'snapshot') === []
|
||||
&& ! is_numeric($version->redaction_version);
|
||||
|
||||
if (! $hasLegacySnapshotContract) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->normalizeHashValue(is_array($version->snapshot) ? $version->snapshot : [])
|
||||
=== $this->normalizeHashValue($snapshot);
|
||||
}
|
||||
|
||||
private function normalizeHashValue(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
|
||||
@ -76,7 +76,7 @@ public function sanitizeAuditString(string $value): string
|
||||
public function sanitizeOpsFailureString(string $value): string
|
||||
{
|
||||
$sanitized = $this->sanitizeMessageLikeString($value, '[REDACTED_SECRET]');
|
||||
$sanitized = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
|
||||
$sanitized = preg_replace('/(?:\[[A-Z_]+\]|[A-Z0-9._%+\-]+)@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $sanitized) ?? $sanitized;
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
@ -19,8 +19,13 @@ public function __construct(private readonly OperateHubShell $operateHubShell) {
|
||||
/**
|
||||
* @param array<int, string> $tenantSensitiveFilters
|
||||
*/
|
||||
public function sync(string $filtersSessionKey, array $tenantSensitiveFilters = [], ?Request $request = null): void
|
||||
{
|
||||
public function sync(
|
||||
string $filtersSessionKey,
|
||||
array $tenantSensitiveFilters = [],
|
||||
?Request $request = null,
|
||||
?string $tenantFilterName = 'tenant_id',
|
||||
string $tenantAttribute = 'id',
|
||||
): void {
|
||||
$session = $this->session($request);
|
||||
|
||||
$persistedFilters = $session->get($filtersSessionKey, []);
|
||||
@ -40,13 +45,25 @@ public function sync(string $filtersSessionKey, array $tenantSensitiveFilters =
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedTenantId !== null) {
|
||||
data_set($persistedFilters, 'tenant_id.value', $resolvedTenantId);
|
||||
} else {
|
||||
Arr::forget($persistedFilters, 'tenant_id');
|
||||
if ($tenantFilterName !== null) {
|
||||
$tenantFilterValue = match ($tenantAttribute) {
|
||||
'external_id' => $activeTenant?->external_id,
|
||||
default => $resolvedTenantId,
|
||||
};
|
||||
|
||||
if ($tenantFilterValue !== null) {
|
||||
data_set($persistedFilters, "{$tenantFilterName}.value", $tenantFilterValue);
|
||||
} else {
|
||||
Arr::forget($persistedFilters, $tenantFilterName);
|
||||
}
|
||||
}
|
||||
|
||||
if ($persistedFilters === []) {
|
||||
$session->forget($filtersSessionKey);
|
||||
} else {
|
||||
$session->put($filtersSessionKey, $persistedFilters);
|
||||
}
|
||||
|
||||
$session->put($filtersSessionKey, $persistedFilters);
|
||||
$session->put($stateKey, $resolvedTenantId);
|
||||
}
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperateHub\OperateHubShell;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Closure;
|
||||
use Filament\Facades\Filament;
|
||||
@ -84,7 +85,7 @@ public function handle(Request $request, Closure $next): Response
|
||||
|
||||
if (
|
||||
$tenantParameter === null
|
||||
&& ! filled(Filament::getTenant())
|
||||
&& ! $this->hasCanonicalTenantSelection($request)
|
||||
&& $this->adminPathRequiresTenantSelection($path)
|
||||
) {
|
||||
return redirect()->route('filament.admin.pages.choose-tenant');
|
||||
@ -271,4 +272,9 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
||||
|
||||
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets)(/|$)#', $path) === 1;
|
||||
}
|
||||
|
||||
private function hasCanonicalTenantSelection(Request $request): bool
|
||||
{
|
||||
return app(OperateHubShell::class)->activeEntitledTenant($request) instanceof Tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ public function scopeLabel(?Request $request = null): string
|
||||
$activeTenant = $this->activeEntitledTenant($request);
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
return 'Filtered by tenant: '.$activeTenant->name;
|
||||
return 'Tenant scope: '.$activeTenant->name;
|
||||
}
|
||||
|
||||
return 'All tenants';
|
||||
|
||||
117
docs/research/admin-canonical-tenant-rollout.md
Normal file
117
docs/research/admin-canonical-tenant-rollout.md
Normal file
@ -0,0 +1,117 @@
|
||||
# Admin Canonical Tenant Rollout
|
||||
|
||||
## Purpose
|
||||
|
||||
Spec 136 completes the workspace-admin canonical tenant rule across admin-visible and admin-reachable shared surfaces. Workspace-admin requests under `/admin/...` resolve tenant context through `App\Support\OperateHub\OperateHubShell::activeEntitledTenant(request())`. Tenant-panel requests under `/admin/t/{tenant}/...` keep panel-native tenant semantics.
|
||||
|
||||
## Rollout Manifest
|
||||
|
||||
### Type A: Hard tenant-sensitive
|
||||
|
||||
- `app/Filament/Resources/PolicyResource.php`
|
||||
- `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- `app/Filament/Resources/BackupSetResource.php`
|
||||
- `app/Filament/Resources/FindingResource.php`
|
||||
- `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- `app/Filament/Resources/RestoreRunResource.php`
|
||||
- `app/Filament/Resources/InventoryItemResource.php`
|
||||
- `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- `app/Filament/Pages/TenantDiagnostics.php`
|
||||
- `app/Filament/Pages/InventoryCoverage.php`
|
||||
- `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
|
||||
|
||||
### Type B: Workspace-wide with tenant-default behavior
|
||||
|
||||
- `app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- `app/Filament/Resources/EntraGroupResource.php`
|
||||
- `app/Filament/Resources/AlertDeliveryResource.php`
|
||||
|
||||
### Type C: Workspace-only non-regression references
|
||||
|
||||
- `app/Filament/Resources/AlertRuleResource.php`
|
||||
- `app/Filament/Resources/BaselineProfileResource.php`
|
||||
- `app/Filament/Resources/BaselineSnapshotResource.php`
|
||||
- `app/Filament/Resources/TenantResource.php`
|
||||
|
||||
## Persisted Filter Sync
|
||||
|
||||
Apply `App\Support\Filament\CanonicalAdminTenantFilterState::sync()` on admin list surfaces that persist filters in session.
|
||||
|
||||
- `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`
|
||||
- `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- `app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php`
|
||||
- `app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`
|
||||
- `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
- `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`
|
||||
- `app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php`
|
||||
- `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
|
||||
- `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`
|
||||
|
||||
## Guarded Files
|
||||
|
||||
These files must not introduce raw admin-path `Tenant::current()` or `Filament::getTenant()` reads:
|
||||
|
||||
- `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- `app/Filament/Pages/TenantDiagnostics.php`
|
||||
- `app/Filament/Pages/InventoryCoverage.php`
|
||||
- `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
|
||||
- `app/Filament/Resources/PolicyResource.php`
|
||||
- `app/Filament/Resources/BackupScheduleResource.php`
|
||||
- `app/Filament/Resources/InventoryItemResource.php`
|
||||
- `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- `app/Filament/Resources/ProviderConnectionResource.php`
|
||||
- `app/Filament/Resources/AlertDeliveryResource.php`
|
||||
- `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`
|
||||
- `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`
|
||||
|
||||
## Exception Inventory
|
||||
|
||||
Approved tenant-panel-native or bootstrapping exceptions:
|
||||
|
||||
- `app/Filament/Pages/ChooseTenant.php`
|
||||
- `app/Http/Controllers/SelectTenantController.php`
|
||||
- `app/Support/Middleware/EnsureFilamentTenantSelected.php`
|
||||
- `app/Filament/Resources/EntraGroupResource.php`
|
||||
- `app/Filament/Concerns/ResolvesPanelTenantContext.php`
|
||||
|
||||
`app/Filament/Concerns/ResolvesPanelTenantContext.php` is the only shared internal delegation wrapper allowed for this rollout. It is not a new public resolver. Admin semantics still come from `OperateHubShell`.
|
||||
|
||||
## Global Search
|
||||
|
||||
- `PolicyResource`: global search disabled explicitly.
|
||||
- `PolicyVersionResource`: global search disabled explicitly.
|
||||
- `EntraGroupResource`: global search remains enabled and uses admin-aware scoping with a View page.
|
||||
|
||||
## Future-Surface Rule
|
||||
|
||||
Any new admin-visible or admin-reachable tenant-sensitive Filament surface must:
|
||||
|
||||
- resolve workspace-admin tenant context through `OperateHubShell` or the internal `ResolvesPanelTenantContext` helper
|
||||
- keep tenant-panel requests panel-native
|
||||
- synchronize persisted tenant-derived filters before render when `persistFiltersInSession()` is used
|
||||
- disable global search unless list, detail, and search parity are explicitly tenant-safe
|
||||
- keep destructive actions on `->action(...)` with `->requiresConfirmation()` and server-side authorization
|
||||
|
||||
## Verification Log
|
||||
|
||||
### Wave 1
|
||||
|
||||
- 2026-03-11: automated parity coverage added for representative tenant-sensitive resources and admin list scoping.
|
||||
- 2026-03-12: manual tenant-switch verification completed on `/admin/findings` with `Phoenicon (DEV)` and `YPTW2 (DEV)`.
|
||||
- 2026-03-12: the admin shell changed visible finding rows and row-action tenant IDs together after switching tenants, confirming header context, list queries, and deep links stayed aligned.
|
||||
|
||||
### Wave 2
|
||||
|
||||
- 2026-03-11: automated stale-filter and tenant-default coverage added for persisted filter surfaces.
|
||||
- 2026-03-12: manual verification completed on `/admin/inventory/inventory-coverage` after fixing remembered-canonical-tenant handling in `EnsureFilamentTenantSelected`.
|
||||
- 2026-03-12: switching from `YPTW2 (DEV)` to `Phoenicon (DEV)` changed tenant-driven KPI values while the shared support-matrix table remained available, confirming page-widget parity and the expected safe no-data state.
|
||||
- 2026-03-12: tenant-panel navigation under `/admin/t/{tenant}/...` remained route-bound and required explicit tenant switching through the chooser flow instead of reusing remembered admin context.
|
||||
|
||||
### Wave 3
|
||||
|
||||
- 2026-03-11: guard inventory and shared-surface panel-split coverage updated for the canonical rollout.
|
||||
- 2026-03-12: manual verification completed on `/admin/provider-connections` and confirmed the base dataset stays workspace-wide while the default tenant filter follows the active canonical admin tenant.
|
||||
- 2026-03-12: clearing the tenant filter exposed workspace-wide provider connections, while switching back to another tenant reseeded the tenant-default pill and `tenant_id` deep links for the newly selected tenant.
|
||||
- 2026-03-12: admin search safety remained unchanged from the rollout contract: `PolicyResource` and `PolicyVersionResource` stay disabled for global search, and `EntraGroupResource` keeps admin-aware global search with a View page.
|
||||
@ -1,5 +1,5 @@
|
||||
@php
|
||||
$report = isset($getState) ? $getState() : ($report ?? null);
|
||||
$report = $report ?? null;
|
||||
$report = is_array($report) ? $report : null;
|
||||
|
||||
$run = $run ?? null;
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
@php
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\ChooseWorkspace;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -151,7 +152,12 @@ class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transi
|
||||
<div class="flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2 dark:bg-primary-950/30">
|
||||
<x-filament::icon icon="heroicon-o-building-office-2" class="h-4 w-4 text-primary-600 dark:text-primary-400" />
|
||||
<span class="text-sm font-medium text-primary-700 dark:text-primary-300">{{ $currentTenantName ?? 'Tenant' }}</span>
|
||||
<span class="ml-auto text-xs text-primary-500 dark:text-primary-400">locked</span>
|
||||
<a
|
||||
href="{{ ChooseTenant::getUrl(panel: 'admin') }}"
|
||||
class="ml-auto text-xs font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>
|
||||
Switch tenant
|
||||
</a>
|
||||
</div>
|
||||
@else
|
||||
@if ($tenants->isEmpty())
|
||||
|
||||
37
specs/136-admin-canonical-tenant/checklists/requirements.md
Normal file
37
specs/136-admin-canonical-tenant/checklists/requirements.md
Normal file
@ -0,0 +1,37 @@
|
||||
# Specification Quality Checklist: Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-03-11
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [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
|
||||
|
||||
## Notes
|
||||
|
||||
- Validation pass completed on 2026-03-11.
|
||||
- No unresolved clarification markers remain.
|
||||
- Product-specific source-of-truth names are retained only where they define the required tenant-resolution rule and panel separation for this feature.
|
||||
- Spec is ready for `/speckit.plan`.
|
||||
@ -0,0 +1,183 @@
|
||||
version: 1
|
||||
feature: 136-admin-canonical-tenant
|
||||
title: Admin Panel Canonical Tenant Resolution Rollout Matrix
|
||||
type: internal-behavior-contract
|
||||
|
||||
panel_rules:
|
||||
workspace_admin:
|
||||
source_of_truth: App\Support\OperateHub\OperateHubShell::activeEntitledTenant
|
||||
persisted_filter_sync: App\Support\Filament\CanonicalAdminTenantFilterState::sync
|
||||
search_rule: admin_aware_scoped_search_or_disable
|
||||
notes:
|
||||
- Workspace-admin tenant-sensitive behavior must not read Filament::getTenant() or Tenant::current() directly.
|
||||
- One surface must use one tenant source for header, query, widgets, links, and actions.
|
||||
tenant_panel:
|
||||
source_of_truth: Filament::getTenant()
|
||||
persisted_filter_sync: panel_native_tenant_semantics
|
||||
search_rule: panel_native_tenant_search
|
||||
notes:
|
||||
- Tenant-panel-native routes keep Filament tenancy semantics.
|
||||
- Remembered admin tenant state must not override tenant-panel context.
|
||||
|
||||
surface_classes:
|
||||
type_a:
|
||||
description: hard tenant-sensitive surfaces
|
||||
members:
|
||||
- PolicyResource
|
||||
- BackupScheduleResource
|
||||
- BackupSetResource
|
||||
- FindingResource
|
||||
- BaselineCompareLanding
|
||||
- RestoreRunResource
|
||||
- InventoryItemResource
|
||||
- PolicyVersionResource
|
||||
- TenantDiagnostics
|
||||
- InventoryCoverage
|
||||
- InventoryKpiHeader
|
||||
type_b:
|
||||
description: workspace-wide surfaces with tenant-default behavior
|
||||
members:
|
||||
- ProviderConnectionResource
|
||||
- AuditLog
|
||||
- EntraGroupResource
|
||||
type_c:
|
||||
description: workspace-only surfaces with no tenant execution semantics
|
||||
members:
|
||||
- AlertRuleResource
|
||||
- BaselineProfileResource
|
||||
- BaselineSnapshotResource
|
||||
- TenantResource
|
||||
|
||||
global_search:
|
||||
searchable_resources:
|
||||
- name: PolicyResource
|
||||
requirement: retain_view_page_and_admin_safe_scope_if_search_remains_enabled
|
||||
- name: PolicyVersionResource
|
||||
requirement: retain_view_page_and_admin_safe_scope_if_search_remains_enabled
|
||||
- name: EntraGroupResource
|
||||
requirement: retain_view_page_and_admin_safe_scope_or_disable_admin_search
|
||||
non_searchable_rollout_surfaces:
|
||||
- BaselineCompareLanding
|
||||
- InventoryCoverage
|
||||
- InventoryKpiHeader
|
||||
- AuditLog
|
||||
- TenantDiagnostics
|
||||
|
||||
surfaces:
|
||||
- name: PolicyResource
|
||||
class: type_a
|
||||
visibility: admin_visible_and_shared
|
||||
expectations:
|
||||
- query_detail_and_actions_use_canonical_admin_tenant_in_admin_path
|
||||
- tenant_panel_behavior_remains_panel_native
|
||||
- persisted_tenant_filters_reseed_or_clear_on_switch_when_present
|
||||
|
||||
- name: BackupScheduleResource
|
||||
class: type_a
|
||||
visibility: shared_tenant_sensitive
|
||||
expectations:
|
||||
- sensitive_actions_match_visible_tenant_context
|
||||
- existing_confirmation_and_authorization_rules_are_preserved
|
||||
- admin_path_does_not_fall_back_to_raw_panel_native_resolvers
|
||||
|
||||
- name: BackupSetResource
|
||||
class: type_a
|
||||
visibility: shared_tenant_sensitive
|
||||
expectations:
|
||||
- list_detail_relation_and_action_paths_share_one_tenant_source
|
||||
- no_mixed_query_record_or_action_resolution
|
||||
|
||||
- name: FindingResource
|
||||
class: type_a
|
||||
visibility: shared_tenant_sensitive
|
||||
expectations:
|
||||
- query_detail_widget_and_action_paths_share_one_tenant_source
|
||||
- governance_sensitive_actions_cannot_execute_against_wrong_tenant
|
||||
|
||||
- name: BaselineCompareLanding
|
||||
class: type_a
|
||||
visibility: admin_visible
|
||||
expectations:
|
||||
- page_summaries_links_and_drilldowns_use_canonical_admin_tenant
|
||||
- no_valid_tenant_produces_safe_no_tenant_selected_behavior
|
||||
|
||||
- name: RestoreRunResource
|
||||
class: type_a
|
||||
visibility: shared_panel_aware
|
||||
expectations:
|
||||
- tenant_panel_routes_remain_panel_native
|
||||
- shared_code_paths_do_not_mix_admin_and_tenant_resolvers
|
||||
- destructive_restore_contexts_cannot_drift_from_visible_tenant
|
||||
|
||||
- name: InventoryItemResource
|
||||
class: type_a
|
||||
visibility: admin_visible_and_shared
|
||||
expectations:
|
||||
- admin_query_detail_and_links_use_canonical_admin_tenant
|
||||
- tenant_panel_behavior_remains_panel_native
|
||||
|
||||
- name: PolicyVersionResource
|
||||
class: type_a
|
||||
visibility: shared_tenant_sensitive
|
||||
expectations:
|
||||
- version_query_detail_and_restore_related_actions_share_one_tenant_source
|
||||
- persisted_tenant_filters_reseed_or_clear_on_switch_when_present
|
||||
|
||||
- name: ProviderConnectionResource
|
||||
class: type_b
|
||||
visibility: admin_visible
|
||||
expectations:
|
||||
- base_dataset_remains_workspace_wide
|
||||
- tenant_default_header_filters_and_links_follow_canonical_admin_tenant
|
||||
|
||||
- name: TenantDiagnostics
|
||||
class: type_a
|
||||
visibility: admin_visible
|
||||
expectations:
|
||||
- diagnostic_subject_matches_canonical_admin_tenant
|
||||
- no_global_or_is_current_fallback_is_used_in_admin_path
|
||||
|
||||
- name: InventoryCoverage_and_InventoryKpiHeader
|
||||
class: type_a
|
||||
visibility: admin_visible
|
||||
expectations:
|
||||
- page_and_widget_share_one_tenant_source
|
||||
- linked_drilldowns_match_the_same_tenant_context
|
||||
|
||||
- name: AuditLog
|
||||
class: type_b
|
||||
visibility: admin_visible
|
||||
expectations:
|
||||
- base_dataset_remains_workspace_wide
|
||||
- tenant_default_filters_and_header_actions_follow_canonical_admin_tenant
|
||||
- guard_coverage_is_explicit
|
||||
|
||||
- name: EntraGroupResource
|
||||
class: type_b
|
||||
visibility: shared_panel_aware
|
||||
expectations:
|
||||
- admin_path_uses_admin_safe_scope_rule
|
||||
- tenant_panel_path_remains_panel_native
|
||||
- direct_record_and_search_behavior_match_list_scope
|
||||
- admin_no_context_is_not_found_or_no_results
|
||||
|
||||
authorization_contract:
|
||||
non_member_or_out_of_scope:
|
||||
response: not_found
|
||||
member_missing_capability:
|
||||
response: forbidden
|
||||
|
||||
guardrail:
|
||||
purpose: prevent_new_admin_path_raw_panel_native_tenant_reads
|
||||
forbidden_patterns:
|
||||
- Filament::getTenant()
|
||||
- Tenant::current()
|
||||
enforcement_target:
|
||||
- app/Filament/Pages
|
||||
- app/Filament/Resources
|
||||
- app/Filament/Widgets
|
||||
allowed_exceptions:
|
||||
- app/Filament/Pages/ChooseTenant.php
|
||||
- app/Http/Controllers/SelectTenantController.php
|
||||
- app/Support/Middleware/EnsureFilamentTenantSelected.php
|
||||
- any_explicitly_documented_tenant_panel_native_file_in_the_guard_manifest
|
||||
186
specs/136-admin-canonical-tenant/data-model.md
Normal file
186
specs/136-admin-canonical-tenant/data-model.md
Normal file
@ -0,0 +1,186 @@
|
||||
# Data Model: Spec 136 Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new database tables or persisted business objects. Its data model is request-time and behavioral: it formalizes how surface classification, panel mode, canonical admin tenant context, session-backed filter state, and sensitive actions combine into one deterministic tenant outcome.
|
||||
|
||||
## Entity: Surface Inventory Entry
|
||||
|
||||
**Purpose**: Represents one admin-visible or admin-reachable surface in the rollout manifest.
|
||||
|
||||
**Fields**:
|
||||
- `surface_name`
|
||||
- `class_name`
|
||||
- `surface_kind` enum: `resource`, `page`, `widget`, `shared_helper`, `guarded_file`
|
||||
- `classification` enum: `type_a`, `type_b`, `type_c`
|
||||
- `visibility_mode` enum: `admin_visible`, `shared_code_path`, `tenant_panel_only_but_reviewed`
|
||||
- `has_persisted_tenant_filters` boolean
|
||||
- `has_global_search` boolean
|
||||
- `has_sensitive_actions` boolean
|
||||
- `guard_required` boolean
|
||||
|
||||
**Relationships**:
|
||||
- may reference one resource, page, or widget class
|
||||
- may depend on one or more support-layer resolver or filter-state helpers
|
||||
|
||||
**Validation rules**:
|
||||
- Every rollout surface must have exactly one classification.
|
||||
- Type A and Type B surfaces must be guard-covered unless explicitly documented as an exception.
|
||||
- Type C surfaces must not acquire hidden tenant enforcement in read or write paths.
|
||||
|
||||
## Entity: Canonical Admin Tenant Context
|
||||
|
||||
**Purpose**: Represents the single tenant context used by workspace-admin tenant-sensitive flows.
|
||||
|
||||
**Source fields**:
|
||||
- `workspace_id`
|
||||
- `panel_id`
|
||||
- `route_tenant_id` nullable
|
||||
- `filament_tenant_id` nullable
|
||||
- `remembered_tenant_id` nullable
|
||||
- `resolved_tenant_id` nullable
|
||||
- `resolution_source` enum: `route`, `filament`, `remembered`, `none`
|
||||
- `is_entitled` boolean
|
||||
|
||||
**Relationships**:
|
||||
- belongs to one current workspace
|
||||
- may resolve to one entitled tenant
|
||||
- drives header context, queries, filter defaults, widgets, links, and sensitive actions for Type A and Type B admin surfaces
|
||||
|
||||
**Validation rules**:
|
||||
- `resolved_tenant_id` must be null when no entitled tenant exists.
|
||||
- The same resolved tenant must drive every tenant-sensitive element of the same admin surface.
|
||||
- When the panel is admin, raw panel-native tenant reads are not valid substitutes for this entity.
|
||||
|
||||
## Entity: Panel Resolver Contract
|
||||
|
||||
**Purpose**: Encodes which resolver is allowed in a given panel mode.
|
||||
|
||||
**Fields**:
|
||||
- `panel_id`
|
||||
- `allowed_source` enum: `operate_hub_shell`, `filament_tenant`, `none`
|
||||
- `fallback_behavior` enum: `remembered_allowed`, `no_fallback`, `safe_none`
|
||||
- `applies_to` enum: `admin`, `tenant`, `shared_surface`
|
||||
|
||||
**Validation rules**:
|
||||
- Admin panel uses `operate_hub_shell`.
|
||||
- Tenant panel uses `filament_tenant`.
|
||||
- Shared surfaces must branch by current panel rather than blending both rules.
|
||||
|
||||
## Entity: Persisted Tenant Filter Session State
|
||||
|
||||
**Purpose**: Represents session-backed filter state whose validity depends on the current canonical admin tenant.
|
||||
|
||||
**Fields**:
|
||||
- `filters_session_key`
|
||||
- `tenant_sensitive_filters` array
|
||||
- `previous_resolved_tenant_id` nullable
|
||||
- `current_resolved_tenant_id` nullable
|
||||
- `resolution_action` enum: `apply`, `reseed`, `clear`
|
||||
- `has_invalid_values` boolean
|
||||
|
||||
**Relationships**:
|
||||
- belongs to one table or page surface
|
||||
- depends on one `Canonical Admin Tenant Context`
|
||||
|
||||
**Validation rules**:
|
||||
- Tenant-sensitive filter values must be cleared or reseeded when resolved tenant changes.
|
||||
- If `current_resolved_tenant_id` is null, tenant-specific filter state must not remain active.
|
||||
- Workspace-wide Type B surfaces may preserve non-tenant filters while clearing or reseeding tenant-specific ones.
|
||||
|
||||
## Entity: Sensitive Action Scope Contract
|
||||
|
||||
**Purpose**: Represents the tenant parity requirement between visible surface context and a sensitive action target.
|
||||
|
||||
**Fields**:
|
||||
- `surface_name`
|
||||
- `action_name`
|
||||
- `visible_tenant_id` nullable
|
||||
- `query_tenant_id` nullable
|
||||
- `action_tenant_id` nullable
|
||||
- `authorization_outcome` enum: `ok`, `not_found`, `forbidden`
|
||||
- `confirmation_required` boolean
|
||||
|
||||
**Relationships**:
|
||||
- belongs to one rollout surface
|
||||
- depends on one resolved tenant context and one authorization decision
|
||||
|
||||
**Validation rules**:
|
||||
- `action_tenant_id` must equal `visible_tenant_id` for Type A surfaces.
|
||||
- Out-of-scope or missing tenant access must remain `not_found` when membership is not established.
|
||||
- Existing destructive-like actions must still require confirmation and server-side authorization.
|
||||
|
||||
## Entity: Search and Record Resolution Contract
|
||||
|
||||
**Purpose**: Represents list, detail, deep-link, and global-search parity for tenant-sensitive resources.
|
||||
|
||||
**Fields**:
|
||||
- `surface_name`
|
||||
- `access_path` enum: `list`, `detail`, `direct_url`, `deep_link`, `global_search`
|
||||
- `panel_mode` enum: `admin`, `tenant`
|
||||
- `resolved_tenant_id` nullable
|
||||
- `record_tenant_id` nullable
|
||||
- `parity_outcome` enum: `aligned`, `disabled`, `not_found`, `forbidden`
|
||||
|
||||
**Validation rules**:
|
||||
- Detail and direct access must never be broader than list scope.
|
||||
- Global search must either match list/detail parity or be disabled.
|
||||
- Admin no-context behavior must be explicit and deterministic per surface class.
|
||||
|
||||
## Entity: Guard Coverage Entry
|
||||
|
||||
**Purpose**: Documents whether a file is enforced by the admin tenant resolver guard or allowed as an exception.
|
||||
|
||||
**Fields**:
|
||||
- `relative_path`
|
||||
- `surface_name`
|
||||
- `guard_status` enum: `guarded`, `exception`
|
||||
- `exception_reason` nullable
|
||||
- `review_owner`
|
||||
|
||||
**Validation rules**:
|
||||
- Exceptions must be explicit and stable.
|
||||
- Admin-only files are not valid exceptions for raw `Filament::getTenant()` or `Tenant::current()` reads.
|
||||
- Guard output must clearly distinguish true violations from approved tenant-panel-native usage.
|
||||
|
||||
## State Transitions
|
||||
|
||||
### Canonical admin tenant state
|
||||
|
||||
1. `none`
|
||||
- No entitled route, Filament, or remembered tenant is available.
|
||||
- Outcome: safe no-tenant-selected behavior by surface type.
|
||||
|
||||
2. `remembered`
|
||||
- Only remembered tenant is valid and entitled.
|
||||
- Outcome: remembered tenant becomes the canonical admin tenant for the full request.
|
||||
|
||||
3. `filament`
|
||||
- A Filament tenant is valid and entitled.
|
||||
- Outcome: Filament tenant becomes the canonical admin tenant for the full request.
|
||||
|
||||
4. `route`
|
||||
- A route tenant parameter is present and entitled for a tenant-panel or shared route.
|
||||
- Outcome: route tenant governs the request where panel-native semantics apply.
|
||||
|
||||
### Persisted filter synchronization state
|
||||
|
||||
1. `unchanged`
|
||||
- Previous and current resolved tenant IDs match.
|
||||
- Outcome: persisted tenant filter values may remain.
|
||||
|
||||
2. `tenant_switched`
|
||||
- Previous and current resolved tenant IDs differ.
|
||||
- Outcome: tenant-sensitive filter values are cleared or reseeded.
|
||||
|
||||
3. `tenant_removed`
|
||||
- Current resolved tenant ID becomes null.
|
||||
- Outcome: tenant-sensitive filter values are cleared.
|
||||
|
||||
## Invariants
|
||||
|
||||
- One workspace-admin surface uses one tenant source for every tenant-sensitive element.
|
||||
- Shared resources must branch by panel mode instead of mixing admin and tenant rules inside one execution path.
|
||||
- Persisted tenant filter state is never trusted without synchronization.
|
||||
- Search, list, detail, and deep-link behavior cannot exceed the same tenant boundary.
|
||||
- Existing destructive or governance-sensitive actions keep their confirmations and authorization while gaining tenant-target parity.
|
||||
236
specs/136-admin-canonical-tenant/plan.md
Normal file
236
specs/136-admin-canonical-tenant/plan.md
Normal file
@ -0,0 +1,236 @@
|
||||
# Implementation Plan: Spec 136 Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
**Branch**: `136-admin-canonical-tenant` | **Date**: 2026-03-11 | **Spec**: `specs/136-admin-canonical-tenant/spec.md`
|
||||
**Spec (absolute)**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/136-admin-canonical-tenant/spec.md`
|
||||
**Input**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/136-admin-canonical-tenant/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Complete the rollout of the canonical admin tenant rule established in Spec 135 across all remaining admin-visible and admin-reachable shared tenant-sensitive surfaces.
|
||||
|
||||
Workspace-admin flows under `/admin/...` will use `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the single tenant source for header context, queries, filters, widgets, links, and sensitive actions. Workspace-admin navigation will stay workspace-only except for baseline assets, so tenant-sensitive entry points move to the tenant panel under `/admin/t/{tenant}/...`. Admin surfaces with persisted tenant-related filters will standardize on `CanonicalAdminTenantFilterState`. Tenant-panel flows under `/admin/t/{tenant}/...` will keep panel-native `Filament::getTenant()` semantics. Global-search parity will be preserved through the existing admin-aware scoping pattern in `ScopesGlobalSearchToTenant` or explicit disablement where parity cannot be guaranteed cheaply.
|
||||
|
||||
Implementation is organized into three rollout waves:
|
||||
1. high-risk tenant-sensitive resources with sensitive actions or strong drift risk,
|
||||
2. governance, restore, and inventory alignment surfaces,
|
||||
3. workspace-wide tenant-default pages, diagnostics, widget alignment, and full guard expansion.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 on Laravel 12
|
||||
**Primary Dependencies**: Filament v5, Livewire v4, Pest v4, Laravel Sail
|
||||
**Storage**: PostgreSQL application database and session-backed Filament table state
|
||||
**Testing**: Pest feature and unit tests via `vendor/bin/sail artisan test --compact`
|
||||
**Target Platform**: Web application with Filament admin and tenant panels
|
||||
**Project Type**: Laravel monolith with Filament resources, pages, widgets, policies, support-layer helpers, and Pest guard tests
|
||||
**Performance Goals**: Keep monitoring and admin renders DB-only, keep tenant resolution deterministic per request, and avoid broader-than-visible scopes or stale session-driven tenant drift
|
||||
**Constraints**:
|
||||
- No dependency changes.
|
||||
- No new Graph calls, queued workflows, or scheduled workflows.
|
||||
- No panel-provider or routing redesign; provider registration remains in `bootstrap/providers.php` and panel routing remains intact.
|
||||
- No universal resolver abstraction that erases the distinction between workspace-admin and tenant-panel semantics.
|
||||
- Existing destructive and governance-sensitive actions must keep their current confirmation, authorization, and audit behavior while gaining tenant-target parity.
|
||||
- No new assets are introduced; deployment remains unchanged and does not add `filament:assets` work for this feature.
|
||||
**Scale/Scope**: Rollout across the canonical admin shell helper, session filter synchronization helper, admin-aware global-search behavior, 10+ affected resources/pages/widgets, the admin panel provider registration map, one architectural guard suite, and focused regression coverage for tenant switching, stale filters, search parity, and sensitive actions
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- **Inventory-first / snapshots**: PASS — the feature changes request-time resolution and session-backed filter state only; inventory, backup, and snapshot storage remain unchanged.
|
||||
- **Read/write separation**: PASS — no new write workflow is introduced. Existing destructive and governance-sensitive actions remain existing actions and are only hardened to keep visible tenant and execution tenant aligned.
|
||||
- **Graph contract path**: PASS — no Graph calls or contract-registry changes are required.
|
||||
- **Deterministic capabilities**: PASS — no capability registry changes; existing Gates, policies, and capability helpers remain authoritative.
|
||||
- **RBAC-UX planes**: PASS — `/admin/...` and `/admin/t/{tenant}/...` stay explicitly separate. Admin canonical tenant logic applies only in the admin path, tenant-panel-native logic applies only in tenant routes.
|
||||
- **Workspace isolation**: PASS — workspace-admin routes remain workspace-scoped and tenant-safe, with deny-as-not-found preserved for non-members or out-of-scope access.
|
||||
- **Tenant isolation**: PASS — Type A and Type B rollout surfaces will keep tenant entitlement checks aligned across query, widgets, links, detail access, and actions.
|
||||
- **Global search safety**: PASS — any rollout resource that remains globally searchable must either use admin-safe scoped search with a View page present or disable global search where parity cannot be guaranteed.
|
||||
- **Run observability**: PASS — no new `OperationRun` types or lifecycle behavior. Monitoring remains DB-only at render time.
|
||||
- **Ops-UX lifecycle / summary counts / notifications**: PASS — unchanged.
|
||||
- **BADGE-001**: PASS — badge meanings remain centralized; only the tenant context feeding those views is normalized.
|
||||
- **UI-NAMING-001**: PASS — existing operator-facing phrases such as tenant labels, safe-state messages, and filter labels remain domain-first and implementation-neutral.
|
||||
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — affected resources and pages mostly keep their current action surfaces. The rollout verifies tenant-target parity for existing actions instead of changing action inventory.
|
||||
- **Filament UX-001**: PASS WITH EXEMPTION — no layout redesign is needed; the feature only tightens tenant semantics and explicit safe-state behavior.
|
||||
- **Livewire v4.0+ compliance**: PASS — all affected Filament screens remain on the supported Filament v5 / Livewire v4 stack.
|
||||
- **Provider registration location**: PASS — no provider changes are required; Laravel 12 provider registration remains in `bootstrap/providers.php`.
|
||||
- **Global-search hard rule**: PASS — `PolicyResource`, `PolicyVersionResource`, and `EntraGroupResource` already have View pages where search parity matters; rollout design requires those resources to retain View-page-backed parity or disable admin-path global search.
|
||||
- **Destructive action safety**: PASS — `BackupScheduleResource`, `BackupSetResource`, `RestoreRunResource`, `FindingResource`, and `PolicyVersionResource` retain existing destructive or governance-sensitive actions; rollout scope is to preserve confirmation + authorization while guaranteeing the action tenant matches the visible tenant.
|
||||
- **Asset strategy**: PASS — no new panel assets, no `FilamentAsset::register()`, and no deployment change beyond existing processes.
|
||||
|
||||
## Phase 0 — Research Summary
|
||||
|
||||
Research findings are recorded in `specs/136-admin-canonical-tenant/research.md`.
|
||||
|
||||
Key decisions:
|
||||
- Treat admin panel provider registration plus direct admin page registration as the source of truth for which surfaces are truly admin-visible, while still reviewing shared resources whose code can run in both panels.
|
||||
- Keep `OperateHubShell::activeEntitledTenant()` as the only canonical admin tenant resolver instead of inventing a second abstraction.
|
||||
- Standardize persisted tenant-filter synchronization through `CanonicalAdminTenantFilterState` wherever `persistFiltersInSession()` and tenant-related filters coexist.
|
||||
- Reuse the existing `ScopesGlobalSearchToTenant` admin-aware search pattern for searchable rollout resources, and disable admin-path search if parity is not cheap or safe.
|
||||
- Treat `AlertDeliveryResource` and `AuditLog` as reference patterns for workspace-wide datasets with canonical tenant-default behavior.
|
||||
- Expand `AdminTenantResolverGuardTest` from a narrow allowlist to the full rollout surface set with explicit, documented exceptions for tenant-panel-native files.
|
||||
|
||||
## Phase 1 — Design & Contracts
|
||||
|
||||
### Data Model
|
||||
|
||||
Design details are recorded in `specs/136-admin-canonical-tenant/data-model.md`.
|
||||
|
||||
Key design points:
|
||||
- No schema changes are required.
|
||||
- The feature models request-time surface classification, tenant-resolution state, persisted filter state, sensitive action parity, and guard coverage as behavioral entities.
|
||||
- Shared resources are treated as panel-aware execution surfaces rather than purely admin or purely tenant objects.
|
||||
- Persisted filter state is never trusted without synchronization against the current canonical admin tenant.
|
||||
|
||||
### Contracts
|
||||
|
||||
Internal behavior contracts are recorded in `specs/136-admin-canonical-tenant/contracts/admin-tenant-resolution-rollout.yaml`.
|
||||
|
||||
Contract scope:
|
||||
- workspace-admin canonical tenant rule versus tenant-panel-native rule
|
||||
- Type A, Type B, and Type C surface classifications
|
||||
- rollout expectations for the named resources, pages, and widgets
|
||||
- search parity rules and no-context behavior
|
||||
- guard coverage and exception inventory expectations
|
||||
|
||||
### Quickstart
|
||||
|
||||
Implementation and verification steps are recorded in `specs/136-admin-canonical-tenant/quickstart.md`.
|
||||
|
||||
### Post-Design Constitution Re-check
|
||||
|
||||
- **Inventory-first / snapshots**: PASS — unchanged.
|
||||
- **Read/write separation**: PASS — the design only normalizes read, filter, link, and action-target semantics.
|
||||
- **Graph contract path**: PASS — still no Graph activity.
|
||||
- **RBAC-UX**: PASS — design keeps 404 for non-members or out-of-scope tenant access and 403 only after scope membership is established for capability-gated mutations.
|
||||
- **Workspace isolation**: PASS — admin routes remain workspace-scoped even when tenant is a shell concept rather than a route parameter.
|
||||
- **Tenant isolation**: PASS — list, detail, widget, search, and action paths are designed to use one tenant rule per surface.
|
||||
- **Global search safety**: PASS — the design explicitly requires admin-safe search parity for searchable resources or explicit disablement.
|
||||
- **Run observability / Ops-UX lifecycle**: PASS — unchanged.
|
||||
- **BADGE-001 / UI-NAMING-001**: PASS — centralized badge rendering and product wording remain intact.
|
||||
- **Filament Action Surface Contract**: PASS WITH EXEMPTION — no new destructive actions, no new action-surface inventory.
|
||||
- **Livewire v4.0+ compliance**: PASS — unchanged Filament v5 / Livewire v4 stack.
|
||||
- **Provider registration location**: PASS — no panel or provider changes; `bootstrap/providers.php` remains correct.
|
||||
- **Asset strategy**: PASS — still no asset registration or deployment delta.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/136-admin-canonical-tenant/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── admin-tenant-resolution-rollout.yaml
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Providers/Filament/
|
||||
│ └── AdminPanelProvider.php
|
||||
├── Support/
|
||||
│ ├── OperateHub/
|
||||
│ │ └── OperateHubShell.php
|
||||
│ └── Filament/
|
||||
│ ├── CanonicalAdminTenantFilterState.php
|
||||
│ └── ScopesGlobalSearchToTenant.php
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ ├── Monitoring/
|
||||
│ │ │ └── AuditLog.php
|
||||
│ │ ├── BaselineCompareLanding.php
|
||||
│ │ ├── InventoryCoverage.php
|
||||
│ │ └── TenantDiagnostics.php
|
||||
│ ├── Resources/
|
||||
│ │ ├── PolicyResource.php
|
||||
│ │ ├── BackupScheduleResource.php
|
||||
│ │ ├── BackupSetResource.php
|
||||
│ │ ├── FindingResource.php
|
||||
│ │ ├── RestoreRunResource.php
|
||||
│ │ ├── InventoryItemResource.php
|
||||
│ │ ├── PolicyVersionResource.php
|
||||
│ │ ├── ProviderConnectionResource.php
|
||||
│ │ ├── EntraGroupResource.php
|
||||
│ │ └── AlertDeliveryResource.php
|
||||
│ └── Widgets/
|
||||
│ ├── Inventory/
|
||||
│ │ └── InventoryKpiHeader.php
|
||||
│ └── Operations/
|
||||
├── Models/
|
||||
└── Policies/
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Guards/
|
||||
│ ├── Filament/
|
||||
│ ├── Monitoring/
|
||||
│ └── Spec085/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Keep implementation inside the existing Laravel / Filament monolith. Reuse `OperateHubShell`, `CanonicalAdminTenantFilterState`, and the existing admin-aware search trait instead of adding new infrastructure. Harden affected resources, pages, and widgets in place, then extend the current Pest guard and feature coverage.
|
||||
|
||||
## Phase 2 — Implementation Planning
|
||||
|
||||
Implementation should be delivered in the following slices so `/speckit.tasks` can break work cleanly.
|
||||
|
||||
1. **Freeze the final surface inventory and classifications**
|
||||
- Use `AdminPanelProvider`, direct admin page registration, and shared resource reachability to finalize the rollout manifest.
|
||||
- Classify each target as Type A hard tenant-sensitive, Type B workspace-wide with tenant-default, or Type C workspace-only.
|
||||
- Keep tenant-only resources in the rollout only where shared code, admin deep links, cross-resource URLs, or dual-panel discovery can still create admin-path drift.
|
||||
|
||||
2. **Standardize support-layer resolver usage**
|
||||
- Keep `OperateHubShell::activeEntitledTenant(Request)` as the only public canonical admin tenant resolver.
|
||||
- Avoid introducing a second public resolver abstraction unless it is a thin, internal delegation wrapper for readability only.
|
||||
- Audit direct admin-path usages of `Tenant::current()` and `Filament::getTenant()` across the rollout set.
|
||||
|
||||
3. **Roll out persisted filter-state hardening**
|
||||
- Apply `CanonicalAdminTenantFilterState::sync()` to every affected admin surface that combines `persistFiltersInSession()` with tenant-related filters or tenant-default behavior.
|
||||
- Standardize stale-session behavior so old tenant state is cleared or reseeded before the surface renders.
|
||||
- Reuse `AlertDeliveryResource` and `AuditLog` as known-good reference patterns.
|
||||
|
||||
4. **Wave 1: high-risk tenant-sensitive resources**
|
||||
- Harden `PolicyResource`, `BackupScheduleResource`, `BackupSetResource`, and `FindingResource` so query, detail, filters, links, and sensitive actions align to the same tenant.
|
||||
- Verify destructive or governance-sensitive actions retain confirmation and server-side authorization while gaining tenant-target parity.
|
||||
|
||||
5. **Wave 2: governance, restore, and inventory alignment**
|
||||
- Harden `BaselineCompareLanding`, `RestoreRunResource`, `InventoryItemResource`, and `PolicyVersionResource`.
|
||||
- Treat `InventoryCoverage` and `InventoryKpiHeader` as one unit so page and widget cannot diverge.
|
||||
- Review `RestoreRunResource` and other shared tenant resources as panel-aware code paths even where admin navigation is intentionally absent.
|
||||
|
||||
6. **Wave 3: workspace-wide tenant-default, diagnostics, and search parity**
|
||||
- Harden `ProviderConnectionResource`, `TenantDiagnostics`, `AuditLog`, and `EntraGroupResource` follow-up behavior.
|
||||
- Keep workspace-wide datasets workspace-wide while synchronizing tenant-default labels, filters, deep links, and persisted state.
|
||||
- Preserve or explicitly disable admin-path global search where parity is not safe.
|
||||
|
||||
7. **Expand the guardrail and developer guidance**
|
||||
- Extend `AdminTenantResolverGuardTest` to the full rollout surface set.
|
||||
- Maintain a narrow, explicit exception inventory for tenant-panel-native files and other approved panel-native surfaces.
|
||||
- Update short developer guidance so future admin surfaces reuse the canonical rule and synchronized filter-state pattern.
|
||||
|
||||
8. **Finish with regression coverage and manual tenant-switch verification**
|
||||
- Add direct tests for `CanonicalAdminTenantFilterState`.
|
||||
- Add focused feature tests for wrong-tenant drift, stale filters, shared-surface panel behavior, search parity, and wrong-tenant sensitive-action prevention.
|
||||
- Record a manual tenant-switch check per rollout wave for representative Type A and Type B surfaces.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Extend `tests/Feature/Guards/AdminTenantResolverGuardTest.php` to cover the full rollout manifest and exceptions.
|
||||
- Add direct unit or feature coverage for `CanonicalAdminTenantFilterState` behavior on tenant change, stale state, and invalid persisted state.
|
||||
- Add focused feature coverage for:
|
||||
- `PolicyResource`, `BackupScheduleResource`, `BackupSetResource`, and `FindingResource` tenant parity across list/detail/action paths,
|
||||
- `BaselineCompareLanding`, `InventoryCoverage`, and `InventoryKpiHeader` page-widget alignment,
|
||||
- `RestoreRunResource`, `InventoryItemResource`, and `PolicyVersionResource` shared resource parity across panel modes,
|
||||
- `ProviderConnectionResource`, `AuditLog`, and `EntraGroupResource` workspace-wide tenant-default behavior and search or deep-link safety,
|
||||
- representative 404 vs 403 authorization semantics for admin-path tenant-sensitive access.
|
||||
- Reuse existing Pest helpers, factories, and workspace or tenant session setup; avoid bespoke harnesses.
|
||||
- Run the minimum affected suite with Sail during implementation, then finish with `vendor/bin/sail bin pint --dirty --format agent`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations or complexity exemptions are required for this plan.
|
||||
93
specs/136-admin-canonical-tenant/quickstart.md
Normal file
93
specs/136-admin-canonical-tenant/quickstart.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Quickstart: Spec 136 Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
## Goal
|
||||
|
||||
Finish the canonical tenant rollout for workspace-admin flows, preserve tenant-panel-native behavior, and leave the feature ready for small, test-driven implementation slices.
|
||||
|
||||
## Expected implementation slices
|
||||
|
||||
1. Freeze the final rollout manifest and classify surfaces as Type A, Type B, or Type C.
|
||||
2. Standardize canonical admin resolver usage through `OperateHubShell`.
|
||||
3. Roll out `CanonicalAdminTenantFilterState` across persisted tenant-filter surfaces.
|
||||
4. Deliver Wave 1 high-risk tenant-sensitive resource fixes.
|
||||
5. Deliver Wave 2 governance, restore, and inventory alignment.
|
||||
6. Deliver Wave 3 workspace-wide tenant-default, diagnostics, search parity, and guard expansion.
|
||||
7. Finish with direct filter-state tests, regression coverage, and manual tenant-switch verification.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
1. Update the rollout manifest and guard file list first.
|
||||
2. Normalize support-layer resolver and filter-state usage before touching individual resources.
|
||||
3. Fix one high-risk resource family at a time and run focused tests after each slice.
|
||||
4. Align widget and page pairs together instead of fixing them independently.
|
||||
5. Finish with global-search parity, guard expansion, and developer guidance.
|
||||
|
||||
## Focused verification commands
|
||||
|
||||
Run all commands from the repository root.
|
||||
|
||||
```bash
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Guards/AdminTenantResolverGuardTest.php
|
||||
vendor/bin/sail artisan test --compact tests/Feature/Filament/TableStatePersistenceTest.php
|
||||
vendor/bin/sail artisan test --compact --filter='CanonicalAdminTenantFilterState|PolicyResource|BackupSchedule|BackupSet|FindingResource|BaselineCompareLanding|RestoreRunResource|InventoryItemResource|PolicyVersionResource|ProviderConnectionResource|TenantDiagnostics|InventoryCoverage|InventoryKpiHeader|AuditLog|EntraGroup'
|
||||
vendor/bin/sail bin pint --dirty --format agent
|
||||
```
|
||||
|
||||
## Manual tenant-switch verification per wave
|
||||
|
||||
### Wave 1
|
||||
|
||||
- Select Tenant A in the workspace-admin shell.
|
||||
- Visit representative Type A surfaces in Wave 1 and confirm header context, table/query results, and sensitive actions align.
|
||||
- Switch to Tenant B and confirm stale tenant filters are cleared or reseeded before data renders.
|
||||
|
||||
### Wave 2
|
||||
|
||||
- Re-check inventory and governance pages plus their embedded widgets.
|
||||
- Confirm widget KPIs, page summaries, record links, and detail pages remain aligned after a tenant switch.
|
||||
- Validate that shared resources preserve tenant-panel behavior under `/admin/t/{tenant}/...`.
|
||||
|
||||
### Wave 3
|
||||
|
||||
- Re-check workspace-wide datasets with tenant-default behavior.
|
||||
- Confirm base dataset stays workspace-wide while header tenant, filter defaults, deep links, and persisted filters update to the new canonical admin tenant.
|
||||
- Verify admin search and direct links remain tenant-safe or explicitly disabled where planned.
|
||||
|
||||
## Scenario matrix to cover in tests
|
||||
|
||||
### Type A hard tenant-sensitive surfaces
|
||||
|
||||
- header tenant, query tenant, widget tenant, and action tenant all match
|
||||
- no valid canonical tenant produces the defined safe no-tenant-selected behavior
|
||||
- wrong-tenant sensitive actions are blocked safely
|
||||
|
||||
### Type B workspace-wide tenant-default surfaces
|
||||
|
||||
- base dataset remains workspace-wide
|
||||
- tenant-default header, filter state, and deep links align to the canonical admin tenant
|
||||
- stale persisted tenant filters are cleared or reseeded after a tenant switch
|
||||
|
||||
### Shared panel-aware resources
|
||||
|
||||
- admin panel uses the canonical admin tenant rule
|
||||
- tenant panel preserves panel-native `Filament::getTenant()` behavior
|
||||
- direct URLs and search paths do not bypass list parity
|
||||
|
||||
### Guardrail
|
||||
|
||||
- a new raw `Filament::getTenant()` or `Tenant::current()` read in a guarded admin file fails CI
|
||||
- approved tenant-panel-native exception files remain explicitly allowed
|
||||
|
||||
## Future-surface developer rule
|
||||
|
||||
- New `/admin/...` tenant-sensitive surfaces must resolve tenant context through `OperateHubShell` or `app/Filament/Concerns/ResolvesPanelTenantContext.php`.
|
||||
- Shared resources must keep tenant-panel behavior panel-native and must not let remembered admin context override `/admin/t/{tenant}/...`.
|
||||
- Any list surface that uses `persistFiltersInSession()` and tenant-derived defaults must call `CanonicalAdminTenantFilterState::sync()` before render.
|
||||
- If global search cannot be kept list/detail-safe in admin context, disable it explicitly instead of relying on implicit scoping.
|
||||
|
||||
## Out of scope during implementation
|
||||
|
||||
- dependency or asset changes
|
||||
- panel bootstrap or routing redesign
|
||||
- unrelated CLI, queue, or scheduled-context resolver refactors
|
||||
- broad stylistic rewrites of tenant-panel resources that are already correct
|
||||
134
specs/136-admin-canonical-tenant/research.md
Normal file
134
specs/136-admin-canonical-tenant/research.md
Normal file
@ -0,0 +1,134 @@
|
||||
# Research: Spec 136 Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
## Decision 1: Use admin registration plus reachability to freeze the rollout manifest
|
||||
|
||||
**Decision**: Treat admin panel provider registration and direct admin page registration as the primary source of truth for admin-visible surfaces, but keep shared resources in scope when admin paths, deep links, embedded widgets, or dual-panel discovery can still execute their code in admin context.
|
||||
|
||||
**Rationale**:
|
||||
- The codebase already exposes some resources directly in `AdminPanelProvider`, while others are tenant-scoped but still referenced from admin pages or shared navigation helpers.
|
||||
- The spec cares about admin tenant drift, not only sidebar registration.
|
||||
- Using reachability avoids missing shared resources that can still execute admin-path logic without appearing in admin navigation.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Limit the rollout strictly to resources registered in `AdminPanelProvider`: rejected because shared resources and pages can still create admin-path drift through direct URLs, deep links, or widgets.
|
||||
- Treat every tenant resource as automatically in scope: rejected because some tenant-only surfaces have no admin-path behavior to harden.
|
||||
|
||||
## Decision 2: Keep `OperateHubShell` as the canonical admin resolver
|
||||
|
||||
**Decision**: Continue using `OperateHubShell::activeEntitledTenant(Request $request): ?Tenant` as the only canonical tenant resolver for workspace-admin tenant-sensitive behavior.
|
||||
|
||||
**Rationale**:
|
||||
- The support layer already encodes the intended admin priority order and entitlement checks.
|
||||
- Spec 135 established this as the correct canonical pattern.
|
||||
- A second public resolver would reintroduce the same drift risk under a new name.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Introduce a new universal resolver service: rejected because the feature must preserve the admin-versus-tenant-panel distinction.
|
||||
- Resolve tenant state separately in each resource or page: rejected because the defect class is inconsistent surface-level resolution.
|
||||
|
||||
## Decision 3: Persisted tenant-filter synchronization should standardize on `CanonicalAdminTenantFilterState`
|
||||
|
||||
**Decision**: Apply `CanonicalAdminTenantFilterState::sync()` as the standard synchronization primitive for admin surfaces that persist tenant-related filters in session.
|
||||
|
||||
**Rationale**:
|
||||
- The helper already exists and clears or reseeds persisted tenant filter state based on the current canonical admin tenant.
|
||||
- Filament v5 explicitly supports `persistFiltersInSession()`, so session-backed filter drift must be handled deliberately.
|
||||
- `AlertDeliveryResource` and `AuditLog` already show the intended pattern.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Disable filter persistence on affected surfaces: rejected because persistence is an existing UX choice outside this feature.
|
||||
- Hand-roll per-page session fixes: rejected because inconsistent local fixes are exactly what the rollout is trying to eliminate.
|
||||
|
||||
## Decision 4: Search parity should reuse the admin-aware global-search trait or fall back to disablement
|
||||
|
||||
**Decision**: Reuse `ScopesGlobalSearchToTenant` for rollout resources that stay globally searchable in admin context, and disable admin-path search when parity cannot be guaranteed cheaply.
|
||||
|
||||
**Rationale**:
|
||||
- Filament v5 requires a View or Edit page for global-searchable resources.
|
||||
- The existing trait already distinguishes admin-panel and tenant-panel behavior by using `OperateHubShell` in the admin panel and `Filament::getTenant()` elsewhere.
|
||||
- The constitution requires non-member-safe, tenant-safe global search.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Leave search behavior implicit: rejected because search can bypass list-level scoping and create the same drift bug.
|
||||
- Build a separate search-only resolver: rejected because the existing trait already captures the right panel split.
|
||||
|
||||
## Decision 5: Use `AlertDeliveryResource` and `AuditLog` as Type B reference patterns
|
||||
|
||||
**Decision**: Treat `AlertDeliveryResource` and `AuditLog` as the reference patterns for workspace-wide datasets with canonical tenant-default behavior.
|
||||
|
||||
**Rationale**:
|
||||
- Both surfaces already combine workspace-wide data with synchronized tenant-default or tenant-filter behavior.
|
||||
- Both already use `OperateHubShell` and `CanonicalAdminTenantFilterState` rather than raw panel-native tenant reads.
|
||||
- They are good comparison points for `ProviderConnectionResource`, `AuditLog` guard expansion, and any `EntraGroupResource` workspace-wide follow-up behavior.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Use a pure tenant-scoped resource as the reference pattern: rejected because Type B surfaces have different requirements from Type A surfaces.
|
||||
- Build a new shared reference helper first: rejected because the current reference patterns already exist in production code.
|
||||
|
||||
## Decision 6: Treat Inventory page and widget alignment as one rollout slice
|
||||
|
||||
**Decision**: Handle `InventoryCoverage` and `InventoryKpiHeader` together so the page shell and embedded KPI widget use the same tenant rule.
|
||||
|
||||
**Rationale**:
|
||||
- The spec names `InventoryKpiHeader` as an explicit correction case.
|
||||
- Widget drift is one of the exact defect classes the feature is intended to eliminate.
|
||||
- Page-level tenant context is not trustworthy unless the widget and its linked drill-down behavior follow the same source.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Fix the page only: rejected because widget-only drift would remain.
|
||||
- Leave the widget workspace-wide while the page is tenant-sensitive: rejected because it violates one-surface, one-source semantics.
|
||||
|
||||
## Decision 7: Shared tenant resources require panel-aware hardening even when admin navigation is absent
|
||||
|
||||
**Decision**: Keep shared tenant-sensitive resources such as `RestoreRunResource`, `BackupSetResource`, and other rollout targets in scope where their code can still be executed by admin paths, deep links, or shared helper logic, even if they do not register admin navigation.
|
||||
|
||||
**Rationale**:
|
||||
- The spec explicitly calls out panel-safe behavior and shared-code review.
|
||||
- Some resources are tenant-scoped in navigation yet still participate in admin-driven links, comparisons, or operational workflows.
|
||||
- Drift can still occur inside shared methods, action URLs, and record-resolution logic.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Exclude tenant-routed resources from the rollout entirely: rejected because shared code can still carry unsafe resolver assumptions.
|
||||
- Force admin registration for all tenant resources: rejected because the feature is about semantics, not information architecture redesign.
|
||||
|
||||
## Decision 8: Surface classification should be explicit and operational
|
||||
|
||||
**Decision**: Freeze the rollout using three operational classes:
|
||||
- Type A hard tenant-sensitive: `PolicyResource`, `BackupScheduleResource`, `BackupSetResource`, `FindingResource`, `BaselineCompareLanding`, `RestoreRunResource`, `InventoryItemResource`, `PolicyVersionResource`, `TenantDiagnostics`, `InventoryCoverage`, `InventoryKpiHeader`
|
||||
- Type B workspace-wide with tenant-default: `ProviderConnectionResource`, `AuditLog`, `EntraGroupResource` follow-up when admin path remains workspace-wide with tenant-default semantics
|
||||
- Type C workspace-only: `AlertRuleResource`, `BaselineProfileResource`, `BaselineSnapshotResource`, `TenantResource`, and other workspace-owned admin surfaces with no tenant-sensitive execution path
|
||||
|
||||
**Rationale**:
|
||||
- The rollout needs a stable manifest for guard coverage and test scope.
|
||||
- The classes match the behavior contract described in the spec.
|
||||
- Explicit classification prevents accidental over-scoping of workspace-wide surfaces.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Keep an informal list only: rejected because guard and task generation need explicit categories.
|
||||
- Add a fourth ambiguous class: rejected because the spec requires ambiguous surfaces to be resolved before implementation.
|
||||
|
||||
## Decision 9: The guardrail should stay as a focused Pest architecture test
|
||||
|
||||
**Decision**: Expand `AdminTenantResolverGuardTest` rather than creating a new lint rule or analysis tool.
|
||||
|
||||
**Rationale**:
|
||||
- The existing guard already scans selected canonical admin files for forbidden raw panel-native tenant reads.
|
||||
- Pest is already the repo standard for architecture and regression guards.
|
||||
- A focused allowlist and exception inventory is simpler to maintain than new tooling.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add a PHPStan or ESLint-style custom rule: rejected because it is heavier than the feature requires.
|
||||
- Rely on code review only: rejected because the feature explicitly asks for regression resistance and CI enforcement.
|
||||
|
||||
## Decision 10: No asset or panel bootstrap changes are needed
|
||||
|
||||
**Decision**: Keep the rollout entirely inside existing Filament resources, pages, widgets, support helpers, and tests with no new assets or panel-provider wiring changes.
|
||||
|
||||
**Rationale**:
|
||||
- The defect is semantic and behavioral, not presentational.
|
||||
- Existing admin and tenant panels already provide the right routing and discovery model.
|
||||
- Avoiding asset and provider changes minimizes regression risk and keeps deployment unchanged.
|
||||
|
||||
**Alternatives considered**:
|
||||
- Add panel-specific assets or hooks to manage tenant state in the browser: rejected because the canonical source is server-side and already exists.
|
||||
- Change panel registration or routing: rejected because the feature explicitly excludes panel redesign.
|
||||
196
specs/136-admin-canonical-tenant/spec.md
Normal file
196
specs/136-admin-canonical-tenant/spec.md
Normal file
@ -0,0 +1,196 @@
|
||||
# Feature Specification: Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
**Feature Branch**: `136-admin-canonical-tenant`
|
||||
**Created**: 2026-03-11
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 136 — Admin Panel Canonical Tenant Resolution Full Rollout"
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: canonical-view
|
||||
- **Primary Routes**:
|
||||
- Workspace-admin tenant-sensitive resources and pages under `/admin/...`
|
||||
- Workspace-admin list, detail, widget, KPI, filter, and deep-link flows that expose tenant-bound semantics under `/admin/...`
|
||||
- Tenant-panel flows under `/admin/t/{tenant}/...` only where shared code must preserve panel-native tenant behavior while admin flows adopt the canonical admin rule
|
||||
- **Data Ownership**:
|
||||
- Workspace-admin shells, navigation, and workspace-owned operational surfaces remain workspace-owned
|
||||
- Workspace-admin navigation exposes only workspace-wide surfaces and workspace-owned baseline assets; tenant-sensitive navigation entry points live under `/admin/t/{tenant}/...`
|
||||
- Tenant-sensitive records shown in admin flows remain unchanged in storage and ownership; this feature only standardizes how their active tenant context is resolved, displayed, filtered, and enforced
|
||||
- No new business domain is added; the feature completes rollout of one canonical tenant-context rule across the named admin surfaces and their guard coverage
|
||||
- **RBAC**:
|
||||
- Workspace membership remains required for workspace-admin access
|
||||
- Tenant entitlement remains required before tenant-sensitive data or actions can be shown or executed in admin flows
|
||||
- Capability checks continue to gate protected actions on the affected resources and pages
|
||||
- Non-members and out-of-scope tenant access remain deny-as-not-found, while entitled users without the required capability remain forbidden on protected actions
|
||||
|
||||
For canonical-view specs, the spec MUST define:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Admin flows that remain workspace-wide but show a tenant-default must seed the default tenant filter, filter labels, header context, deep links, and persisted filter state from the same canonical admin tenant. Hard tenant-sensitive admin flows must either use that same tenant consistently everywhere on the surface or show an explicit no-tenant-selected safe state.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: Every in-scope admin query, filter option set, KPI, widget, record lookup, search result, and sensitive action must validate workspace membership and tenant entitlement against the same canonical tenant context before data is shown or acted on. No direct link, persisted filter, or action target may broaden scope beyond that entitled tenant.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Trust the active tenant everywhere (Priority: P1)
|
||||
|
||||
As an admin operator, I want the visible tenant context, data results, KPIs, and actions on a tenant-sensitive admin surface to all point to the same tenant so that I do not make decisions or changes against the wrong tenant.
|
||||
|
||||
**Why this priority**: This is the core safety and correctness problem. If a surface can drift between header context, list data, and actions, every downstream workflow becomes unreliable.
|
||||
|
||||
**Independent Test**: Can be fully tested by opening each representative tenant-sensitive admin surface after selecting a tenant and verifying that header context, table data, widgets, drill-down links, and sensitive actions all align to the same tenant.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an operator has an active entitled tenant in the workspace-admin shell, **When** they open a hard tenant-sensitive admin surface, **Then** the page header, query results, KPIs, widgets, and links all use that same tenant.
|
||||
2. **Given** a tenant-sensitive admin surface includes a sensitive row action, **When** the operator triggers that action, **Then** the action operates on the same tenant context that the surface visibly represents.
|
||||
3. **Given** no valid entitled tenant is active for a hard tenant-sensitive admin surface, **When** the operator opens that surface, **Then** the surface returns a defined safe no-tenant-selected outcome instead of silently broadening scope.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Switch tenants without stale filters (Priority: P1)
|
||||
|
||||
As an admin operator, I want persisted filters and tenant-default views to update deterministically when I switch tenant context so that I never keep seeing stale data from a previously selected tenant.
|
||||
|
||||
**Why this priority**: Persisted filter drift is one of the known failure modes and can silently produce misleading tables, counts, and follow-on actions.
|
||||
|
||||
**Independent Test**: Can be fully tested by persisting a tenant-related filter for Tenant A, switching to Tenant B, and reloading representative Type A and Type B surfaces to verify that stale filter state is cleared or reseeded consistently.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a tenant-related filter is persisted for Tenant A, **When** the operator switches the canonical admin tenant to Tenant B and reloads the surface, **Then** the stale filter state is cleared or replaced so the table and header match Tenant B.
|
||||
2. **Given** a workspace-wide admin surface uses a tenant-default filter, **When** the operator switches tenant, **Then** the visible default tenant, filter state, and deep links all update to the new canonical admin tenant without changing the base workspace-wide dataset.
|
||||
3. **Given** a persisted tenant-related filter references a tenant the operator no longer has access to, **When** the surface loads, **Then** the invalid persisted filter state is not reused.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Preserve panel-specific tenant rules (Priority: P2)
|
||||
|
||||
As a maintainer, I want admin-panel flows and tenant-panel flows to keep their distinct tenant-resolution rules so that fixing the admin drift bug does not break tenant-panel behavior that already depends on panel-native routing.
|
||||
|
||||
**Why this priority**: The rollout touches shared resources and widgets that can be discovered in more than one panel. Separation between panel rules is required to avoid regressions.
|
||||
|
||||
**Independent Test**: Can be fully tested by exercising representative shared surfaces in both the workspace-admin path and the tenant-panel path and verifying that each path uses its intended tenant rule without cross-contamination.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a shared surface is opened in the workspace-admin panel, **When** it resolves tenant context, **Then** it uses the canonical admin tenant rule.
|
||||
2. **Given** the same or related surface is opened in the tenant panel, **When** it resolves tenant context, **Then** it preserves tenant-panel-native behavior.
|
||||
3. **Given** a workspace-only admin surface has no tenant-sensitive semantics, **When** the page renders, **Then** no artificial tenant enforcement is applied.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Catch regressions before merge (Priority: P3)
|
||||
|
||||
As a maintainer, I want guard coverage and focused regression tests across all relevant admin tenant-sensitive surfaces so that future changes cannot quietly reintroduce mixed tenant sources.
|
||||
|
||||
**Why this priority**: The defect class is architectural. Without broad enforcement, the same bug can return surface by surface.
|
||||
|
||||
**Independent Test**: Can be fully tested by running the guard suite plus focused regression coverage for representative resources, filters, widgets, and sensitive actions across the rollout set.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a new or modified admin tenant-sensitive surface introduces a forbidden tenant resolver in the admin path, **When** the guard suite runs, **Then** the change is flagged before release.
|
||||
2. **Given** a formerly mixed resource is exercised through list, detail, widget, and action flows, **When** the regression suite runs, **Then** all covered tenant-sensitive paths remain aligned.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A resource or widget may be discoverable in both the workspace-admin panel and the tenant panel and must honor the correct rule in each path.
|
||||
- A persisted tenant filter may reference an old tenant after the operator switches tenant context between requests.
|
||||
- A workspace-wide surface may correctly remain workspace-wide while still showing a tenant-default header or filter state that must stay synchronized.
|
||||
- A hard tenant-sensitive surface may be opened directly from a bookmarked detail link without first loading the list page.
|
||||
- A sensitive action may be available from a page that also shows tenant-driven widgets or counts, and all of those elements must align to the same tenant.
|
||||
- A workspace-only surface may display tenant context for navigation or orientation only and must not accidentally turn that decorative context into query scope.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature does not introduce new Microsoft Graph calls, new queued work, or new scheduled work. It standardizes tenant-context resolution, filter synchronization, and guard coverage for existing workspace-admin and tenant-panel surfaces. Existing destructive behaviors remain destructive and must keep their existing confirmation, authorization, and audit expectations.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature does not create or mutate `OperationRun` lifecycles. If in-scope monitoring or operational surfaces read existing run data, they must remain display-only changes to tenant alignment, filter state, and regression protection.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin/...` flows and tenant-context `/admin/t/{tenant}/...` flows where shared files exist. Cross-plane access remains deny-as-not-found. Non-members and non-entitled tenant access remain deny-as-not-found. Entitled users without the required capability remain forbidden on protected mutations. Authorization remains server-side for list queries, filter option sets, record resolution, deep links, search results, and sensitive actions. No raw capability strings or role-name shortcuts may be introduced. Existing destructive actions remain confirmation-gated.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication handshake behavior is added or changed.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If in-scope pages or widgets show status-like badges or KPI states, their meanings remain centralized. This feature only aligns the tenant context behind those surfaces.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing headers, filter labels, safe-state messages, and navigation copy must consistently describe the active tenant using existing product vocabulary. Implementation-first tenant-resolution terms must not become primary operator-facing copy.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies existing Filament Resources, Pages, and Widgets. The Action Surface Contract remains satisfied because the rollout focuses on read, filter, navigation, and safety alignment. No new destructive action is introduced. Existing destructive actions on the affected surfaces remain subject to confirmation and authorization rules.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** In-scope Filament screens keep their established layouts and information architecture, except that `/admin` navigation is intentionally simplified to workspace-owned surfaces while tenant-sensitive entry points move to `/admin/t/{tenant}/...`. The required change is consistency of tenant context, explicit safe no-tenant outcomes where applicable, and deterministic tenant-default state on workspace-wide surfaces.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-136-01 Canonical admin resolver**: The product must define one canonical tenant source for workspace-admin tenant-sensitive surfaces and require those surfaces to use it for visible context, queries, filters, widgets, actions, and deep links.
|
||||
- **FR-136-02 No mixed admin sources**: A single workspace-admin surface must not combine more than one tenant resolver for header context, query scope, widget scope, default filters, record lookup, or action execution.
|
||||
- **FR-136-03 Tenant-panel preservation**: Tenant-panel flows must preserve their existing panel-native tenant rule and must not be forced into workspace-admin semantics.
|
||||
- **FR-136-04 Surface classification**: Every admin-visible surface touched by this rollout must be explicitly classified as hard tenant-sensitive, workspace-wide with tenant-default, or workspace-only.
|
||||
- **FR-136-05 Hard tenant-sensitive rule**: Every hard tenant-sensitive admin surface must use the same canonical tenant for header context, query results, KPIs, widgets, drill-down links, record lookup, and actions, or show a safe no-tenant-selected state.
|
||||
- **FR-136-06 Workspace-wide tenant-default rule**: Every workspace-wide admin surface with tenant-default behavior must keep its base dataset workspace-wide while ensuring that header context, default filters, persisted filter state, and deep links are synchronized to the canonical admin tenant.
|
||||
- **FR-136-07 Workspace-only rule**: Workspace-only admin surfaces must remain free of hidden tenant scoping in their read and write paths.
|
||||
- **FR-136-08 Persisted filter synchronization**: Admin surfaces with persisted tenant-related filters must revalidate and synchronize persisted state whenever the canonical admin tenant changes.
|
||||
- **FR-136-09 Invalid filter-state handling**: Persisted tenant-related filter state that references a stale, inaccessible, or mismatched tenant must be cleared or replaced before the surface renders tenant-sensitive data.
|
||||
- **FR-136-10 Query-to-action parity**: Sensitive row actions, bulk actions, and page-level actions must never operate on a different tenant than the tenant used to produce the visible list, detail view, or widget context.
|
||||
- **FR-136-11 Record-resolution parity**: Direct record lookup, detail views, and deep links for affected admin surfaces must use the same tenant rule as the corresponding list and widget flows.
|
||||
- **FR-136-12 Search parity**: Search entry points for affected admin tenant-sensitive surfaces must obey the same tenant and entitlement boundaries as their list and detail flows, or be explicitly excluded.
|
||||
- **FR-136-13 Shared-surface safety**: Shared resources, pages, and widgets that can render in more than one panel must apply the correct tenant rule for the current panel without leaking state or semantics across panels.
|
||||
- **FR-136-14 Rollout coverage**: The rollout must cover at minimum `PolicyResource`, `BackupScheduleResource`, `BackupSetResource`, `FindingResource`, `BaselineCompareLanding`, `RestoreRunResource`, `InventoryItemResource`, `PolicyVersionResource`, `ProviderConnectionResource`, `TenantDiagnostics`, `InventoryKpiHeader`, `AuditLog` guard follow-up, and `EntraGroupResource` filter-state follow-up where tenant-persisted filters apply.
|
||||
- **FR-136-15 Mixed-resource cleanup**: Known mixed-source resources, including backup, finding, governance, restore, and inventory surfaces in the rollout set, must eliminate cross-source drift between list, detail, widget, and action paths.
|
||||
- **FR-136-16 Guard enforcement**: The architectural guard must cover the full target set of relevant workspace-admin tenant-sensitive surfaces and fail when a forbidden admin-path tenant resolver appears.
|
||||
- **FR-136-17 Direct filter-state tests**: The rollout must add direct behavior coverage for tenant-filter synchronization, stale-state detection, and deterministic reseeding or clearing.
|
||||
- **FR-136-18 Regression scenarios**: The rollout must include focused regression coverage for wrong-tenant drift, stale persisted filters, widget-page alignment, and wrong-tenant sensitive-action prevention.
|
||||
- **FR-136-19 Documentation rule**: The developer guidance for future admin surfaces must state that workspace-admin tenant-sensitive flows use the canonical admin rule, tenant-panel flows use the panel-native rule, and persisted tenant filters must be synchronized.
|
||||
- **FR-136-20 No hidden fallback mutations**: A destructive or governance-sensitive admin action must not execute through an implicit tenant fallback when the surface does not visibly and deterministically establish that tenant context.
|
||||
- **FR-136-21 Workspace navigation split**: `/admin` must not register navigation items for hard tenant-sensitive surfaces. Those entry points must live in the tenant panel, while `BaselineProfileResource` and `BaselineSnapshotResource` remain in workspace-admin navigation because they are workspace assets.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Redesigning the tenant panel or changing its routing model
|
||||
- Creating a universal context engine for every panel or non-web runtime
|
||||
- Refactoring unrelated CLI, queue, or scheduled contexts that are not part of admin-path tenant drift
|
||||
- Expanding governance, backup, restore, or inventory product scope beyond tenant-context alignment
|
||||
- Reworking workspace-first or tenant-drilldown information architecture beyond what is needed to keep semantics consistent
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Spec 135 already established the intended canonical admin pattern and this feature is a rollout and enforcement completion rather than a new conceptual design.
|
||||
- Some resources and widgets are discovered in more than one panel and therefore require explicit panel-aware behavior rather than one generic rule.
|
||||
- Workspace-wide admin surfaces can remain workspace-wide while still using a deterministic tenant-default for filters, header context, and deep links.
|
||||
- Existing destructive actions on in-scope resources already have their domain-specific confirmations and authorizations; this feature ensures those actions cannot drift to the wrong tenant.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing workspace membership and tenant entitlement enforcement in admin and tenant-panel flows
|
||||
- Existing canonical admin shell state and remembered tenant behavior used by workspace-admin navigation
|
||||
- Existing tenant-sensitive filter persistence behavior on affected resources and pages
|
||||
- Existing architectural guard coverage that can be extended to the broader rollout set
|
||||
- Existing focused tests around admin resources, filters, widgets, and sensitive actions that can be expanded for regression protection
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Type A tenant-sensitive resources (`PolicyResource`, `BackupScheduleResource`, `BackupSetResource`, `FindingResource`, `RestoreRunResource`, `InventoryItemResource`, `PolicyVersionResource`) | Workspace-admin resource lists and details under `/admin/...` | Existing header actions unchanged | Table rows, record views, and detail pages remain the inspect affordances | Existing inspect or domain actions only | Existing grouped bulk actions only | Existing empty states plus explicit safe no-tenant state where required | Existing view actions only | Existing create or edit semantics remain unchanged | Existing audit behavior only | This rollout aligns tenant context across list, detail, widgets, and actions; it does not add new mutations. |
|
||||
| Type A governance pages (`BaselineCompareLanding`, `TenantDiagnostics`) | Workspace-admin pages under `/admin/...` | Existing non-destructive header actions only | KPI cards, comparison summaries, diagnostic panels | Existing inspect or drill-down actions only | None new | Existing empty states plus explicit safe no-tenant state where required | Existing page actions only | N/A unless already present | Existing audit behavior only | Page-level queries, widgets, and drill-down links must use one tenant source. |
|
||||
| Type B workspace-wide surfaces (`ProviderConnectionResource`, `AuditLog`, `EntraGroupResource` follow-up where tenant defaults apply) | Workspace-admin lists under `/admin/...` | Existing header actions unchanged | Workspace-wide tables with tenant-default filters remain the inspect affordance | Existing inspect actions only | Existing grouped bulk actions only | Existing empty states remain | Existing view actions only | Existing create or edit semantics remain unchanged | Existing audit behavior only | Base dataset remains workspace-wide; tenant-default state, filter persistence, and deep links must remain synchronized. |
|
||||
| Embedded widget surfaces (`InventoryKpiHeader` and related tenant-sensitive KPI or coverage widgets) | Workspace-admin widgets embedded in parent pages | None new | KPI cards and linked drill-downs | Linked drill-downs only where already present | None | N/A | N/A | N/A | No new audit event | Widgets must follow the same tenant source as the parent admin surface and must not silently use a different resolver. |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Canonical Admin Tenant Context**: The single tenant context used by workspace-admin tenant-sensitive flows to align what the operator sees with what the system queries and acts on.
|
||||
- **Tenant-Default Workspace Surface**: A workspace-wide admin surface that stays workspace-wide in base scope but still uses the canonical admin tenant to seed defaults, labels, and deep links.
|
||||
- **Workspace-Only Surface**: An admin surface whose semantics are owned by the workspace and must not acquire hidden tenant scoping.
|
||||
- **Persisted Tenant Filter State**: Session-backed filter state whose validity depends on the current canonical admin tenant and the operator's entitlement.
|
||||
- **Mixed-Source Drift**: A defect state where header context, query scope, widget scope, record lookup, or action execution derive tenant meaning from different sources.
|
||||
- **Sensitive Tenant Action Path**: A destructive or governance-relevant action flow whose tenant target must exactly match the tenant visible on the surrounding admin surface.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-136-01 Surface alignment**: In focused rollout coverage, 100% of representative hard tenant-sensitive admin surfaces render one consistent tenant context across header, data, widgets, links, and actions for the covered scenarios.
|
||||
- **SC-136-02 Filter-state determinism**: In focused filter-state coverage, 100% of covered tenant switches clear or reseed stale persisted tenant filters before tenant-sensitive data is rendered.
|
||||
- **SC-136-03 Wrong-tenant action prevention**: In focused sensitive-action coverage, 100% of covered destructive or governance-sensitive actions execute only against the same tenant shown on the surrounding surface or are blocked safely.
|
||||
- **SC-136-04 Workspace-wide preservation**: In focused coverage for workspace-wide tenant-default surfaces, 100% of covered scenarios preserve workspace-wide base datasets while keeping tenant-default headers, filters, and links synchronized.
|
||||
- **SC-136-05 Guard breadth**: The architectural guard explicitly covers the full rollout surface set and fails on any newly introduced forbidden admin-path tenant resolver in the covered classes.
|
||||
- **SC-136-06 Panel regression safety**: In focused dual-panel coverage for shared surfaces, 100% of covered tenant-panel scenarios preserve panel-native behavior while covered admin-panel scenarios use the canonical admin rule.
|
||||
- **SC-136-07 Developer clarity**: A maintainer can determine the correct tenant rule for a new admin surface, a tenant-panel surface, and a persisted tenant filter from the feature spec and follow-up guidance without reading implementation code first.
|
||||
241
specs/136-admin-canonical-tenant/tasks.md
Normal file
241
specs/136-admin-canonical-tenant/tasks.md
Normal file
@ -0,0 +1,241 @@
|
||||
# Tasks: Admin Panel Canonical Tenant Resolution Full Rollout
|
||||
|
||||
**Input**: Design documents from `/specs/136-admin-canonical-tenant/`
|
||||
**Prerequisites**: `plan.md`, `spec.md`, `research.md`, `data-model.md`, `contracts/admin-tenant-resolution-rollout.yaml`, `quickstart.md`
|
||||
|
||||
**Tests**: Tests are REQUIRED for this feature because it changes runtime behavior across existing Filament admin flows, persisted table filter state, global search parity, direct record resolution, and sensitive action safety.
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Create the implementation inventory, regression entry points, and developer-facing rollout note used by every implementation slice.
|
||||
|
||||
- [X] T001 Create the rollout manifest and manual tenant-switch verification note in `docs/research/admin-canonical-tenant-rollout.md`
|
||||
- [X] T002 Create direct canonical tenant regression entry points in `tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php` and `tests/Feature/Filament/AdminTenantSurfaceParityTest.php`
|
||||
- [X] T003 [P] Create shared-surface panel parity and guard expansion entry points in `tests/Feature/Filament/AdminSharedSurfacePanelParityTest.php` and `tests/Feature/Guards/AdminTenantResolverGuardTest.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Freeze the rollout manifest, lock in the canonical admin resolver and filter-sync contract, and preserve the known-good admin reference patterns before any user-story-specific migrations begin.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [X] T004 Freeze the Type A, Type B, and Type C rollout manifest plus exception inventory in `docs/research/admin-canonical-tenant-rollout.md` and `tests/Feature/Guards/AdminTenantResolverGuardTest.php`
|
||||
- [X] T005 Define the shared canonical admin tenant and filter-sync contract in `app/Support/OperateHub/OperateHubShell.php` and `app/Support/Filament/CanonicalAdminTenantFilterState.php`
|
||||
- [X] T006 [P] Preserve the admin-safe reference patterns in `app/Filament/Resources/AlertDeliveryResource.php`, `app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php`, and `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- [X] T007 [P] Extend the admin-aware search and panel-split foundation in `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`
|
||||
- [X] T008 [P] Add foundational resolver-precedence and filter-sync coverage in `tests/Feature/OpsUx/OperateHubShellTest.php` and `tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php`
|
||||
|
||||
**Checkpoint**: The rollout manifest, canonical resolver contract, filter-state contract, and shared admin reference patterns are fixed; user-story implementation can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Trust The Active Tenant Everywhere (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Eliminate mixed tenant sources on hard tenant-sensitive admin surfaces so header context, queries, widgets, links, and sensitive actions all resolve the same tenant.
|
||||
|
||||
**Independent Test**: Open representative Type A admin surfaces with an active tenant and verify that the visible tenant, list or detail data, KPIs, links, and sensitive actions all use the same tenant, with explicit safe behavior when no canonical tenant exists.
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [X] T009 [P] [US1] Add hard-tenant parity coverage for policy and backup schedule flows in `tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php` and `tests/Feature/BackupScheduling/BackupScheduleAdminTenantParityTest.php`
|
||||
- [X] T010 [P] [US1] Add hard-tenant parity coverage for backup set, findings, and baseline compare flows in `tests/Feature/Filament/BackupSetAdminTenantParityTest.php`, `tests/Feature/Findings/FindingAdminTenantParityTest.php`, and `tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php`
|
||||
- [X] T011 [P] [US1] Add hard-tenant parity coverage for restore, inventory, policy version, diagnostics, and page-widget alignment in `tests/Feature/Filament/RestoreRunAdminTenantParityTest.php`, `tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php`, `tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php`, and `tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php`
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [X] T012 [US1] Align admin-path query, detail, and action tenant resolution in `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/BackupScheduleResource.php`, `app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php`, and `app/Filament/Resources/BackupScheduleResource/Pages/EditBackupSchedule.php`
|
||||
- [X] T013 [US1] Eliminate mixed resolver usage in `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`, `app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php`, `app/Filament/Resources/FindingResource.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`, and `app/Filament/Pages/BaselineCompareLanding.php`
|
||||
- [X] T014 [US1] Align shared-panel restore semantics and sensitive-action tenant parity in `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php`, `app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php`, and `app/Filament/Resources/RestoreRunResource/Pages/ViewRestoreRun.php`
|
||||
- [X] T015 [US1] Align inventory, policy-version, diagnostics, and page-widget tenant resolution in `app/Filament/Resources/InventoryItemResource.php`, `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, `app/Filament/Resources/InventoryItemResource/Pages/ViewInventoryItem.php`, `app/Filament/Resources/PolicyVersionResource.php`, `app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php`, `app/Filament/Resources/PolicyVersionResource/Pages/ViewPolicyVersion.php`, `app/Filament/Pages/TenantDiagnostics.php`, `app/Filament/Pages/InventoryCoverage.php`, and `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
|
||||
|
||||
**Checkpoint**: User Story 1 is complete when all Type A rollout surfaces use one tenant source per admin request and expose explicit safe no-tenant behavior where required.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Switch Tenants Without Stale Filters (Priority: P1)
|
||||
|
||||
**Goal**: Make tenant-related persisted filters reseed or clear deterministically on tenant switch while keeping workspace-wide tenant-default surfaces workspace-wide.
|
||||
|
||||
**Independent Test**: Persist a tenant-related filter for one tenant, switch to another tenant, reload representative Type A and Type B surfaces, and verify that stale filter state is cleared or reseeded before any tenant-sensitive data renders.
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [X] T016 [P] [US2] Extend stale filter and tenant-switch coverage in `tests/Feature/Filament/TableStatePersistenceTest.php` and `tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php`
|
||||
- [X] T017 [P] [US2] Add admin-path search parity or explicit disablement coverage for policy and policy version resources in `tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php`, `tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php`, and `tests/Feature/Filament/PolicyVersionListFiltersTest.php`
|
||||
- [X] T018 [P] [US2] Add workspace-wide tenant-default coverage for provider connections and audit log in `tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php`, `tests/Feature/Filament/AuditLogPageTest.php`, and `tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php`
|
||||
- [X] T019 [P] [US2] Extend Entra group admin filter, detail, and search parity coverage in `tests/Feature/Filament/EntraGroupAdminScopeTest.php`, `tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php`, and `tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php`
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [X] T020 [US2] Apply `CanonicalAdminTenantFilterState` across tenant-sensitive list surfaces in `app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php`, `app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php`, `app/Filament/Resources/FindingResource/Pages/ListFindings.php`, `app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php`, and `app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php`
|
||||
- [X] T021 [US2] Align admin-path search parity or explicit disablement for policy and policy version resources in `app/Filament/Resources/PolicyResource.php`, `app/Filament/Resources/PolicyVersionResource.php`, and `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`
|
||||
- [X] T022 [US2] Align workspace-wide tenant-default filter behavior in `app/Filament/Resources/ProviderConnectionResource.php`, `app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php`, and `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
- [X] T023 [US2] Align admin list, direct-record, search, and tenant-persisted filter behavior in `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php`, `app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php`, and `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`
|
||||
|
||||
**Checkpoint**: User Story 2 is complete when stale tenant filters cannot survive a tenant switch and Type B surfaces remain workspace-wide while their tenant-default context stays synchronized.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Preserve Panel-Specific Tenant Rules (Priority: P2)
|
||||
|
||||
**Goal**: Keep tenant-panel-native behavior intact while ensuring admin-path execution uses the canonical admin tenant rule and workspace-only surfaces stay tenant-independent.
|
||||
|
||||
**Independent Test**: Exercise representative shared resources in both `/admin/...` and `/admin/t/{tenant}/...` contexts and verify that admin-path behavior uses the canonical admin rule, tenant-panel behavior remains panel-native, and workspace-only surfaces do not gain hidden tenant scoping.
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [X] T024 [P] [US3] Add shared-surface admin-versus-tenant panel parity coverage in `tests/Feature/Filament/AdminSharedSurfacePanelParityTest.php`, `tests/Feature/Filament/EntraGroupAdminScopeTest.php`, and `tests/Feature/Filament/RestoreRunUiEnforcementTest.php`
|
||||
- [X] T025 [P] [US3] Add workspace-only non-regression coverage in `tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php`, `tests/Feature/Filament/Alerts/AlertRuleAccessTest.php`, `tests/Feature/Filament/BaselineProfileFoundationScopeTest.php`, `tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php`, `tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php`, and `tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php`
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [X] T026 [US3] Preserve tenant-panel-native branching in `app/Filament/Resources/RestoreRunResource.php`, `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/BackupSetResource.php`, and `app/Filament/Concerns/ScopesGlobalSearchToTenant.php`
|
||||
- [X] T027 [US3] Keep workspace-only and workspace-wide admin surfaces free of artificial tenant enforcement in `app/Filament/Resources/AlertRuleResource.php`, `app/Filament/Resources/BaselineProfileResource.php`, `app/Filament/Resources/BaselineSnapshotResource.php`, `app/Filament/Resources/ProviderConnectionResource.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Pages/Monitoring/AuditLog.php`
|
||||
|
||||
**Checkpoint**: User Story 3 is complete when shared surfaces branch correctly by panel and workspace-only surfaces remain tenant-independent.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 - Catch Regressions Before Merge (Priority: P3)
|
||||
|
||||
**Goal**: Expand the guard and focused regression suite so new admin-path mixed-resolver drift is blocked in CI and future contributors have a clear rule to follow.
|
||||
|
||||
**Independent Test**: Run the guard suite and the focused regression pack and verify that any new raw admin-path `Filament::getTenant()` or `Tenant::current()` usage fails with actionable output while approved tenant-panel exceptions remain explicit.
|
||||
|
||||
### Tests for User Story 4
|
||||
|
||||
- [X] T028 [P] [US4] Expand guard manifest and persisted-filter regression coverage in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` and `tests/Feature/Guards/FilamentTableStandardsGuardTest.php`
|
||||
- [X] T029 [P] [US4] Add focused wrong-tenant action and record-resolution regression coverage in `tests/Feature/Filament/AdminTenantSurfaceParityTest.php`, `tests/Feature/Findings/FindingWorkflowRowActionsTest.php`, `tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php`, and `tests/Feature/RestoreRunWizardExecuteTest.php`
|
||||
|
||||
### Implementation for User Story 4
|
||||
|
||||
- [X] T030 [US4] Update the executable guard allowlist, rollout manifest notes, and exception rationale in `tests/Feature/Guards/AdminTenantResolverGuardTest.php` and `docs/research/admin-canonical-tenant-rollout.md`
|
||||
- [X] T031 [US4] Document the future-surface developer rule in `docs/research/admin-canonical-tenant-rollout.md` and `specs/136-admin-canonical-tenant/quickstart.md`
|
||||
|
||||
**Checkpoint**: User Story 4 is complete when the guard suite reflects the full rollout manifest and future admin-path drift is blocked cheaply in CI.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Reconcile operator-facing copy, run the focused verification pack, and format touched files.
|
||||
|
||||
- [X] T032 [P] Reconcile operator-facing safe-state and tenant-default copy in `app/Support/OperateHub/OperateHubShell.php`, `app/Filament/Pages/BaselineCompareLanding.php`, `app/Filament/Pages/Monitoring/AuditLog.php`, and `app/Filament/Widgets/Inventory/InventoryKpiHeader.php`
|
||||
- [X] T033 Record Wave 1, Wave 2, and Wave 3 manual tenant-switch verification outcomes in `docs/research/admin-canonical-tenant-rollout.md` using the checklist in `specs/136-admin-canonical-tenant/quickstart.md`
|
||||
- [X] T034 Run the focused verification commands documented in `specs/136-admin-canonical-tenant/quickstart.md`
|
||||
- [X] T035 Run formatting on touched files with `vendor/bin/sail bin pint --dirty --format agent` from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: No dependencies; can start immediately.
|
||||
- **Foundational (Phase 2)**: Depends on Setup completion; blocks all user-story implementation.
|
||||
- **User Stories (Phases 3-6)**: Depend on Foundational completion.
|
||||
- **Polish (Phase 7)**: Depends on the desired user stories being complete.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **User Story 1 (P1)**: Starts after Foundational and delivers the MVP by aligning all hard tenant-sensitive surfaces.
|
||||
- **User Story 2 (P1)**: Starts after Foundational and can run in parallel with US1 once the shared filter-sync contract is in place.
|
||||
- **User Story 3 (P2)**: Starts after Foundational and should land before release so shared resources preserve tenant-panel semantics and workspace-only surfaces stay clean.
|
||||
- **User Story 4 (P3)**: Depends on the remediation intent from US1-US3 so the final guard manifest and regression pack reflect the completed rollout set.
|
||||
|
||||
### Within Each User Story
|
||||
|
||||
- Write or update the story tests first and confirm they fail against pre-change behavior.
|
||||
- Land shared resolver, filter-sync, and panel-branching logic before adjusting action or link affordances that consume it.
|
||||
- Keep list, detail, deep-link, and search behavior aligned before closing the story.
|
||||
- Finish story-level validation before moving to the next priority.
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T003 can run in parallel with T001-T002 once the feature directory exists.
|
||||
- T006-T008 can run in parallel after T004-T005.
|
||||
- In US1, T009-T011 can run in parallel before T012-T015.
|
||||
- In US2, T016-T019 can run in parallel before T020-T023.
|
||||
- In US3, T024-T025 can run in parallel before T026-T027.
|
||||
- In US4, T028-T029 can run in parallel before T030-T031.
|
||||
- T032 can run in parallel with T033-T034 after the story phases are complete.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 1
|
||||
|
||||
```bash
|
||||
# Launch the US1 parity tests together:
|
||||
Task: "Add hard-tenant parity coverage for policy and backup schedule flows in tests/Feature/Filament/PolicyResourceAdminTenantParityTest.php and tests/Feature/BackupScheduling/BackupScheduleAdminTenantParityTest.php"
|
||||
Task: "Add hard-tenant parity coverage for backup set, findings, and baseline compare flows in tests/Feature/Filament/BackupSetAdminTenantParityTest.php, tests/Feature/Findings/FindingAdminTenantParityTest.php, and tests/Feature/Filament/BaselineCompareLandingAdminTenantParityTest.php"
|
||||
Task: "Add hard-tenant parity coverage for restore, inventory, policy version, diagnostics, and page-widget alignment in tests/Feature/Filament/RestoreRunAdminTenantParityTest.php, tests/Feature/Filament/InventoryCoverageAdminTenantParityTest.php, tests/Feature/Filament/PolicyVersionAdminTenantParityTest.php, and tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 2
|
||||
|
||||
```bash
|
||||
# Launch the US2 filter and tenant-default tests together:
|
||||
Task: "Extend stale filter and tenant-switch coverage in tests/Feature/Filament/TableStatePersistenceTest.php and tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php"
|
||||
Task: "Add admin-path search parity or explicit disablement coverage for policy and policy version resources in tests/Feature/Filament/PolicyResourceAdminSearchParityTest.php, tests/Feature/Filament/PolicyVersionAdminSearchParityTest.php, and tests/Feature/Filament/PolicyVersionListFiltersTest.php"
|
||||
Task: "Add workspace-wide tenant-default coverage for provider connections and audit log in tests/Feature/ProviderConnections/ProviderConnectionAuthorizationTest.php, tests/Feature/Filament/AuditLogPageTest.php, and tests/Feature/WorkspaceIsolation/AuditLogScopeInvariantTest.php"
|
||||
Task: "Extend Entra group admin filter, detail, and search parity coverage in tests/Feature/Filament/EntraGroupAdminScopeTest.php, tests/Feature/Filament/EntraGroupGlobalSearchScopeTest.php, and tests/Feature/Filament/EntraGroupEnterpriseDetailPageTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 3
|
||||
|
||||
```bash
|
||||
# Launch the US3 panel-behavior tests together:
|
||||
Task: "Add shared-surface admin-versus-tenant panel parity coverage in tests/Feature/Filament/AdminSharedSurfacePanelParityTest.php, tests/Feature/Filament/EntraGroupAdminScopeTest.php, and tests/Feature/Filament/RestoreRunUiEnforcementTest.php"
|
||||
Task: "Add workspace-only non-regression coverage in tests/Feature/Filament/WorkspaceOnlySurfaceTenantIndependenceTest.php, tests/Feature/Filament/Alerts/AlertRuleAccessTest.php, tests/Feature/Filament/BaselineProfileFoundationScopeTest.php, tests/Feature/Filament/BaselineSnapshotAuthorizationTest.php, tests/Feature/ProviderConnections/ProviderConnectionListAuthorizationTest.php, and tests/Feature/TenantRBAC/TenantDiagnosticsAccessTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parallel Example: User Story 4
|
||||
|
||||
```bash
|
||||
# Launch the US4 guard and regression work together:
|
||||
Task: "Expand guard manifest and persisted-filter regression coverage in tests/Feature/Guards/AdminTenantResolverGuardTest.php and tests/Feature/Guards/FilamentTableStandardsGuardTest.php"
|
||||
Task: "Add focused wrong-tenant action and record-resolution regression coverage in tests/Feature/Filament/AdminTenantSurfaceParityTest.php, tests/Feature/Findings/FindingWorkflowRowActionsTest.php, tests/Feature/Filament/PolicyVersionRestoreToIntuneTest.php, and tests/Feature/RestoreRunWizardExecuteTest.php"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First (User Story 1 Only)
|
||||
|
||||
1. Complete Phase 1: Setup.
|
||||
2. Complete Phase 2: Foundational.
|
||||
3. Complete Phase 3: User Story 1.
|
||||
4. Validate representative Type A surfaces before moving on.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Finish Setup and Foundational shared resolver and filter-state work.
|
||||
2. Deliver User Story 1 to eliminate mixed tenant sources on Type A surfaces.
|
||||
3. Deliver User Story 2 to harden persisted filters, policy search parity, and workspace-wide tenant-default behavior.
|
||||
4. Deliver User Story 3 to preserve tenant-panel semantics and workspace-only independence.
|
||||
5. Deliver User Story 4 to lock in guard coverage and future-surface guidance.
|
||||
6. Finish with copy reconciliation, manual tenant-switch verification, focused validation, and formatting.
|
||||
|
||||
### Parallel Team Strategy
|
||||
|
||||
1. One contributor handles the support-layer resolver, filter-sync contract, and rollout manifest while another prepares the new regression entry points.
|
||||
2. After Foundation is ready, split US1 and US2 between hard tenant-sensitive surface parity and persisted-filter or workspace-wide tenant-default hardening.
|
||||
3. Reserve one contributor for shared-surface panel behavior and the guard manifest so exception handling stays coherent while implementation lands.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- `[P]` tasks touch different files and can be executed in parallel.
|
||||
- User-story labels map directly to the prioritized stories in `spec.md`.
|
||||
- Tests are mandatory in this repo for every runtime change in the resulting implementation.
|
||||
- The suggested MVP scope is Phase 3 only after Setup and Foundational are complete.
|
||||
@ -56,6 +56,7 @@ public function test_renders_verification_report_on_canonical_detail_without_fil
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Verification report')
|
||||
->assertDontSee('Verification report unavailable')
|
||||
->assertSee('Open previous verification')
|
||||
->assertSee('/admin/operations/'.((int) $previousRun->getKey()), false)
|
||||
->assertSee('Token acquisition works');
|
||||
|
||||
@ -112,6 +112,6 @@
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'action' => 'tenant.consent.callback',
|
||||
'status' => 'error',
|
||||
'status' => 'failed',
|
||||
]);
|
||||
});
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
expect($audit->status)->toBe('failure');
|
||||
expect($audit->status)->toBe('failed');
|
||||
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
|
||||
expect($audit->metadata['reason'] ?? null)->toBe('invalid_credentials');
|
||||
});
|
||||
@ -115,7 +115,7 @@
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
expect($audit->status)->toBe('failure');
|
||||
expect($audit->status)->toBe('failed');
|
||||
expect($audit->metadata['attempted_email'] ?? null)->toBe($user->email);
|
||||
expect($audit->metadata['reason'] ?? null)->toBe('inactive');
|
||||
});
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns not found for admin backup-schedule edit outside the canonical tenant scope', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$allowed = BackupSchedule::create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'name' => 'Allowed schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '10:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$blocked = BackupSchedule::create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
'name' => 'Blocked schedule',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '11:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$this->withSession($session)
|
||||
->get(BackupScheduleResource::getUrl('edit', ['record' => $allowed], panel: 'admin'))
|
||||
->assertOk();
|
||||
|
||||
$this->withSession($session)
|
||||
->get(BackupScheduleResource::getUrl('edit', ['record' => $blocked], panel: 'admin'))
|
||||
->assertNotFound();
|
||||
});
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Services\Graph\GroupResolver;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySnapshotService;
|
||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
@ -163,6 +164,7 @@
|
||||
'policy_id' => $this->policy->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
'snapshot' => $this->snapshotPayload,
|
||||
@ -223,6 +225,7 @@
|
||||
'policy_id' => $this->policy->id,
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'version_number' => 1,
|
||||
'capture_purpose' => PolicyVersionCapturePurpose::Backup,
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
'platform' => 'windows10',
|
||||
'snapshot' => $this->snapshotPayload,
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\InventoryMetaContract;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -64,12 +60,10 @@
|
||||
'snapshot' => $snapshotPayload,
|
||||
]);
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized(
|
||||
[
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
],
|
||||
$expectedContentHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $snapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -56,11 +52,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshot, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $baselineSnapshot,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
@ -180,11 +176,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $snapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -174,11 +170,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineContentHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $snapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -54,11 +50,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $baselineSnapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -54,11 +50,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($baselineSnapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $baselineSnapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
@ -5,10 +5,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Baselines\CurrentStateHashResolver;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
it('Baseline resolver prefers content evidence over meta evidence when available', function () {
|
||||
@ -48,15 +44,11 @@
|
||||
'last_seen_operation_run_id' => null,
|
||||
]);
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff(
|
||||
is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [],
|
||||
(string) $policyVersion->policy_type,
|
||||
is_string($policyVersion->platform) ? $policyVersion->platform : null,
|
||||
),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$expectedContentHash = expectedPolicyVersionContentHash(
|
||||
snapshot: is_array($policyVersion->snapshot) ? $policyVersion->snapshot : [],
|
||||
policyType: (string) $policyVersion->policy_type,
|
||||
platform: is_string($policyVersion->platform) ? $policyVersion->platform : null,
|
||||
);
|
||||
|
||||
$expectedMetaHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||
policyType: (string) $inventory->policy_type,
|
||||
|
||||
@ -10,10 +10,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -55,11 +51,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$expectedContentHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$expectedContentHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $snapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
BaselineSnapshotItem::factory()->create([
|
||||
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||
|
||||
@ -9,10 +9,6 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\Baselines\BaselineSubjectKey;
|
||||
@ -80,20 +76,15 @@
|
||||
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
|
||||
]);
|
||||
|
||||
$hasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$baselineHash = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineVersion->snapshot ?? [],
|
||||
policyType: $policyType,
|
||||
platform: $baselineVersion->platform,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
||||
policyType: $policyType,
|
||||
platform: is_string($baselineVersion->platform) ? $baselineVersion->platform : null,
|
||||
assignments: is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [],
|
||||
scopeTags: is_array($baselineVersion->scope_tags) ? $baselineVersion->scope_tags : [],
|
||||
secretFingerprints: is_array($baselineVersion->secret_fingerprints) ? $baselineVersion->secret_fingerprints : [],
|
||||
redactionVersion: is_numeric($baselineVersion->redaction_version) ? (int) $baselineVersion->redaction_version : null,
|
||||
);
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
|
||||
|
||||
@ -212,20 +203,15 @@
|
||||
'scope_tags' => ['ids' => ['0'], 'names' => ['Default']],
|
||||
]);
|
||||
|
||||
$hasher = app(DriftHasher::class);
|
||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
||||
|
||||
$baselineHash = $hasher->hashNormalized([
|
||||
'settings' => $settingsNormalizer->normalizeForDiff(
|
||||
snapshot: $baselineVersion->snapshot ?? [],
|
||||
policyType: $policyType,
|
||||
platform: $baselineVersion->platform,
|
||||
),
|
||||
'assignments' => $assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments ?? []),
|
||||
'scope_tag_ids' => $scopeTagsNormalizer->normalizeIds($baselineVersion->scope_tags ?? []),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [],
|
||||
policyType: $policyType,
|
||||
platform: is_string($baselineVersion->platform) ? $baselineVersion->platform : null,
|
||||
assignments: is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [],
|
||||
scopeTags: is_array($baselineVersion->scope_tags) ? $baselineVersion->scope_tags : [],
|
||||
secretFingerprints: is_array($baselineVersion->secret_fingerprints) ? $baselineVersion->secret_fingerprints : [],
|
||||
redactionVersion: is_numeric($baselineVersion->redaction_version) ? (int) $baselineVersion->redaction_version : null,
|
||||
);
|
||||
|
||||
$workspaceSafeExternalId = BaselineSubjectKey::workspaceSafeSubjectExternalId($policyType, (string) $subjectKey);
|
||||
|
||||
|
||||
@ -10,10 +10,6 @@
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Baselines\BaselineContentCapturePhase;
|
||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||
use App\Services\Drift\DriftHasher;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\OperationRunService;
|
||||
@ -431,11 +427,11 @@
|
||||
],
|
||||
];
|
||||
|
||||
$baselineHash = app(DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(SettingsNormalizer::class)->normalizeForDiff($snapshotPayload, 'deviceConfiguration', 'windows'),
|
||||
'assignments' => app(AssignmentsNormalizer::class)->normalizeForDiff([]),
|
||||
'scope_tag_ids' => app(ScopeTagsNormalizer::class)->normalizeIds([]),
|
||||
]);
|
||||
$baselineHash = expectedPolicyVersionContentHash(
|
||||
snapshot: $snapshotPayload,
|
||||
policyType: 'deviceConfiguration',
|
||||
platform: 'windows',
|
||||
);
|
||||
|
||||
$subjectKey = BaselineSubjectKey::fromDisplayName((string) $policy->display_name);
|
||||
expect($subjectKey)->not->toBeNull();
|
||||
|
||||
48
tests/Feature/Filament/AdminSharedSurfacePanelParityTest.php
Normal file
48
tests/Feature/Filament/AdminSharedSurfacePanelParityTest.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps backup-set scoping split between admin remembered context and tenant-panel context', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$backupSetA = BackupSet::factory()->for($tenantA)->create(['name' => 'Remembered admin backup']);
|
||||
$backupSetB = BackupSet::factory()->for($tenantB)->create(['name' => 'Tenant panel backup']);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListBackupSets::class)
|
||||
->assertCanSeeTableRecords([$backupSetA])
|
||||
->assertCanNotSeeTableRecords([$backupSetB]);
|
||||
|
||||
Filament::setCurrentPanel('tenant');
|
||||
Filament::setTenant($tenantB, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantB->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)->test(ListBackupSets::class)
|
||||
->assertCanSeeTableRecords([$backupSetB])
|
||||
->assertCanNotSeeTableRecords([$backupSetA]);
|
||||
});
|
||||
@ -12,6 +12,14 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
dataset('admin tenant scoped surface paths', [
|
||||
'/admin/policies',
|
||||
'/admin/policy-versions',
|
||||
'/admin/backup-sets',
|
||||
'/admin/inventory',
|
||||
'/admin/inventory/inventory-coverage',
|
||||
]);
|
||||
|
||||
it('redirects tenant-scoped admin surfaces to choose-tenant when no tenant is selected', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
@ -63,3 +71,27 @@
|
||||
->get('/admin/inventory')
|
||||
->assertRedirect('/admin/choose-tenant');
|
||||
});
|
||||
|
||||
it('allows tenant-scoped admin surfaces to load from the remembered canonical tenant', function (string $path): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
])
|
||||
->get($path);
|
||||
|
||||
expect($response->getStatusCode())->toBeIn([200, 302]);
|
||||
expect($response->headers->get('Location'))->not->toBe('/admin/choose-tenant');
|
||||
})->with('admin tenant scoped surface paths');
|
||||
|
||||
75
tests/Feature/Filament/AdminTenantSurfaceParityTest.php
Normal file
75
tests/Feature/Filament/AdminTenantSurfaceParityTest.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns not found for admin direct record access outside the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$allowedPolicy = Policy::factory()->for($tenantA)->create();
|
||||
$blockedBackupSet = BackupSet::factory()->for($tenantB)->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $session[WorkspaceContext::SESSION_KEY]);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY]);
|
||||
|
||||
expect(PolicyResource::getEloquentQuery()->whereKey($allowedPolicy->getKey())->exists())->toBeTrue();
|
||||
expect(BackupSetResource::getEloquentQuery()->whereKey($blockedBackupSet->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps admin direct policy-version resolution inside the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create();
|
||||
$policyB = Policy::factory()->for($tenantB)->create();
|
||||
|
||||
$allowedVersion = PolicyVersion::factory()->for($tenantA)->for($policyA)->create();
|
||||
$blockedVersion = PolicyVersion::factory()->for($tenantB)->for($policyB)->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $session[WorkspaceContext::SESSION_KEY]);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, $session[WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY]);
|
||||
|
||||
expect(PolicyVersionResource::getEloquentQuery()->whereKey($allowedVersion->getKey())->exists())->toBeTrue();
|
||||
expect(PolicyVersionResource::getEloquentQuery()->whereKey($blockedVersion->getKey())->exists())->toBeFalse();
|
||||
});
|
||||
37
tests/Feature/Filament/BackupSetAdminTenantParityTest.php
Normal file
37
tests/Feature/Filament/BackupSetAdminTenantParityTest.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('scopes the admin backup-set list to the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$backupSetA = BackupSet::factory()->for($tenantA)->create(['name' => 'Remembered tenant backup']);
|
||||
$backupSetB = BackupSet::factory()->for($tenantB)->create(['name' => 'Other tenant backup']);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListBackupSets::class)
|
||||
->assertCanSeeTableRecords([$backupSetA])
|
||||
->assertCanNotSeeTableRecords([$backupSetB]);
|
||||
});
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can access baseline compare when only the remembered admin tenant is available', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
expect(BaselineCompareLanding::canAccess())->toBeTrue();
|
||||
});
|
||||
114
tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php
Normal file
114
tests/Feature/Filament/CanonicalAdminTenantFilterStateTest.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function canonicalAdminTenantRequest(): Request
|
||||
{
|
||||
$request = Request::create('/admin');
|
||||
$request->setLaravelSession(app('session.store'));
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
it('clears tenant-sensitive persisted filters when the canonical admin tenant changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
$filtersSessionKey = 'filament.test.filters';
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $tenantA->getKey(),
|
||||
]);
|
||||
session()->put($filtersSessionKey, [
|
||||
'run_ids' => ['baseline_operation_run_id' => 101],
|
||||
'status' => ['value' => 'new'],
|
||||
]);
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$filtersSessionKey,
|
||||
['run_ids'],
|
||||
canonicalAdminTenantRequest(),
|
||||
null,
|
||||
);
|
||||
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
$filtersSessionKey,
|
||||
['run_ids'],
|
||||
canonicalAdminTenantRequest(),
|
||||
null,
|
||||
);
|
||||
|
||||
expect(session()->get($filtersSessionKey))
|
||||
->toMatchArray([
|
||||
'status' => ['value' => 'new'],
|
||||
])
|
||||
->not->toHaveKey('run_ids');
|
||||
});
|
||||
|
||||
it('can seed a non-default tenant filter with the canonical tenant external id', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
'filament.provider.filters',
|
||||
request: canonicalAdminTenantRequest(),
|
||||
tenantFilterName: 'tenant',
|
||||
tenantAttribute: 'external_id',
|
||||
);
|
||||
|
||||
expect(session()->get('filament.provider.filters'))
|
||||
->toMatchArray([
|
||||
'tenant' => ['value' => (string) $tenant->external_id],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not persist an empty filter bag when only canonical tenant state is synced', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
]);
|
||||
|
||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
||||
'filament.findings.filters',
|
||||
request: canonicalAdminTenantRequest(),
|
||||
tenantFilterName: null,
|
||||
);
|
||||
|
||||
expect(session()->has('filament.findings.filters'))->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('loads inventory coverage from the remembered canonical tenant in the admin panel', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(InventoryCoverage::class)
|
||||
->assertOk()
|
||||
->assertSee('Coverage');
|
||||
});
|
||||
@ -45,14 +45,12 @@
|
||||
->get($itemsUrl)
|
||||
->assertOk()
|
||||
->assertSee('Run Inventory Sync')
|
||||
->assertSee($coverageUrl)
|
||||
->assertSee($kpiLabels)
|
||||
->assertSee('Item A');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->get($coverageUrl)
|
||||
->assertOk()
|
||||
->assertSee($itemsUrl)
|
||||
->assertSee($kpiLabels)
|
||||
->assertSee('Coverage')
|
||||
->assertSee('Searchable support matrix')
|
||||
|
||||
126
tests/Feature/Filament/PanelNavigationSegregationTest.php
Normal file
126
tests/Feature/Filament/PanelNavigationSegregationTest.php
Normal file
@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Resources\BackupScheduleResource;
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\BaselineSnapshotResource;
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
afterEach(function (): void {
|
||||
Filament::setCurrentPanel(null);
|
||||
});
|
||||
|
||||
dataset('admin hidden navigation classes', [
|
||||
InventoryCluster::class,
|
||||
InventoryCoverage::class,
|
||||
InventoryItemResource::class,
|
||||
PolicyResource::class,
|
||||
PolicyVersionResource::class,
|
||||
BackupScheduleResource::class,
|
||||
BackupSetResource::class,
|
||||
RestoreRunResource::class,
|
||||
EntraGroupResource::class,
|
||||
FindingResource::class,
|
||||
]);
|
||||
|
||||
dataset('tenant visible navigation classes', [
|
||||
InventoryCluster::class,
|
||||
InventoryCoverage::class,
|
||||
InventoryItemResource::class,
|
||||
PolicyResource::class,
|
||||
PolicyVersionResource::class,
|
||||
BackupScheduleResource::class,
|
||||
BackupSetResource::class,
|
||||
RestoreRunResource::class,
|
||||
EntraGroupResource::class,
|
||||
FindingResource::class,
|
||||
]);
|
||||
|
||||
it('keeps tenant-sensitive surfaces out of admin navigation registration', function (string $class): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get('/admin')
|
||||
->assertOk();
|
||||
|
||||
expect($class::shouldRegisterNavigation())->toBeFalse();
|
||||
})->with('admin hidden navigation classes');
|
||||
|
||||
it('registers tenant-sensitive surfaces in the tenant panel navigation', function (string $class): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertOk();
|
||||
|
||||
expect($class::shouldRegisterNavigation())->toBeTrue();
|
||||
})->with('tenant visible navigation classes');
|
||||
|
||||
it('keeps baseline navigation in the workspace panel and out of the tenant panel', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get('/admin')
|
||||
->assertOk();
|
||||
|
||||
expect(BaselineProfileResource::shouldRegisterNavigation())->toBeTrue();
|
||||
expect(BaselineSnapshotResource::shouldRegisterNavigation())->toBeTrue();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get("/admin/t/{$tenant->external_id}")
|
||||
->assertOk();
|
||||
|
||||
expect(BaselineProfileResource::shouldRegisterNavigation())->toBeFalse();
|
||||
expect(BaselineSnapshotResource::shouldRegisterNavigation())->toBeFalse();
|
||||
});
|
||||
|
||||
it('keeps the workspace panel sidebar free of tenant-sensitive entries even with a remembered tenant', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenant->workspace_id => (int) $tenant->getKey(),
|
||||
],
|
||||
])
|
||||
->get('/admin')
|
||||
->assertOk();
|
||||
|
||||
$response->assertDontSee('>Items</span>', false);
|
||||
$response->assertDontSee('>Policies</span>', false);
|
||||
$response->assertDontSee('>Policy Versions</span>', false);
|
||||
$response->assertDontSee('>Backup Schedules</span>', false);
|
||||
$response->assertDontSee('>Backup Sets</span>', false);
|
||||
$response->assertDontSee('>Restore Runs</span>', false);
|
||||
$response->assertDontSee('>Findings</span>', false);
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
|
||||
it('disables policy global search explicitly for the rollout', function (): void {
|
||||
$property = new \ReflectionProperty(PolicyResource::class, 'isGloballySearchable');
|
||||
$property->setAccessible(true);
|
||||
|
||||
expect($property->getValue())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('scopes the admin policy list to the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered tenant policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other tenant policy']);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListPolicies::class)
|
||||
->assertCanSeeTableRecords([$policyA])
|
||||
->assertCanNotSeeTableRecords([$policyB]);
|
||||
});
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
|
||||
it('disables policy-version global search explicitly for the rollout', function (): void {
|
||||
$property = new \ReflectionProperty(PolicyVersionResource::class, 'isGloballySearchable');
|
||||
$property->setAccessible(true);
|
||||
|
||||
expect($property->getValue())->toBeFalse();
|
||||
});
|
||||
@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('scopes the admin policy-version list to the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policyA = Policy::factory()->for($tenantA)->create(['display_name' => 'Remembered policy']);
|
||||
$policyB = Policy::factory()->for($tenantB)->create(['display_name' => 'Other policy']);
|
||||
|
||||
$versionA = PolicyVersion::factory()->for($tenantA)->for($policyA)->create(['version_number' => 10]);
|
||||
$versionB = PolicyVersion::factory()->for($tenantB)->for($policyB)->create(['version_number' => 20]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListPolicyVersions::class)
|
||||
->assertCanSeeTableRecords([$versionA])
|
||||
->assertCanNotSeeTableRecords([$versionB]);
|
||||
});
|
||||
@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -158,3 +159,54 @@ public function getServicePrincipalPermissions(array $options = []): GraphRespon
|
||||
|
||||
expect($definitionValueCreateCalls)->toHaveCount(5);
|
||||
});
|
||||
|
||||
test('restore rejects policy versions from a different tenant before creating backup artifacts', function () {
|
||||
$tenantA = Tenant::create([
|
||||
'tenant_id' => 'tenant-version-restore-a',
|
||||
'name' => 'Tenant A',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$tenantB = Tenant::create([
|
||||
'tenant_id' => 'tenant-version-restore-b',
|
||||
'name' => 'Tenant B',
|
||||
'metadata' => [],
|
||||
]);
|
||||
|
||||
$policy = Policy::create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
'external_id' => 'gpo-versioned-wrong-tenant',
|
||||
'policy_type' => 'groupPolicyConfiguration',
|
||||
'display_name' => 'Wrong Tenant Policy',
|
||||
'platform' => 'windows',
|
||||
]);
|
||||
|
||||
$version = PolicyVersion::create([
|
||||
'tenant_id' => $tenantB->id,
|
||||
'policy_id' => $policy->id,
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'created_by' => 'tester@example.com',
|
||||
'captured_at' => now(),
|
||||
'snapshot' => [
|
||||
'id' => $policy->external_id,
|
||||
'displayName' => $policy->display_name,
|
||||
'@odata.type' => '#microsoft.graph.groupPolicyConfiguration',
|
||||
'definitionValues' => [],
|
||||
],
|
||||
'metadata' => ['source' => 'version_capture'],
|
||||
]);
|
||||
|
||||
$service = app(RestoreService::class);
|
||||
|
||||
expect(fn () => $service->executeFromPolicyVersion(
|
||||
tenant: $tenantA,
|
||||
version: $version,
|
||||
dryRun: false,
|
||||
actorEmail: 'tester@example.com',
|
||||
actorName: 'Tester',
|
||||
))->toThrow(\InvalidArgumentException::class, 'Policy version does not belong to the provided tenant.');
|
||||
|
||||
expect(BackupSet::query()->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
39
tests/Feature/Filament/RestoreRunAdminTenantParityTest.php
Normal file
39
tests/Feature/Filament/RestoreRunAdminTenantParityTest.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('scopes the admin restore-run list to the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$backupSetA = BackupSet::factory()->for($tenantA)->create();
|
||||
$backupSetB = BackupSet::factory()->for($tenantB)->create();
|
||||
|
||||
RestoreRun::factory()->for($tenantA)->for($backupSetA)->create(['results' => ['label' => 'Remembered restore']]);
|
||||
RestoreRun::factory()->for($tenantB)->for($backupSetB)->create(['results' => ['label' => 'Other restore']]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$this->followingRedirects()->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenantA->workspace_id,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
],
|
||||
])->get(RestoreRunResource::getUrl('index', panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee((string) $backupSetA->name)
|
||||
->assertDontSee((string) $backupSetB->name);
|
||||
});
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Filament\Resources\ProviderConnectionResource\Pages\ListProviderConnections;
|
||||
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
|
||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -55,7 +56,9 @@ function spec125AssertPersistedTableState(
|
||||
[$user] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
spec125AssertPersistedTableState(
|
||||
ListTenants::class,
|
||||
@ -280,3 +283,32 @@ function spec125AssertPersistedTableState(
|
||||
'queued',
|
||||
);
|
||||
});
|
||||
|
||||
it('reseeds the provider-connections tenant filter when the remembered admin tenant changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListProviderConnections::class)
|
||||
->assertSet('tableFilters.tenant.value', (string) $tenantA->external_id);
|
||||
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $tenantB->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListProviderConnections::class)
|
||||
->assertSet('tableFilters.tenant.value', (string) $tenantB->external_id);
|
||||
});
|
||||
|
||||
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\AlertRuleResource;
|
||||
use App\Filament\Resources\BaselineProfileResource;
|
||||
use App\Filament\Resources\TenantResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\BaselineProfile;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps workspace-only admin surfaces accessible without canonical tenant context', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
AlertRule::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Workspace alert rule',
|
||||
]);
|
||||
|
||||
BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'name' => 'Workspace baseline',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
];
|
||||
|
||||
$this->withSession($session)
|
||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Workspace alert rule');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(BaselineProfileResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Workspace baseline');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(TenantResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee($tenant->name);
|
||||
});
|
||||
|
||||
it('keeps workspace-only admin surfaces independent from remembered tenant changes', function (): void {
|
||||
$tenantA = Tenant::factory()->create([
|
||||
'name' => 'Phoenicon',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'YPTW2',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
AlertRule::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Workspace-wide alert rule',
|
||||
]);
|
||||
|
||||
BaselineProfile::factory()->active()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'Workspace-wide baseline',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant(null, true);
|
||||
|
||||
$workspaceId = (int) $tenantA->workspace_id;
|
||||
|
||||
foreach ([$tenantA, $tenantB] as $rememberedTenant) {
|
||||
$session = [
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $rememberedTenant->getKey(),
|
||||
],
|
||||
];
|
||||
|
||||
$this->withSession($session)
|
||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Workspace-wide alert rule');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(BaselineProfileResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Workspace-wide baseline');
|
||||
|
||||
$this->withSession($session)
|
||||
->get(TenantResource::getUrl(panel: 'admin'))
|
||||
->assertOk()
|
||||
->assertSee('Phoenicon')
|
||||
->assertSee('YPTW2');
|
||||
}
|
||||
});
|
||||
44
tests/Feature/Findings/FindingAdminTenantParityTest.php
Normal file
44
tests/Feature/Findings/FindingAdminTenantParityTest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('scopes the admin findings list to the remembered canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'manager');
|
||||
$tenantB = Tenant::factory()->create(['workspace_id' => (int) $tenantA->workspace_id]);
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'manager');
|
||||
|
||||
$findingA = Finding::factory()->for($tenantA)->create([
|
||||
'subject_external_id' => 'finding-a',
|
||||
'evidence_jsonb' => ['display_name' => 'Remembered finding'],
|
||||
]);
|
||||
|
||||
$findingB = Finding::factory()->for($tenantB)->create([
|
||||
'subject_external_id' => 'finding-b',
|
||||
'evidence_jsonb' => ['display_name' => 'Other finding'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListFindings::class)
|
||||
->assertCanSeeTableRecords([$findingA])
|
||||
->assertCanNotSeeTableRecords([$findingB]);
|
||||
});
|
||||
@ -4,7 +4,9 @@
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -124,3 +126,36 @@
|
||||
$finding->refresh();
|
||||
expect((int) $finding->assignee_user_id)->toBe((int) $assignee->getKey());
|
||||
});
|
||||
|
||||
it('keeps the admin workflow surface scoped to the canonical tenant', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$visibleFinding = Finding::factory()->for($tenantA)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$hiddenFinding = Finding::factory()->for($tenantB)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($user)->test(ListFindings::class)
|
||||
->assertCanSeeTableRecords([$visibleFinding])
|
||||
->assertCanNotSeeTableRecords([$hiddenFinding]);
|
||||
});
|
||||
|
||||
@ -5,14 +5,33 @@
|
||||
function adminTenantResolverGuardedFiles(): array
|
||||
{
|
||||
return [
|
||||
'app/Filament/Pages/BaselineCompareLanding.php',
|
||||
'app/Filament/Pages/TenantDiagnostics.php',
|
||||
'app/Filament/Pages/InventoryCoverage.php',
|
||||
'app/Filament/Widgets/Inventory/InventoryKpiHeader.php',
|
||||
'app/Filament/Pages/Monitoring/Operations.php',
|
||||
'app/Filament/Pages/Operations/TenantlessOperationRunViewer.php',
|
||||
'app/Filament/Widgets/Operations/OperationsKpiHeader.php',
|
||||
'app/Filament/Resources/OperationRunResource.php',
|
||||
'app/Filament/Resources/PolicyResource.php',
|
||||
'app/Filament/Resources/BackupScheduleResource.php',
|
||||
'app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php',
|
||||
'app/Filament/Resources/BackupSetResource.php',
|
||||
'app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php',
|
||||
'app/Filament/Resources/BackupSetResource/Pages/ViewBackupSet.php',
|
||||
'app/Filament/Resources/FindingResource.php',
|
||||
'app/Filament/Resources/FindingResource/Pages/ListFindings.php',
|
||||
'app/Filament/Resources/InventoryItemResource.php',
|
||||
'app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php',
|
||||
'app/Filament/Resources/PolicyVersionResource.php',
|
||||
'app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php',
|
||||
'app/Filament/Resources/ProviderConnectionResource.php',
|
||||
'app/Filament/Resources/AlertDeliveryResource.php',
|
||||
'app/Filament/Resources/AlertDeliveryResource/Pages/ListAlertDeliveries.php',
|
||||
'app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php',
|
||||
'app/Filament/Resources/EntraGroupResource/Pages/ViewEntraGroup.php',
|
||||
'app/Filament/Pages/Monitoring/AuditLog.php',
|
||||
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php',
|
||||
'app/Filament/Resources/RestoreRunResource.php',
|
||||
'app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php',
|
||||
];
|
||||
}
|
||||
|
||||
@ -23,6 +42,7 @@ function adminTenantResolverExceptionFiles(): array
|
||||
'app/Http/Controllers/SelectTenantController.php',
|
||||
'app/Support/Middleware/EnsureFilamentTenantSelected.php',
|
||||
'app/Filament/Resources/EntraGroupResource.php',
|
||||
'app/Filament/Concerns/ResolvesPanelTenantContext.php',
|
||||
];
|
||||
}
|
||||
|
||||
@ -53,7 +73,7 @@ function adminTenantResolverExceptionFiles(): array
|
||||
});
|
||||
|
||||
it('documents the approved panel-native exception inventory', function (): void {
|
||||
$notes = file_get_contents(base_path('docs/research/canonical-tenant-context-resolution.md'));
|
||||
$notes = file_get_contents(base_path('docs/research/admin-canonical-tenant-rollout.md'));
|
||||
|
||||
expect($notes)->not->toBeFalse();
|
||||
|
||||
|
||||
@ -158,6 +158,63 @@
|
||||
expect($missing)->toBeEmpty('Missing persistence declarations: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
it('syncs canonical admin tenant filter state on the persisted admin list surfaces', function (): void {
|
||||
$patternByPath = [
|
||||
'app/Filament/Resources/BackupScheduleResource/Pages/ListBackupSchedules.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/FindingResource/Pages/ListFindings.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/PolicyVersionResource/Pages/ListPolicyVersions.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
'app/Filament/Pages/Monitoring/AuditLog.php' => [
|
||||
'CanonicalAdminTenantFilterState::class',
|
||||
'->sync(',
|
||||
],
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($patternByPath as $relativePath => $patterns) {
|
||||
$contents = file_get_contents(base_path($relativePath));
|
||||
|
||||
if (! is_string($contents)) {
|
||||
$missing[] = $relativePath;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($patterns as $pattern) {
|
||||
if (! str_contains($contents, $pattern)) {
|
||||
$missing[] = "{$relativePath} ({$pattern})";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($missing)->toBeEmpty('Missing canonical admin tenant filter sync coverage: '.implode(', ', $missing));
|
||||
});
|
||||
|
||||
it('uses shared archived and date-range presets on the repeated soft-delete filter surfaces', function (): void {
|
||||
$patternByPath = [
|
||||
'app/Filament/Resources/PolicyVersionResource.php' => [
|
||||
|
||||
@ -89,7 +89,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => $workspaceId])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Filtered by tenant: '.$tenantA->name)
|
||||
->assertSee('Tenant scope: '.$tenantA->name)
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('TenantA')
|
||||
->assertDontSee('Inventory sync');
|
||||
|
||||
@ -5,11 +5,16 @@
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
|
||||
function operationRunService(): OperationRunService
|
||||
{
|
||||
return app(OperationRunService::class);
|
||||
}
|
||||
|
||||
it('creates a new operation run', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$run = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $user);
|
||||
|
||||
@ -27,7 +32,7 @@
|
||||
it('reuses an active run (idempotent)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||
@ -39,7 +44,7 @@
|
||||
it('dedupes assignment run identities by type and scope', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$fetchRunA = $service->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
@ -112,7 +117,7 @@
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userA);
|
||||
$runB = $service->ensureRun($tenant, 'test.action', ['scope' => 'full'], $userB);
|
||||
@ -124,7 +129,7 @@
|
||||
|
||||
it('hashes inputs deterministically regardless of key order', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRun($tenant, 'test.action', ['b' => 2, 'a' => 1]);
|
||||
$runB = $service->ensureRun($tenant, 'test.action', ['a' => 1, 'b' => 2]);
|
||||
@ -134,7 +139,7 @@
|
||||
|
||||
it('hashes list inputs deterministically regardless of list order', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRun($tenant, 'test.action', ['ids' => [2, 1]]);
|
||||
$runB = $service->ensureRun($tenant, 'test.action', ['ids' => [1, 2]]);
|
||||
@ -144,7 +149,7 @@
|
||||
|
||||
it('handles unique-index race collisions by returning the active run', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$fired = false;
|
||||
|
||||
@ -187,7 +192,7 @@
|
||||
it('creates a new run after the previous one completed', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRun($tenant, 'test.action', ['scope' => 'full']);
|
||||
$runA->update(['status' => 'completed']);
|
||||
@ -201,7 +206,7 @@
|
||||
it('reuses the same run even after completion when using strict identity', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$runA = $service->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
@ -225,7 +230,7 @@
|
||||
|
||||
it('handles strict unique-index race collisions by returning the existing run', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$fired = false;
|
||||
|
||||
@ -273,7 +278,7 @@
|
||||
it('updates run lifecycle fields and summaries', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$run = $service->ensureRun($tenant, 'test.action', []);
|
||||
|
||||
@ -293,7 +298,7 @@
|
||||
|
||||
it('sanitizes failure messages and redacts obvious secrets', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$service = new OperationRunService;
|
||||
$service = operationRunService();
|
||||
|
||||
$run = $service->ensureRun($tenant, 'test.action', []);
|
||||
|
||||
|
||||
@ -326,7 +326,7 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])->get(route('admin.operations.index'))
|
||||
->assertOk()
|
||||
->assertSee('Filtered by tenant: '.$tenant->name)
|
||||
->assertSee('Tenant scope: '.$tenant->name)
|
||||
->assertDontSee('Scope: Tenant')
|
||||
->assertDontSee('Scope: Workspace')
|
||||
->assertDontSee('All tenants');
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
use App\Models\User;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
|
||||
it('Spec081 redirects non-members on provider connection management routes', function (): void {
|
||||
it('Spec081 returns 404 for non-members on provider connection management routes', function (): void {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
@ -25,7 +25,7 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id,
|
||||
])
|
||||
->get(ProviderConnectionResource::getUrl('edit', ['record' => $connection], tenant: $tenant))
|
||||
->assertRedirect();
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('Spec081 returns 403 for members without provider manage capability', function (): void {
|
||||
|
||||
@ -37,7 +37,7 @@
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('redirects non-workspace-members with stale session', function (): void {
|
||||
it('returns 404 for non-workspace-members with stale session', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
@ -49,7 +49,7 @@
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
])
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertRedirect();
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 when the route tenant is invalid instead of falling back to the current tenant context', function (): void {
|
||||
|
||||
@ -63,20 +63,20 @@
|
||||
$response->assertDontSee('>Governance</span>', false);
|
||||
});
|
||||
|
||||
it('redirects non-workspace-members with stale session (FR-002 regression guard)', function (): void {
|
||||
it('returns 404 for non-workspace-members with stale session (FR-002 regression guard)', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$workspace = Workspace::factory()->create();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
]);
|
||||
|
||||
// User is NOT a workspace member — middleware clears stale session and redirects
|
||||
// User is NOT a workspace member, so the canonical route is deny-as-not-found.
|
||||
$this->actingAs($user)
|
||||
->withSession([
|
||||
WorkspaceContext::SESSION_KEY => (int) $workspace->getKey(),
|
||||
])
|
||||
->get("/admin/tenants/{$tenant->external_id}/required-permissions")
|
||||
->assertRedirect();
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('returns 404 for workspace members without tenant entitlement after middleware change (FR-002 regression guard)', function (): void {
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
@ -196,3 +197,50 @@
|
||||
&& $job->operationRun->getKey() === $operationRun?->getKey();
|
||||
});
|
||||
});
|
||||
|
||||
test('admin restore run wizard ignores prefill query params for backup sets outside the canonical tenant', function () {
|
||||
$tenantA = Tenant::factory()->create([
|
||||
'name' => 'Phoenicon',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');
|
||||
|
||||
$tenantB = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $tenantA->workspace_id,
|
||||
'name' => 'YPTW2',
|
||||
'environment' => 'dev',
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $tenantB, user: $user, role: 'owner');
|
||||
|
||||
$policy = Policy::factory()->for($tenantB)->create([
|
||||
'external_id' => 'policy-admin-prefill-wrong-tenant',
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenantB)->create([
|
||||
'status' => 'completed',
|
||||
'item_count' => 1,
|
||||
]);
|
||||
|
||||
$backupItem = BackupItem::factory()->for($tenantB)->for($backupSet)->for($policy)->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
Filament::setCurrentPanel('admin');
|
||||
Filament::setTenant(null, true);
|
||||
Filament::bootCurrentPanel();
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenantA->workspace_id);
|
||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
||||
(string) $tenantA->workspace_id => (int) $tenantA->getKey(),
|
||||
]);
|
||||
|
||||
$component = Livewire::withQueryParams([
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'scope_mode' => 'selected',
|
||||
'backup_item_ids' => [$backupItem->id],
|
||||
])->test(CreateRestoreRun::class);
|
||||
|
||||
expect($component->get('data.backup_set_id'))->toBeNull();
|
||||
expect($component->get('data.scope_mode'))->not->toBe('selected');
|
||||
expect($component->get('data.backup_item_ids'))->toBe([]);
|
||||
});
|
||||
|
||||
@ -32,14 +32,14 @@
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('redirects non-members on the workspace-managed tenants index', function (): void {
|
||||
it('returns 404 for non-members on the workspace-managed tenants index', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/tenants')
|
||||
->assertRedirect();
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('allows workspace members to open the workspace-managed tenant view route', function (): void {
|
||||
@ -61,14 +61,14 @@
|
||||
->assertSee('/admin/provider-connections?tenant_id='.$tenant->external_id, false);
|
||||
});
|
||||
|
||||
it('redirects non-members on the workspace-managed tenant view route', function (): void {
|
||||
it('returns 404 for non-members on the workspace-managed tenant view route', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get("/admin/tenants/{$tenant->external_id}")
|
||||
->assertRedirect();
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
it('exposes memberships management under workspace scope', function (): void {
|
||||
|
||||
@ -24,7 +24,7 @@
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('Filtered by tenant: '.$tenant->name)
|
||||
->assertSee('Tenant scope: '.$tenant->name)
|
||||
->assertSee('Back to '.$tenant->name)
|
||||
->assertSee('Show all tenants');
|
||||
});
|
||||
@ -79,5 +79,5 @@
|
||||
->get('/admin/operations')
|
||||
->assertOk()
|
||||
->assertSee('All tenants')
|
||||
->assertDontSee('Filtered by tenant: '.$tenant->name);
|
||||
->assertDontSee('Tenant scope: '.$tenant->name);
|
||||
});
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('lists platform auth access logs with success and failure statuses plus break-glass actions', function () {
|
||||
it('lists platform auth access logs with success and failed statuses plus break-glass actions', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'external_id' => 'platform',
|
||||
]);
|
||||
@ -28,7 +28,7 @@
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'action' => 'platform.auth.login',
|
||||
'status' => 'failure',
|
||||
'status' => 'failed',
|
||||
'metadata' => ['attempted_email' => 'operator@tenantpilot.io'],
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
@ -64,7 +64,7 @@
|
||||
->assertSuccessful()
|
||||
->assertSee('platform.auth.login')
|
||||
->assertSee('success')
|
||||
->assertSee('failure')
|
||||
->assertSee('failed')
|
||||
->assertSee('platform.break_glass.enter')
|
||||
->assertDontSee('platform.unrelated.event');
|
||||
});
|
||||
|
||||
@ -363,3 +363,35 @@ function ensureDefaultProviderConnection(Tenant $tenant, string $provider = 'mic
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<int, array<string, mixed>> $assignments
|
||||
* @param array<string, mixed> $scopeTags
|
||||
* @param array<string, array<string, string>> $secretFingerprints
|
||||
*/
|
||||
function expectedPolicyVersionContentHash(
|
||||
array $snapshot,
|
||||
string $policyType,
|
||||
?string $platform = null,
|
||||
array $assignments = [],
|
||||
array $scopeTags = [],
|
||||
array $secretFingerprints = [],
|
||||
?int $redactionVersion = 1,
|
||||
): string {
|
||||
return app(\App\Services\Drift\DriftHasher::class)->hashNormalized([
|
||||
'settings' => app(\App\Services\Drift\Normalizers\SettingsNormalizer::class)->normalizeForDiff(
|
||||
$snapshot,
|
||||
$policyType,
|
||||
$platform,
|
||||
),
|
||||
'assignments' => app(\App\Services\Drift\Normalizers\AssignmentsNormalizer::class)->normalizeForDiff($assignments),
|
||||
'scope_tag_ids' => app(\App\Services\Drift\Normalizers\ScopeTagsNormalizer::class)->normalizeIds($scopeTags),
|
||||
'secret_fingerprints' => [
|
||||
'snapshot' => is_array($secretFingerprints['snapshot'] ?? null) ? $secretFingerprints['snapshot'] : [],
|
||||
'assignments' => is_array($secretFingerprints['assignments'] ?? null) ? $secretFingerprints['assignments'] : [],
|
||||
'scope_tags' => is_array($secretFingerprints['scope_tags'] ?? null) ? $secretFingerprints['scope_tags'] : [],
|
||||
],
|
||||
'redaction_version' => $redactionVersion,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
$policy->load('tenant');
|
||||
|
||||
$service = new VersionService(
|
||||
auditLogger: new AuditLogger,
|
||||
auditLogger: app(AuditLogger::class),
|
||||
snapshotService: Mockery::mock(PolicySnapshotService::class),
|
||||
snapshotRedactor: new PolicySnapshotRedactor,
|
||||
assignmentFetcher: Mockery::mock(AssignmentFetcher::class),
|
||||
|
||||
@ -37,3 +37,14 @@
|
||||
expect($sanitized)->toContain('passwordMinimumLength');
|
||||
expect($sanitized)->not->toContain('super-secret');
|
||||
});
|
||||
|
||||
it('redacts email domains that survive token redaction boundaries', function (): void {
|
||||
$message = 'Authorization: Bearer highly-sensitive-token-for-user@example.com';
|
||||
|
||||
$sanitized = RunFailureSanitizer::sanitizeMessage($message);
|
||||
|
||||
expect($sanitized)
|
||||
->not->toContain('Bearer')
|
||||
->not->toContain('@example.com')
|
||||
->toContain('[REDACTED_EMAIL]');
|
||||
});
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
'alert_events_produced' => 0,
|
||||
]);
|
||||
|
||||
expect($line)->toBe('Report deduped: 1 · Findings unchanged: 10');
|
||||
expect($line)->toBe('Findings unchanged: 10 · Report deduped: 1');
|
||||
});
|
||||
|
||||
it('returns null when all values are zero', function () {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user