diff --git a/.specify/memory/constitution.md b/.specify/memory/constitution.md index b1c0c79..14cf3a6 100644 --- a/.specify/memory/constitution.md +++ b/.specify/memory/constitution.md @@ -141,7 +141,7 @@ ### Spec-First Workflow ## Quality Gates - Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. -- Run `./vendor/bin/pint --dirty` before finalizing. +- Run `./vendor/bin/sail bin pint --dirty` before finalizing. ## Governance diff --git a/GEMINI.md b/GEMINI.md index b0aed75..1ed6555 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -896,9 +896,9 @@ ### Replaced Utilities ## Recent Changes +- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 - 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 - 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0` -- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4 ## Active Technologies -- PostgreSQL (with a new `platform_users` table) (064-auth-structure) +- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1) diff --git a/app/Filament/Concerns/ScopesGlobalSearchToTenant.php b/app/Filament/Concerns/ScopesGlobalSearchToTenant.php new file mode 100644 index 0000000..3325c7b --- /dev/null +++ b/app/Filament/Concerns/ScopesGlobalSearchToTenant.php @@ -0,0 +1,44 @@ +hasTenancy()) { + $query->withoutGlobalScope($panel->getTenancyScopeName()); + } + } + + $tenant = Filament::getTenant(); + + if (! $tenant instanceof Model) { + return $query->whereRaw('1 = 0'); + } + + $user = auth()->user(); + + if (! $user || ! method_exists($user, 'canAccessTenant') || ! $user->canAccessTenant($tenant)) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereBelongsTo($tenant, static::$globalSearchTenantRelationship); + } +} diff --git a/app/Filament/Pages/DriftLanding.php b/app/Filament/Pages/DriftLanding.php index 7c9c3fa..0669388 100644 --- a/app/Filament/Pages/DriftLanding.php +++ b/app/Filament/Pages/DriftLanding.php @@ -13,6 +13,7 @@ use App\Services\Drift\DriftRunSelector; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; @@ -20,6 +21,7 @@ use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Pages\Page; +use Illuminate\Support\Facades\Gate; use UnitEnum; class DriftLanding extends Page @@ -173,7 +175,7 @@ public function mount(): void } } - if (! $user->canSyncTenant($tenant)) { + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { $this->state = 'blocked'; $this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.'; diff --git a/app/Filament/Pages/Tenancy/RegisterTenant.php b/app/Filament/Pages/Tenancy/RegisterTenant.php index c8b7855..06def3d 100644 --- a/app/Filament/Pages/Tenancy/RegisterTenant.php +++ b/app/Filament/Pages/Tenancy/RegisterTenant.php @@ -5,11 +5,12 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Intune\AuditLogger; -use App\Support\TenantRole; +use App\Support\Auth\Capabilities; use Filament\Forms; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Schemas\Schema; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Gate; class RegisterTenant extends BaseRegisterTenant { @@ -20,7 +21,25 @@ public static function getLabel(): string public static function canView(): bool { - return true; + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); + + if ($tenantIds->isEmpty()) { + return false; + } + + foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { + if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) { + return true; + } + } + + return false; } public function form(Schema $schema): Schema @@ -69,6 +88,8 @@ public function form(Schema $schema): Schema */ protected function handleRegistration(array $data): Model { + abort_unless(static::canView(), 403); + $tenant = Tenant::create($data); $user = auth()->user(); @@ -76,7 +97,7 @@ protected function handleRegistration(array $data): Model if ($user instanceof User) { $user->tenants()->syncWithoutDetaching([ $tenant->getKey() => [ - 'role' => TenantRole::Owner->value, + 'role' => 'owner', 'source' => 'manual', 'created_by_user_id' => $user->getKey(), ], @@ -88,7 +109,7 @@ protected function handleRegistration(array $data): Model context: [ 'metadata' => [ 'user_id' => (int) $user->getKey(), - 'role' => TenantRole::Owner->value, + 'role' => 'owner', 'source' => 'manual', ], ], diff --git a/app/Filament/Resources/BackupScheduleResource.php b/app/Filament/Resources/BackupScheduleResource.php index 53a8721..149464c 100644 --- a/app/Filament/Resources/BackupScheduleResource.php +++ b/app/Filament/Resources/BackupScheduleResource.php @@ -15,6 +15,7 @@ use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\Intune\AuditLogger; use App\Services\OperationRunService; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -22,7 +23,6 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\TenantRole; use BackedEnum; use Carbon\CarbonImmutable; use DateTimeZone; @@ -50,6 +50,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\Bus; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -60,45 +61,63 @@ class BackupScheduleResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; - protected static function currentTenantRole(): ?TenantRole - { - $user = auth()->user(); - - if (! $user instanceof User) { - return null; - } - - return $user->tenantRole(Tenant::current()); - } - public static function canViewAny(): bool { - return static::currentTenantRole() !== null; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_VIEW, $tenant); } public static function canView(Model $record): bool { - return static::currentTenantRole() !== null; + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + return false; + } + + if ($record instanceof BackupSchedule) { + return (int) $record->tenant_id === (int) $tenant->getKey(); + } + + return true; } public static function canCreate(): bool { - return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public static function canEdit(Model $record): bool { - return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public static function canDelete(Model $record): bool { - return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public static function canDeleteAny(): bool { - return static::currentTenantRole()?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public static function form(Schema $schema): Schema @@ -300,11 +319,18 @@ public static function table(Table $table): Table ->label('Run now') ->icon('heroicon-o-play') ->color('success') - ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); - + ->visible(function (): bool { $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); + }) + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant, 403); + abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); + $user = auth()->user(); $userId = auth()->id(); $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); @@ -424,11 +450,18 @@ public static function table(Table $table): Table ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') - ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) - ->action(function (BackupSchedule $record, HasTable $livewire): void { - abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); - + ->visible(function (): bool { $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); + }) + ->action(function (BackupSchedule $record, HasTable $livewire): void { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant, 403); + abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); + $user = auth()->user(); $userId = auth()->id(); $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); @@ -545,9 +578,19 @@ public static function table(Table $table): Table ->send(); }), EditAction::make() - ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ->visible(function (): bool { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + }), DeleteAction::make() - ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ->visible(function (): bool { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + }), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([ @@ -556,9 +599,17 @@ public static function table(Table $table): Table ->label('Run now') ->icon('heroicon-o-play') ->color('success') - ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->visible(function (): bool { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); + }) ->action(function (Collection $records, HasTable $livewire): void { - abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant, 403); + abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); if ($records->isEmpty()) { return; @@ -685,9 +736,17 @@ public static function table(Table $table): Table ->label('Retry') ->icon('heroicon-o-arrow-path') ->color('warning') - ->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) + ->visible(function (): bool { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant); + }) ->action(function (Collection $records, HasTable $livewire): void { - abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403); + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant, 403); + abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403); if ($records->isEmpty()) { return; @@ -811,7 +870,12 @@ public static function table(Table $table): Table } }), DeleteBulkAction::make('bulk_delete') - ->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false), + ->visible(function (): bool { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); + }), ]), ]); } diff --git a/app/Filament/Resources/BackupSetResource.php b/app/Filament/Resources/BackupSetResource.php index 4280ded..ecb62ff 100644 --- a/app/Filament/Resources/BackupSetResource.php +++ b/app/Filament/Resources/BackupSetResource.php @@ -14,6 +14,7 @@ use App\Services\Intune\BackupService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -33,6 +34,7 @@ use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Gate; use UnitEnum; class BackupSetResource extends Resource @@ -43,6 +45,12 @@ class BackupSetResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; + public static function canCreate(): bool + { + return ($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_SYNC, $tenant); + } + public static function form(Schema $schema): Schema { return $schema @@ -87,8 +95,14 @@ public static function table(Table $table): Table ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() - ->visible(fn (BackupSet $record) => $record->trashed()) + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->restore(); $record->items()->withTrashed()->restore(); @@ -113,8 +127,14 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() - ->visible(fn (BackupSet $record) => ! $record->trashed()) + ->visible(fn (BackupSet $record): bool => ! $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->delete(); if ($record->tenant) { @@ -138,8 +158,14 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() - ->visible(fn (BackupSet $record) => $record->trashed()) + ->visible(fn (BackupSet $record): bool => $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) ->action(function (BackupSet $record, AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + if ($record->restoreRuns()->withTrashed()->exists()) { Notification::make() ->title('Cannot force delete backup set') @@ -178,6 +204,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-archive-box-x-mark') ->color('danger') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -212,6 +240,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -258,6 +288,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-uturn-left') ->color('success') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -278,6 +310,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -324,6 +358,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -359,6 +395,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ diff --git a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php index f8e8279..893ed9f 100644 --- a/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php +++ b/app/Filament/Resources/BackupSetResource/Pages/ListBackupSets.php @@ -13,7 +13,9 @@ class ListBackupSets extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->disabled(fn (): bool => ! BackupSetResource::canCreate()) + ->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'), ]; } } diff --git a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php index 9f0ab28..05b7486 100644 --- a/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php +++ b/app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -23,6 +24,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Gate; class BackupItemsRelationManager extends RelationManager { @@ -132,6 +134,10 @@ public function table(Table $table): Table Actions\Action::make('addPolicies') ->label('Add Policies') ->icon('heroicon-o-plus') + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_SYNC, $tenant))) + ->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.') ->modalHeading('Add Policies') ->modalSubmitAction(false) ->modalCancelActionLabel('Close') @@ -173,7 +179,11 @@ public function table(Table $table): Table $tenant = $backupSet->tenant ?? Tenant::current(); - if (! $user->canSyncTenant($tenant)) { + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403); } @@ -252,7 +262,11 @@ public function table(Table $table): Table $tenant = $backupSet->tenant ?? Tenant::current(); - if (! $user->canSyncTenant($tenant)) { + if (! $tenant instanceof Tenant) { + abort(404); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403); } diff --git a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php index edce49f..c2bca06 100644 --- a/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php +++ b/app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php @@ -10,10 +10,12 @@ use App\Models\User; use App\Services\Directory\EntraGroupSelection; use App\Services\OperationRunService; +use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; use Filament\Actions\Action; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Illuminate\Support\Facades\Gate; class ListEntraGroups extends ListRecords { @@ -45,9 +47,43 @@ protected function getHeaderActions(): array return false; } - $role = $user->tenantRole($tenant); + if (! $user->canAccessTenant($tenant)) { + return false; + } - return $role?->canSync() ?? false; + return true; + }) + ->disabled(function (): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); + }) + ->tooltip(function (): ?string { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) + ? null + : 'You do not have permission to sync groups.'; }) ->action(function (): void { $user = auth()->user(); @@ -66,11 +102,7 @@ protected function getHeaderActions(): array abort(403); } - $role = $user->tenantRole($tenant); - - if (! ($role?->canSync() ?? false)) { - abort(403); - } + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403); $selectionKey = EntraGroupSelection::allGroupsV1(); diff --git a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php index 855fbab..012d578 100644 --- a/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php +++ b/app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php @@ -9,8 +9,10 @@ use App\Models\User; use App\Notifications\RunStatusChangedNotification; use App\Services\Directory\EntraGroupSelection; +use App\Support\Auth\Capabilities; use Filament\Actions\Action; use Filament\Resources\Pages\ListRecords; +use Illuminate\Support\Facades\Gate; class ListEntraGroupSyncRuns extends ListRecords { @@ -36,9 +38,11 @@ protected function getHeaderActions(): array return false; } - $role = $user->tenantRole($tenant); + if (! $user->canAccessTenant($tenant)) { + return false; + } - return $role?->canSync() ?? false; + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); }) ->action(function (): void { $user = auth()->user(); @@ -57,11 +61,7 @@ protected function getHeaderActions(): array abort(403); } - $role = $user->tenantRole($tenant); - - if (! ($role?->canSync() ?? false)) { - abort(403); - } + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403); $selectionKey = EntraGroupSelection::allGroupsV1(); diff --git a/app/Filament/Resources/FindingResource.php b/app/Filament/Resources/FindingResource.php index 764768d..e0d1265 100644 --- a/app/Filament/Resources/FindingResource.php +++ b/app/Filament/Resources/FindingResource.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\Drift\DriftFindingDiffBuilder; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use BackedEnum; @@ -25,6 +26,7 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Gate; @@ -40,6 +42,33 @@ class FindingResource extends Resource protected static ?string $navigationLabel = 'Findings'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + } + + public static function canView(Model $record): bool + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + return false; + } + + if ($record instanceof Finding) { + return (int) $record->tenant_id === (int) $tenant->getKey(); + } + + return true; + } + public static function form(Schema $schema): Schema { return $schema; @@ -389,7 +418,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::current()?->getKey(); return parent::getEloquentQuery() ->addSelect([ diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index cb1fbe9..2f07d97 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -8,6 +8,7 @@ use App\Models\Tenant; use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -24,6 +25,8 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Gate; use UnitEnum; class InventoryItemResource extends Resource @@ -38,6 +41,33 @@ class InventoryItemResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + } + + public static function canView(Model $record): bool + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + return false; + } + + if ($record instanceof InventoryItem) { + return (int) $record->tenant_id === (int) $tenant->getKey(); + } + + return true; + } + public static function form(Schema $schema): Schema { return $schema; @@ -225,7 +255,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::current()?->getKey(); return parent::getEloquentQuery() ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) diff --git a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php index d37abf9..56daeaf 100644 --- a/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php +++ b/app/Filament/Resources/InventoryItemResource/Pages/ListInventoryItems.php @@ -11,6 +11,7 @@ use App\Services\Intune\AuditLogger; use App\Services\Inventory\InventorySyncService; use App\Services\OperationRunService; +use App\Support\Auth\Capabilities; use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; @@ -23,6 +24,7 @@ use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; use Filament\Support\Enums\Size; +use Illuminate\Support\Facades\Gate; class ListInventoryItems extends ListRecords { @@ -109,17 +111,57 @@ protected function getHeaderActions(): array return false; } - return $user->canSyncTenant(Tenant::current()); + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return false; + } + + return $user->canAccessTenant($tenant); + }) + ->disabled(function (): bool { + $user = auth()->user(); + if (! $user instanceof User) { + return true; + } + + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); + }) + ->tooltip(function (): ?string { + $user = auth()->user(); + if (! $user instanceof User) { + return null; + } + + $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) + ? null + : 'You do not have permission to start inventory sync.'; }) ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void { $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + abort(404); + } $user = auth()->user(); if (! $user instanceof User) { abort(403, 'Not allowed'); } - if (! $user->canSyncTenant($tenant)) { + if (! $user->canAccessTenant($tenant)) { + abort(404); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403, 'Not allowed'); } diff --git a/app/Filament/Resources/InventorySyncRunResource.php b/app/Filament/Resources/InventorySyncRunResource.php index 044db33..44d8670 100644 --- a/app/Filament/Resources/InventorySyncRunResource.php +++ b/app/Filament/Resources/InventorySyncRunResource.php @@ -6,6 +6,7 @@ use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Models\InventorySyncRun; use App\Models\Tenant; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -19,6 +20,8 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Gate; use UnitEnum; class InventorySyncRunResource extends Resource @@ -35,6 +38,33 @@ class InventorySyncRunResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Inventory'; + public static function canViewAny(): bool + { + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::allows(Capabilities::TENANT_VIEW, $tenant); + } + + public static function canView(Model $record): bool + { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return false; + } + + if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) { + return false; + } + + if ($record instanceof InventorySyncRun) { + return (int) $record->tenant_id === (int) $tenant->getKey(); + } + + return true; + } + public static function getNavigationLabel(): string { return 'Sync History'; @@ -155,7 +185,7 @@ public static function table(Table $table): Table public static function getEloquentQuery(): Builder { - $tenantId = Tenant::current()->getKey(); + $tenantId = Tenant::current()?->getKey(); return parent::getEloquentQuery() ->with('user') diff --git a/app/Filament/Resources/PolicyResource.php b/app/Filament/Resources/PolicyResource.php index ce3d7fc..51dc990 100644 --- a/app/Filament/Resources/PolicyResource.php +++ b/app/Filament/Resources/PolicyResource.php @@ -14,6 +14,7 @@ use App\Services\Intune\PolicyNormalizer; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -40,6 +41,7 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyResource extends Resource @@ -366,8 +368,14 @@ public static function table(Table $table): Table ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() - ->visible(fn (Policy $record) => $record->ignored_at === null) + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (Policy $record, HasTable $livewire) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->ignore(); Notification::make() @@ -380,8 +388,14 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-uturn-left') ->color('success') ->requiresConfirmation() - ->visible(fn (Policy $record) => $record->ignored_at !== null) + ->visible(fn (Policy $record): bool => $record->ignored_at !== null) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (Policy $record) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->unignore(); Notification::make() @@ -406,18 +420,25 @@ public static function table(Table $table): Table } $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return false; + } - return $user->canSyncTenant($tenant); + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); }) ->action(function (Policy $record, HasTable $livewire): void { $tenant = Tenant::current(); $user = auth()->user(); + if (! $tenant instanceof Tenant) { + abort(404); + } + if (! $user instanceof User) { abort(403); } - if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { + if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403); } @@ -461,7 +482,9 @@ public static function table(Table $table): Table Actions\Action::make('export') ->label('Export to Backup') ->icon('heroicon-o-archive-box-arrow-down') - ->visible(fn (Policy $record) => $record->ignored_at === null) + ->visible(fn (Policy $record): bool => $record->ignored_at === null) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->form([ Forms\Components\TextInput::make('backup_name') ->label('Backup Name') @@ -476,6 +499,8 @@ public static function table(Table $table): Table abort(403); } + abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $ids = [(int) $record->getKey()]; /** @var BulkSelectionIdentity $selection */ @@ -533,6 +558,16 @@ public static function table(Table $table): Table return $value === 'ignored'; }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); + }) ->form(function (Collection $records) { if ($records->count() >= 20) { return [ @@ -558,6 +593,8 @@ public static function table(Table $table): Table return; } + abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); @@ -616,6 +653,16 @@ public static function table(Table $table): Table return ! in_array($value, [null, 'ignored'], true); }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); + }) ->action(function (Collection $records, HasTable $livewire) { $tenant = Tenant::current(); $user = auth()->user(); @@ -626,6 +673,8 @@ public static function table(Table $table): Table abort(403); } + abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); @@ -704,8 +753,11 @@ public static function table(Table $table): Table } $tenant = Tenant::current(); + if (! $tenant instanceof Tenant) { + return true; + } - if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { + if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { return true; } @@ -719,11 +771,15 @@ public static function table(Table $table): Table $user = auth()->user(); $count = $records->count(); + if (! $tenant instanceof Tenant) { + abort(404); + } + if (! $user instanceof User) { abort(403); } - if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { + if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403); } @@ -777,6 +833,16 @@ public static function table(Table $table): Table BulkAction::make('bulk_export') ->label('Export to Backup') ->icon('heroicon-o-archive-box-arrow-down') + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant); + }) ->form([ Forms\Components\TextInput::make('backup_name') ->label('Backup Name') @@ -793,6 +859,8 @@ public static function table(Table $table): Table abort(403); } + abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BulkSelectionIdentity $selection */ $selection = app(BulkSelectionIdentity::class); diff --git a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php index 36db4e6..c13d94a 100644 --- a/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php +++ b/app/Filament/Resources/PolicyResource/Pages/ListPolicies.php @@ -7,12 +7,14 @@ use App\Models\Tenant; use App\Models\User; use App\Services\OperationRunService; +use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; use Filament\Actions; use Filament\Notifications\Notification; use Filament\Resources\Pages\ListRecords; +use Illuminate\Support\Facades\Gate; class ListPolicies extends ListRecords { @@ -35,7 +37,28 @@ protected function getHeaderActions(): array $tenant = Tenant::current(); - return $user->canSyncTenant($tenant); + return $tenant instanceof Tenant + && $user->canAccessTenant($tenant); + }) + ->disabled(function (): bool { + $user = auth()->user(); + $tenant = Tenant::current(); + + return ! ($user instanceof User + && $tenant instanceof Tenant + && Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); + }) + ->tooltip(function (): ?string { + $user = auth()->user(); + $tenant = Tenant::current(); + + if (! ($user instanceof User && $tenant instanceof Tenant)) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant) + ? null + : 'You do not have permission to sync policies.'; }) ->action(function (self $livewire): void { $tenant = Tenant::current(); @@ -45,7 +68,15 @@ protected function getHeaderActions(): array abort(403); } - if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { + if (! $tenant instanceof Tenant) { + abort(403); + } + + if (! $user->canAccessTenant($tenant)) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { abort(403); } diff --git a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php index 7885ded..b3778d4 100644 --- a/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php +++ b/app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php @@ -6,6 +6,7 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Intune\RestoreService; +use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use Filament\Actions; @@ -14,6 +15,7 @@ use Filament\Resources\RelationManagers\RelationManager; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Support\Facades\Gate; class VersionsRelationManager extends RelationManager { @@ -42,6 +44,8 @@ public function table(Table $table): Table ->color('danger') ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') + ->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)) ->requiresConfirmation() ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalSubheading('Creates a restore run using this policy version snapshot.') @@ -53,6 +57,8 @@ public function table(Table $table): Table ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) { $tenant = Tenant::current(); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + if ($record->tenant_id !== $tenant->id) { Notification::make() ->title('Policy version belongs to a different tenant') diff --git a/app/Filament/Resources/PolicyVersionResource.php b/app/Filament/Resources/PolicyVersionResource.php index 4e5a338..681625c 100644 --- a/app/Filament/Resources/PolicyVersionResource.php +++ b/app/Filament/Resources/PolicyVersionResource.php @@ -16,6 +16,7 @@ use App\Services\Intune\VersionDiff; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeRenderer; use App\Support\OperationRunLinks; @@ -38,6 +39,7 @@ use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use UnitEnum; class PolicyVersionResource extends Resource @@ -210,8 +212,19 @@ public static function table(Table $table): Table ->label('Restore via Wizard') ->icon('heroicon-o-arrow-path-rounded-square') ->color('primary') - ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') - ->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') + ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only' + || ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) + ->tooltip(function (PolicyVersion $record): ?string { + if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) { + return 'You do not have permission to create restore runs.'; + } + + if (($record->metadata['source'] ?? null) === 'metadata_only') { + return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).'; + } + + return null; + }) ->requiresConfirmation() ->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.') @@ -219,6 +232,8 @@ public static function table(Table $table): Table $tenant = Tenant::current(); $user = auth()->user(); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + if (! $tenant || $record->tenant_id !== $tenant->id) { Notification::make() ->title('Policy version belongs to a different tenant') @@ -304,7 +319,35 @@ public static function table(Table $table): Table ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => ! $record->trashed()) + ->disabled(function (PolicyVersion $record): bool { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (PolicyVersion $record): ?string { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; + + abort_unless($user instanceof User && $tenant instanceof Tenant, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->delete(); if ($record->tenant) { @@ -329,7 +372,35 @@ public static function table(Table $table): Table ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->disabled(function (PolicyVersion $record): bool { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (PolicyVersion $record): ?string { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; + + abort_unless($user instanceof User && $tenant instanceof Tenant, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, @@ -355,7 +426,35 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() ->visible(fn (PolicyVersion $record) => $record->trashed()) + ->disabled(function (PolicyVersion $record): bool { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (PolicyVersion $record): ?string { + $user = auth()->user(); + $tenant = $record->tenant; + + if (! $user instanceof User || ! $tenant instanceof Tenant) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->action(function (PolicyVersion $record, AuditLogger $auditLogger) { + $user = auth()->user(); + $tenant = $record->tenant; + + abort_unless($user instanceof User && $tenant instanceof Tenant, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->restore(); if ($record->tenant) { @@ -392,6 +491,28 @@ public static function table(Table $table): Table return $isOnlyTrashed; }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->form(function (Collection $records) { $fields = [ Forms\Components\TextInput::make('retention_days') @@ -427,6 +548,9 @@ public static function table(Table $table): Table return; } + abort_unless($user instanceof User, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -499,6 +623,28 @@ public static function table(Table $table): Table return ! $isOnlyTrashed; }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->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) { @@ -511,6 +657,9 @@ public static function table(Table $table): Table return; } + abort_unless($user instanceof User, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -565,6 +714,28 @@ public static function table(Table $table): Table return ! $isOnlyTrashed; }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant) + ? null + : 'You do not have permission to manage policy versions.'; + }) ->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?") ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.') ->form([ @@ -586,6 +757,9 @@ public static function table(Table $table): Table return; } + abort_unless($user instanceof User, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ diff --git a/app/Filament/Resources/ProviderConnectionResource.php b/app/Filament/Resources/ProviderConnectionResource.php index dce9cff..6e2377f 100644 --- a/app/Filament/Resources/ProviderConnectionResource.php +++ b/app/Filament/Resources/ProviderConnectionResource.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources; +use App\Filament\Concerns\ScopesGlobalSearchToTenant; use App\Filament\Resources\ProviderConnectionResource\Pages; use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderConnectionHealthCheckJob; @@ -13,6 +14,7 @@ use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -32,6 +34,8 @@ class ProviderConnectionResource extends Resource { + use ScopesGlobalSearchToTenant; + protected static bool $isScopedToTenant = false; protected static ?string $model = ProviderConnection::class; @@ -51,17 +55,17 @@ public static function form(Schema $schema): Schema TextInput::make('display_name') ->label('Display name') ->required() - ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) ->maxLength(255), TextInput::make('entra_tenant_id') ->label('Entra tenant ID') ->required() ->maxLength(255) - ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) ->rules(['uuid']), Toggle::make('is_default') ->label('Default connection') - ->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) + ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) ->helperText('Exactly one default connection is required per tenant/provider.'), TextInput::make('status') ->label('Status') @@ -148,15 +152,49 @@ public static function table(Table $table): Table ->label('Check connection') ->icon('heroicon-o-check-badge') ->color('success') - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) - && $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + ->visible(function (ProviderConnection $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + if (! $tenant instanceof Tenant) { + return false; + } + return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return true; + } + + return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -220,15 +258,49 @@ public static function table(Table $table): Table ->label('Inventory sync') ->icon('heroicon-o-arrow-path') ->color('info') - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) - && $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + ->visible(function (ProviderConnection $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + if (! $tenant instanceof Tenant) { + return false; + } + return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return true; + } + + return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -292,15 +364,49 @@ public static function table(Table $table): Table ->label('Compliance snapshot') ->icon('heroicon-o-shield-check') ->color('info') - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) - && $record->status !== 'disabled') - ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + ->visible(function (ProviderConnection $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); + if (! $tenant instanceof Tenant) { + return false; + } + return $user->canAccessTenant($tenant) && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return true; + } + + return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + + if (! $tenant instanceof Tenant) { + return null; + } + + return Gate::allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) + ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { + $tenant = Tenant::current(); $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -364,13 +470,13 @@ public static function table(Table $table): Table ->label('Set as default') ->icon('heroicon-o-star') ->color('primary') - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) && $record->status !== 'disabled' && ! $record->is_default) ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $record->makeDefault(); @@ -407,7 +513,7 @@ public static function table(Table $table): Table ->icon('heroicon-o-key') ->color('primary') ->modalDescription('Client secret is stored encrypted and will never be shown again.') - ->visible(fn (): bool => Gate::allows('provider.manage', Tenant::current())) + ->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())) ->form([ TextInput::make('client_id') ->label('Client ID') @@ -422,7 +528,7 @@ public static function table(Table $table): Table ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $credentials->upsertClientSecretCredential( connection: $record, @@ -462,12 +568,12 @@ public static function table(Table $table): Table ->label('Enable connection') ->icon('heroicon-o-play') ->color('success') - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) && $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $hadCredentials = $record->credential()->exists(); $status = $hadCredentials ? 'connected' : 'needs_consent'; @@ -527,12 +633,12 @@ public static function table(Table $table): Table ->icon('heroicon-o-archive-box-x-mark') ->color('danger') ->requiresConfirmation() - ->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current()) + ->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()) && $record->status !== 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $previousStatus = (string) $record->status; diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php index 18340d9..bccec5d 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/EditProviderConnection.php @@ -13,6 +13,7 @@ use App\Services\Intune\AuditLogger; use App\Services\Providers\CredentialManager; use App\Services\Providers\ProviderOperationStartGate; +use App\Support\Auth\Capabilities; use App\Support\OperationRunLinks; use Filament\Actions; use Filament\Actions\Action; @@ -119,7 +120,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-eye') ->color('gray') ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.view', $tenant) + && Gate::allows(Capabilities::PROVIDER_VIEW, $tenant) && OperationRun::query() ->where('tenant_id', $tenant->getKey()) ->where('type', 'provider.connection.check') @@ -150,16 +151,45 @@ protected function getHeaderActions(): array ->label('Check connection') ->icon('heroicon-o-check-badge') ->color('success') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.run', $tenant) - && $record->status !== 'disabled') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); - $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -224,7 +254,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-key') ->color('primary') ->modalDescription('Client secret is stored encrypted and will never be shown again.') - ->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) + ->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) ->form([ TextInput::make('client_id') ->label('Client ID') @@ -239,7 +269,7 @@ protected function getHeaderActions(): array ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $credentials->upsertClientSecretCredential( connection: $record, @@ -280,7 +310,7 @@ protected function getHeaderActions(): array ->icon('heroicon-o-star') ->color('primary') ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.manage', $tenant) + && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) && $record->status !== 'disabled' && ! $record->is_default && ProviderConnection::query() @@ -290,7 +320,7 @@ protected function getHeaderActions(): array ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $record->makeDefault(); @@ -326,16 +356,45 @@ protected function getHeaderActions(): array ->label('Inventory sync') ->icon('heroicon-o-arrow-path') ->color('info') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.run', $tenant) - && $record->status !== 'disabled') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); - $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -399,16 +458,45 @@ protected function getHeaderActions(): array ->label('Compliance snapshot') ->icon('heroicon-o-shield-check') ->color('info') - ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.run', $tenant) - && $record->status !== 'disabled') + ->visible(function (ProviderConnection $record): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $user->canAccessTenant($tenant) + && $record->status !== 'disabled'; + }) + ->disabled(function (): bool { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = Tenant::current(); + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant) + ? null + : 'You do not have permission to run provider operations.'; + }) ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void { $tenant = Tenant::current(); - - abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403); - $user = auth()->user(); + + abort_unless($tenant instanceof Tenant, 404); abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($tenant), 404); + abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403); $initiator = $user; $result = $gate->start( @@ -473,12 +561,12 @@ protected function getHeaderActions(): array ->icon('heroicon-o-play') ->color('success') ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.manage', $tenant) + && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) && $record->status === 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $hadCredentials = $record->credential()->exists(); $status = $hadCredentials ? 'connected' : 'needs_consent'; @@ -539,12 +627,12 @@ protected function getHeaderActions(): array ->color('danger') ->requiresConfirmation() ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant - && Gate::allows('provider.manage', $tenant) + && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant) && $record->status !== 'disabled') ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); $previousStatus = (string) $record->status; @@ -591,7 +679,7 @@ protected function getFormActions(): array { $tenant = Tenant::current(); - if ($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) { + if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) { return parent::getFormActions(); } @@ -604,7 +692,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model { $tenant = Tenant::current(); - abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403); return parent::handleRecordUpdate($record, $data); } diff --git a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php index 9209fca..bf1780c 100644 --- a/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php +++ b/app/Filament/Resources/ProviderConnectionResource/Pages/ListProviderConnections.php @@ -13,7 +13,11 @@ class ListProviderConnections extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())) + ->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()) + ? null + : 'You do not have permission to create provider connections.'), ]; } } diff --git a/app/Filament/Resources/RestoreRunResource.php b/app/Filament/Resources/RestoreRunResource.php index c7b7aac..45f256e 100644 --- a/app/Filament/Resources/RestoreRunResource.php +++ b/app/Filament/Resources/RestoreRunResource.php @@ -21,6 +21,7 @@ use App\Services\Intune\RestoreService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\OperationRunLinks; @@ -49,6 +50,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use UnitEnum; @@ -61,6 +63,12 @@ class RestoreRunResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; + public static function canCreate(): bool + { + return ($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant); + } + public static function form(Schema $schema): Schema { return $schema @@ -753,12 +761,18 @@ public static function table(Table $table): Table && $backupSet !== null && ! $backupSet->trashed(); }) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function ( RestoreRun $record, RestoreService $restoreService, \App\Services\Intune\AuditLogger $auditLogger, HasTable $livewire ) { + $currentTenant = Tenant::current(); + + abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403); + $tenant = $record->tenant; $backupSet = $record->backupSet; @@ -924,8 +938,14 @@ public static function table(Table $table): Table ->color('success') ->icon('heroicon-o-arrow-uturn-left') ->requiresConfirmation() - ->visible(fn (RestoreRun $record) => $record->trashed()) + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $record->restore(); if ($record->tenant) { @@ -949,8 +969,14 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() - ->visible(fn (RestoreRun $record) => ! $record->trashed()) + ->visible(fn (RestoreRun $record): bool => ! $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + if (! $record->isDeletable()) { Notification::make() ->title('Restore run cannot be archived') @@ -984,8 +1010,14 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() - ->visible(fn (RestoreRun $record) => $record->trashed()) + ->visible(fn (RestoreRun $record): bool => $record->trashed()) + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) ->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + if ($record->tenant) { $auditLogger->log( tenant: $record->tenant, @@ -1013,6 +1045,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -1046,6 +1080,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -1092,6 +1128,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-arrow-uturn-left') ->color('success') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -1112,6 +1150,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -1169,6 +1209,8 @@ public static function table(Table $table): Table ->icon('heroicon-o-trash') ->color('danger') ->requiresConfirmation() + ->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant + && Gate::allows(Capabilities::TENANT_DELETE, $tenant))) ->hidden(function (HasTable $livewire): bool { $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $value = $trashedFilterState['value'] ?? null; @@ -1198,6 +1240,8 @@ public static function table(Table $table): Table return; } + abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403); + $initiator = $user instanceof User ? $user : null; /** @var BulkSelectionIdentity $selection */ @@ -1450,6 +1494,8 @@ public static function createRestoreRun(array $data): RestoreRun /** @var Tenant $tenant */ $tenant = Tenant::current(); + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + /** @var BackupSet $backupSet */ $backupSet = BackupSet::findOrFail($data['backup_set_id']); diff --git a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php index b799644..19986f2 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php @@ -5,10 +5,12 @@ use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; use App\Models\Tenant; +use App\Support\Auth\Capabilities; use Filament\Actions\Action; use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\CreateRecord; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Gate; use Livewire\Attributes\On; class CreateRestoreRun extends CreateRecord @@ -17,6 +19,13 @@ class CreateRestoreRun extends CreateRecord protected static string $resource = RestoreRunResource::class; + protected function authorizeAccess(): void + { + $tenant = Tenant::current(); + + abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403); + } + public function getSteps(): array { return RestoreRunResource::getWizardSteps(); diff --git a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php index a6e968d..1868ac5 100644 --- a/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php +++ b/app/Filament/Resources/RestoreRunResource/Pages/ListRestoreRuns.php @@ -13,7 +13,9 @@ class ListRestoreRuns extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->disabled(fn (): bool => ! RestoreRunResource::canCreate()) + ->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'), ]; } } diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index a3aab59..b03a866 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -9,6 +9,7 @@ use App\Jobs\SyncPoliciesJob; use App\Models\Tenant; use App\Models\User; +use App\Services\Auth\RoleCapabilityMap; use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Graph\GraphClientInterface; use App\Services\Intune\AuditLogger; @@ -18,6 +19,7 @@ use App\Services\Intune\TenantPermissionService; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -25,7 +27,6 @@ use App\Support\OperationRunLinks; use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OpsUxBrowserEvents; -use App\Support\TenantRole; use BackedEnum; use Filament\Actions; use Filament\Actions\ActionGroup; @@ -39,8 +40,10 @@ use Filament\Tables; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Throwable; @@ -57,6 +60,84 @@ class TenantResource extends Resource protected static string|UnitEnum|null $navigationGroup = 'Settings'; + public static function canCreate(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return static::userCanManageAnyTenant($user); + } + + public static function canEdit(Model $record): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + } + + public static function canDelete(Model $record): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + } + + public static function canDeleteAny(): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + return static::userCanDeleteAnyTenant($user); + } + + private static function userCanManageAnyTenant(User $user): bool + { + $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); + + if ($tenantIds->isEmpty()) { + return false; + } + + foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { + if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) { + return true; + } + } + + return false; + } + + private static function userCanDeleteAnyTenant(User $user): bool + { + $tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id'); + + if ($tenantIds->isEmpty()) { + return false; + } + + foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) { + if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) { + return true; + } + } + + return false; + } + public static function form(Schema $schema): Schema { // ... [Schema Omitted - No Change] ... @@ -188,8 +269,11 @@ public static function table(Table $table): Table ]), ]) ->actions([ - Actions\ViewAction::make(), ActionGroup::make([ + Actions\Action::make('view') + ->label('View') + ->icon('heroicon-o-eye') + ->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)), Actions\Action::make('syncTenant') ->label('Sync') ->icon('heroicon-o-arrow-path') @@ -206,10 +290,34 @@ public static function table(Table $table): Table return false; } - return $user->canSyncTenant($record); + return $user->canAccessTenant($record); + }) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record); + }) + ->tooltip(function (Tenant $record): ?string { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record) + ? null + : 'You do not have permission to sync this tenant.'; }) ->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void { - // Phase 3: Canonical Operation Run Start + $user = auth()->user(); + abort_unless($user instanceof User, 403); + abort_unless($user->canAccessTenant($record), 404); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record), 403); + /** @var OperationRunService $opService */ $opService = app(OperationRunService::class); @@ -224,6 +332,7 @@ public static function table(Table $table): Table 'scope' => 'full', 'types' => $typeNames, ]; + $opRun = $opService->ensureRun( tenant: $record, type: 'policy.sync', @@ -288,12 +397,40 @@ public static function table(Table $table): Table ->color('primary') ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) ->visible(fn (Tenant $record) => $record->isActive()), - Actions\EditAction::make(), - Actions\RestoreAction::make() + Actions\Action::make('edit') + ->label('Edit') + ->icon('heroicon-o-pencil-square') + ->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record)) + ->disabled(fn (Tenant $record): bool => ! static::canEdit($record)) + ->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'), + Actions\Action::make('restore') ->label('Restore') ->color('success') ->successNotificationTitle('Tenant reactivated') - ->after(function (Tenant $record, AuditLogger $auditLogger) { + ->requiresConfirmation() + ->visible(fn (Tenant $record): bool => $record->trashed()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + }) + ->action(function (Tenant $record, AuditLogger $auditLogger): void { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + abort(403); + } + + $record->restore(); + $auditLogger->log( tenant: $record, action: 'tenant.restored', @@ -303,34 +440,31 @@ public static function table(Table $table): Table context: ['metadata' => ['tenant_id' => $record->tenant_id]] ); }), - Actions\Action::make('makeCurrent') - ->label('Make current') - ->color('success') - ->icon('heroicon-o-check-circle') - ->requiresConfirmation() - ->visible(fn (Tenant $record) => $record->isActive() && ! $record->is_current) - ->action(function (Tenant $record, AuditLogger $auditLogger) { - $record->makeCurrent(); - - $auditLogger->log( - tenant: $record, - action: 'tenant.current_set', - resourceType: 'tenant', - resourceId: (string) $record->id, - status: 'success', - context: ['metadata' => ['tenant_id' => $record->tenant_id]] - ); - - Notification::make() - ->title('Current tenant updated') - ->success() - ->send(); - }), Actions\Action::make('admin_consent') ->label('Admin consent') ->icon('heroicon-o-clipboard-document') ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + }) + ->tooltip(function (Tenant $record): ?string { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record) + ? null + : 'You do not have permission to manage tenant consent.'; + }) ->openUrlInNewTab(), Actions\Action::make('open_in_entra') ->label('Open in Entra') @@ -343,6 +477,16 @@ public static function table(Table $table): Table ->icon('heroicon-o-check-badge') ->color('primary') ->requiresConfirmation() + ->visible(fn (Tenant $record): bool => $record->isActive()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + }) ->action(function ( Tenant $record, TenantConfigService $configService, @@ -350,6 +494,16 @@ public static function table(Table $table): Table RbacHealthService $rbacHealthService, AuditLogger $auditLogger ) { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { + abort(403); + } + static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); }), static::rbacAction(), @@ -358,8 +512,27 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() - ->visible(fn (Tenant $record) => ! $record->trashed()) + ->visible(fn (Tenant $record): bool => ! $record->trashed()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + }) ->action(function (Tenant $record, AuditLogger $auditLogger) { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + abort(403); + } + $record->delete(); $auditLogger->log( @@ -382,12 +555,35 @@ public static function table(Table $table): Table ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() - ->visible(fn (?Tenant $record) => $record?->trashed()) + ->visible(fn (?Tenant $record): bool => (bool) $record?->trashed()) + ->disabled(function (?Tenant $record): bool { + if (! $record instanceof Tenant) { + return true; + } + + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record); + }) ->action(function (?Tenant $record, AuditLogger $auditLogger) { if ($record === null) { return; } + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) { + abort(403); + } + $tenant = Tenant::withTrashed()->find($record->id); if (! $tenant?->trashed()) { @@ -415,7 +611,7 @@ public static function table(Table $table): Table ->success() ->send(); }), - ])->icon('heroicon-o-ellipsis-vertical'), + ]), ]) ->bulkActions([ Actions\BulkAction::make('syncSelected') @@ -431,11 +627,7 @@ public static function table(Table $table): Table } return $user->tenants() - ->whereIn('role', [ - TenantRole::Owner->value, - TenantRole::Manager->value, - TenantRole::Operator->value, - ]) + ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) ->exists(); }) ->authorize(function (): bool { @@ -446,11 +638,7 @@ public static function table(Table $table): Table } return $user->tenants() - ->whereIn('role', [ - TenantRole::Owner->value, - TenantRole::Manager->value, - TenantRole::Operator->value, - ]) + ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC)) ->exists(); }) ->action(function (Collection $records, AuditLogger $auditLogger): void { @@ -462,7 +650,7 @@ public static function table(Table $table): Table $eligible = $records ->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) - ->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant)); + ->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)); if ($eligible->isEmpty()) { Notification::make() @@ -697,7 +885,16 @@ public static function rbacAction(): Actions\Action ->noSearchResultsMessage('No security groups found') ->loadingMessage('Searching groups...'), ]) - ->visible(fn (Tenant $record) => $record->isActive()) + ->visible(fn (Tenant $record): bool => $record->isActive()) + ->disabled(function (Tenant $record): bool { + $user = auth()->user(); + + if (! $user instanceof User) { + return true; + } + + return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record); + }) ->requiresConfirmation() ->action(function ( array $data, @@ -705,6 +902,16 @@ public static function rbacAction(): Actions\Action RbacOnboardingService $service, AuditLogger $auditLogger ) { + $user = auth()->user(); + + if (! $user instanceof User) { + abort(403); + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) { + abort(403); + } + $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId()); $token = Cache::get($cacheKey); diff --git a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php index 6e592c6..6d3bd89 100644 --- a/app/Filament/Resources/TenantResource/Pages/CreateTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/CreateTenant.php @@ -4,7 +4,6 @@ use App\Filament\Resources\TenantResource; use App\Models\User; -use App\Support\TenantRole; use Filament\Resources\Pages\CreateRecord; class CreateTenant extends CreateRecord @@ -20,7 +19,7 @@ protected function afterCreate(): void } $user->tenants()->syncWithoutDetaching([ - $this->record->getKey() => ['role' => TenantRole::Owner->value], + $this->record->getKey() => ['role' => 'owner'], ]); } } diff --git a/app/Filament/Resources/TenantResource/Pages/EditTenant.php b/app/Filament/Resources/TenantResource/Pages/EditTenant.php index 32d53e1..19117cf 100644 --- a/app/Filament/Resources/TenantResource/Pages/EditTenant.php +++ b/app/Filament/Resources/TenantResource/Pages/EditTenant.php @@ -3,8 +3,12 @@ namespace App\Filament\Resources\TenantResource\Pages; use App\Filament\Resources\TenantResource; +use App\Models\Tenant; +use App\Models\User; +use App\Support\Auth\Capabilities; use Filament\Actions; use Filament\Resources\Pages\EditRecord; +use Illuminate\Support\Facades\Gate; class EditTenant extends EditRecord { @@ -14,7 +18,42 @@ protected function getHeaderActions(): array { return [ Actions\ViewAction::make(), - Actions\DeleteAction::make(), + Actions\Action::make('archive') + ->label('Archive') + ->color('danger') + ->requiresConfirmation() + ->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed()) + ->disabled(function (): bool { + $tenant = $this->record; + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return true; + } + + return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant); + }) + ->tooltip(function (): ?string { + $tenant = $this->record; + $user = auth()->user(); + + if (! $tenant instanceof Tenant || ! $user instanceof User) { + return null; + } + + return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant) + ? null + : 'You do not have permission to archive tenants.'; + }) + ->action(function (): void { + $tenant = $this->record; + $user = auth()->user(); + + abort_unless($tenant instanceof Tenant && $user instanceof User, 403); + abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant), 403); + + $tenant->delete(); + }), ]; } } diff --git a/app/Filament/Resources/TenantResource/Pages/ListTenants.php b/app/Filament/Resources/TenantResource/Pages/ListTenants.php index 52ab8c1..48fcad2 100644 --- a/app/Filament/Resources/TenantResource/Pages/ListTenants.php +++ b/app/Filament/Resources/TenantResource/Pages/ListTenants.php @@ -13,7 +13,9 @@ class ListTenants extends ListRecords protected function getHeaderActions(): array { return [ - Actions\CreateAction::make(), + Actions\CreateAction::make() + ->disabled(fn (): bool => ! TenantResource::canCreate()) + ->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'), ]; } } diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php index ee56836..4b84f88 100644 --- a/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php @@ -6,7 +6,7 @@ use App\Models\TenantMembership; use App\Models\User; use App\Services\Auth\TenantMembershipManager; -use App\Support\TenantRole; +use App\Support\Auth\Capabilities; use Filament\Actions; use Filament\Forms; use Filament\Notifications\Notification; @@ -26,10 +26,10 @@ public function table(Table $table): Table ->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->columns([ Tables\Columns\TextColumn::make('user.name') - ->label('User') + ->label(__('User')) ->searchable(), Tables\Columns\TextColumn::make('user.email') - ->label('Email') + ->label(__('Email')) ->toggleable(isToggledHiddenByDefault: true), Tables\Columns\TextColumn::make('role') ->badge() @@ -41,7 +41,7 @@ public function table(Table $table): Table ]) ->headerActions([ Actions\Action::make('add_member') - ->label('Add member') + ->label(__('Add member')) ->icon('heroicon-o-plus') ->visible(function (): bool { $tenant = $this->getOwnerRecord(); @@ -50,22 +50,22 @@ public function table(Table $table): Table return false; } - return Gate::allows('tenant_membership.manage', $tenant); + return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); }) ->form([ Forms\Components\Select::make('user_id') - ->label('User') + ->label(__('User')) ->required() ->searchable() ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), Forms\Components\Select::make('role') - ->label('Role') + ->label(__('Role')) ->required() ->options([ - TenantRole::Owner->value => 'Owner', - TenantRole::Manager->value => 'Manager', - TenantRole::Operator->value => 'Operator', - TenantRole::Readonly->value => 'Readonly', + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), ]), ]) ->action(function (array $data, TenantMembershipManager $manager): void { @@ -80,13 +80,13 @@ public function table(Table $table): Table abort(403); } - if (! Gate::allows('tenant_membership.manage', $tenant)) { + if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { abort(403); } $member = User::query()->find((int) $data['user_id']); if (! $member) { - Notification::make()->title('User not found')->danger()->send(); + Notification::make()->title(__('User not found'))->danger()->send(); return; } @@ -96,12 +96,12 @@ public function table(Table $table): Table tenant: $tenant, actor: $actor, member: $member, - role: TenantRole::from((string) $data['role']), + role: (string) $data['role'], source: 'manual', ); } catch (\Throwable $throwable) { Notification::make() - ->title('Failed to add member') + ->title(__('Failed to add member')) ->body($throwable->getMessage()) ->danger() ->send(); @@ -109,14 +109,15 @@ public function table(Table $table): Table return; } - Notification::make()->title('Member added')->success()->send(); + Notification::make()->title(__('Member added'))->success()->send(); $this->resetTable(); }), ]) ->actions([ Actions\Action::make('change_role') - ->label('Change role') + ->label(__('Change role')) ->icon('heroicon-o-pencil') + ->requiresConfirmation() ->visible(function (): bool { $tenant = $this->getOwnerRecord(); @@ -124,17 +125,17 @@ public function table(Table $table): Table return false; } - return Gate::allows('tenant_membership.manage', $tenant); + return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); }) ->form([ Forms\Components\Select::make('role') - ->label('Role') + ->label(__('Role')) ->required() ->options([ - TenantRole::Owner->value => 'Owner', - TenantRole::Manager->value => 'Manager', - TenantRole::Operator->value => 'Operator', - TenantRole::Readonly->value => 'Readonly', + 'owner' => __('Owner'), + 'manager' => __('Manager'), + 'operator' => __('Operator'), + 'readonly' => __('Readonly'), ]), ]) ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { @@ -149,7 +150,7 @@ public function table(Table $table): Table abort(403); } - if (! Gate::allows('tenant_membership.manage', $tenant)) { + if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { abort(403); } @@ -158,11 +159,11 @@ public function table(Table $table): Table tenant: $tenant, actor: $actor, membership: $record, - newRole: TenantRole::from((string) $data['role']), + newRole: (string) $data['role'], ); } catch (\Throwable $throwable) { Notification::make() - ->title('Failed to change role') + ->title(__('Failed to change role')) ->body($throwable->getMessage()) ->danger() ->send(); @@ -170,11 +171,11 @@ public function table(Table $table): Table return; } - Notification::make()->title('Role updated')->success()->send(); + Notification::make()->title(__('Role updated'))->success()->send(); $this->resetTable(); }), Actions\Action::make('remove') - ->label('Remove') + ->label(__('Remove')) ->color('danger') ->icon('heroicon-o-x-mark') ->requiresConfirmation() @@ -185,7 +186,7 @@ public function table(Table $table): Table return false; } - return Gate::allows('tenant_membership.manage', $tenant); + return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant); }) ->action(function (TenantMembership $record, TenantMembershipManager $manager): void { $tenant = $this->getOwnerRecord(); @@ -199,7 +200,7 @@ public function table(Table $table): Table abort(403); } - if (! Gate::allows('tenant_membership.manage', $tenant)) { + if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) { abort(403); } @@ -207,7 +208,7 @@ public function table(Table $table): Table $manager->removeMember($tenant, $actor, $record); } catch (\Throwable $throwable) { Notification::make() - ->title('Failed to remove member') + ->title(__('Failed to remove member')) ->body($throwable->getMessage()) ->danger() ->send(); @@ -215,7 +216,7 @@ public function table(Table $table): Table return; } - Notification::make()->title('Member removed')->success()->send(); + Notification::make()->title(__('Member removed'))->success()->send(); $this->resetTable(); }), ]) diff --git a/app/Jobs/Operations/TenantSyncWorkerJob.php b/app/Jobs/Operations/TenantSyncWorkerJob.php index d57e21e..dd41fbf 100644 --- a/app/Jobs/Operations/TenantSyncWorkerJob.php +++ b/app/Jobs/Operations/TenantSyncWorkerJob.php @@ -8,11 +8,13 @@ use App\Services\Intune\PolicySyncService; use App\Services\OperationRunService; use App\Services\Operations\TargetScopeConcurrencyLimiter; +use App\Support\Auth\Capabilities; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Gate; use RuntimeException; use Throwable; @@ -94,7 +96,7 @@ public function handle( $user = User::query()->whereKey($this->userId)->first(); - if (! $user instanceof User || ! $user->canSyncTenant($tenant)) { + if (! $user instanceof User || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { $runs->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'skipped' => 1, diff --git a/app/Livewire/BackupSetPolicyPickerTable.php b/app/Livewire/BackupSetPolicyPickerTable.php index db2e721..3882dfa 100644 --- a/app/Livewire/BackupSetPolicyPickerTable.php +++ b/app/Livewire/BackupSetPolicyPickerTable.php @@ -9,6 +9,7 @@ use App\Models\User; use App\Services\OperationRunService; use App\Services\Operations\BulkSelectionIdentity; +use App\Support\Auth\Capabilities; use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeRenderer; use App\Support\Badges\TagBadgeDomain; @@ -26,6 +27,7 @@ use Illuminate\Contracts\View\View; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Str; class BackupSetPolicyPickerTable extends TableComponent @@ -201,7 +203,11 @@ public function table(Table $table): Table return false; } - if (! $user->canSyncTenant($tenant)) { + if (! $tenant instanceof Tenant) { + return false; + } + + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { return false; } @@ -248,7 +254,7 @@ public function table(Table $table): Table return; } - if (! $user->canSyncTenant($tenant)) { + if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) { Notification::make() ->title('Not allowed') ->danger() diff --git a/app/Models/User.php b/app/Models/User.php index c8255ee..214ca75 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,7 +2,7 @@ namespace App\Models; -use App\Support\TenantRole; +use App\Support\Auth\Capabilities; use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\HasDefaultTenant; use Filament\Models\Contracts\HasTenants; @@ -15,6 +15,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Schema; class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants @@ -97,7 +98,7 @@ private function tenantPreferencesTableExists(): bool return $exists ??= Schema::hasTable('user_tenant_preferences'); } - public function tenantRole(Tenant $tenant): ?TenantRole + public function tenantRoleValue(Tenant $tenant): ?string { if (! $this->tenantPivotTableExists()) { return null; @@ -111,14 +112,12 @@ public function tenantRole(Tenant $tenant): ?TenantRole return null; } - return TenantRole::tryFrom($role); + return $role; } - public function canSyncTenant(Tenant $tenant): bool + public function allowsTenantSync(Tenant $tenant): bool { - $role = $this->tenantRole($tenant); - - return $role?->canSync() ?? false; + return Gate::forUser($this)->allows(Capabilities::TENANT_SYNC, $tenant); } public function canAccessTenant(Model $tenant): bool diff --git a/app/Policies/BackupSchedulePolicy.php b/app/Policies/BackupSchedulePolicy.php index 4fb4d78..42731b2 100644 --- a/app/Policies/BackupSchedulePolicy.php +++ b/app/Policies/BackupSchedulePolicy.php @@ -5,42 +5,59 @@ use App\Models\BackupSchedule; use App\Models\Tenant; use App\Models\User; -use App\Support\TenantRole; +use App\Support\Auth\Capabilities; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Support\Facades\Gate; class BackupSchedulePolicy { use HandlesAuthorization; - protected function resolveRole(User $user): ?TenantRole + protected function isTenantMember(User $user, ?Tenant $tenant = null): bool { - $tenant = Tenant::current(); + $tenant ??= Tenant::current(); - return $user->tenantRole($tenant); + return $tenant instanceof Tenant + && Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant); } public function viewAny(User $user): bool { - return $this->resolveRole($user) !== null; + return $this->isTenantMember($user); } public function view(User $user, BackupSchedule $schedule): bool { - return $this->resolveRole($user) !== null; + $tenant = Tenant::current(); + + if (! $this->isTenantMember($user, $tenant)) { + return false; + } + + return (int) $schedule->tenant_id === (int) $tenant->getKey(); } public function create(User $user): bool { - return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public function update(User $user, BackupSchedule $schedule): bool { - return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } public function delete(User $user, BackupSchedule $schedule): bool { - return $this->resolveRole($user)?->canManageBackupSchedules() ?? false; + $tenant = Tenant::current(); + + return $tenant instanceof Tenant + && Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant); } } diff --git a/app/Policies/FindingPolicy.php b/app/Policies/FindingPolicy.php index 6a779e0..ff49225 100644 --- a/app/Policies/FindingPolicy.php +++ b/app/Policies/FindingPolicy.php @@ -5,8 +5,9 @@ use App\Models\Finding; use App\Models\Tenant; use App\Models\User; -use App\Support\TenantRole; +use App\Support\Auth\Capabilities; use Illuminate\Auth\Access\HandlesAuthorization; +use Illuminate\Support\Facades\Gate; class FindingPolicy { @@ -42,7 +43,7 @@ public function update(User $user, Finding $finding): bool { $tenant = Tenant::current(); - if (! $tenant) { + if (! $tenant instanceof Tenant) { return false; } @@ -54,13 +55,6 @@ public function update(User $user, Finding $finding): bool return false; } - $role = $user->tenantRole($tenant); - - return match ($role) { - TenantRole::Owner, - TenantRole::Manager, - TenantRole::Operator => true, - default => false, - }; + return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index f509ec5..d52e8f8 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -31,20 +31,7 @@ public function boot(): void }); }; - foreach ([ - Capabilities::PROVIDER_VIEW, - Capabilities::PROVIDER_MANAGE, - Capabilities::PROVIDER_RUN, - Capabilities::TENANT_MEMBERSHIP_VIEW, - Capabilities::TENANT_MEMBERSHIP_MANAGE, - Capabilities::TENANT_ROLE_MAPPING_VIEW, - Capabilities::TENANT_ROLE_MAPPING_MANAGE, - Capabilities::AUDIT_VIEW, - Capabilities::TENANT_VIEW, - Capabilities::TENANT_MANAGE, - Capabilities::TENANT_DELETE, - Capabilities::TENANT_SYNC, - ] as $capability) { + foreach (Capabilities::all() as $capability) { $defineTenantCapability($capability); } diff --git a/app/Services/Auth/CapabilityResolver.php b/app/Services/Auth/CapabilityResolver.php index 196bbc3..152130b 100644 --- a/app/Services/Auth/CapabilityResolver.php +++ b/app/Services/Auth/CapabilityResolver.php @@ -5,7 +5,9 @@ use App\Models\Tenant; use App\Models\TenantMembership; use App\Models\User; +use App\Support\Auth\Capabilities; use App\Support\TenantRole; +use Illuminate\Support\Facades\Log; /** * Capability Resolver @@ -17,6 +19,8 @@ class CapabilityResolver { private array $resolvedMemberships = []; + private array $loggedDenials = []; + /** * Get the user's role for a tenant */ @@ -36,13 +40,42 @@ public function getRole(User $user, Tenant $tenant): ?TenantRole */ public function can(User $user, Tenant $tenant, string $capability): bool { + if (! Capabilities::isKnown($capability)) { + throw new \InvalidArgumentException("Unknown capability: {$capability}"); + } + $role = $this->getRole($user, $tenant); if ($role === null) { + $this->logDenial($user, $tenant, $capability); + return false; } - return RoleCapabilityMap::hasCapability($role, $capability); + $allowed = RoleCapabilityMap::hasCapability($role, $capability); + + if (! $allowed) { + $this->logDenial($user, $tenant, $capability); + } + + return $allowed; + } + + private function logDenial(User $user, Tenant $tenant, string $capability): void + { + $key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]); + + if (isset($this->loggedDenials[$key])) { + return; + } + + $this->loggedDenials[$key] = true; + + Log::warning('rbac.denied', [ + 'capability' => $capability, + 'tenant_id' => (int) $tenant->getKey(), + 'actor_user_id' => (int) $user->getKey(), + ]); } /** diff --git a/app/Services/Auth/RoleCapabilityMap.php b/app/Services/Auth/RoleCapabilityMap.php index ca3c746..dc2a4a4 100644 --- a/app/Services/Auth/RoleCapabilityMap.php +++ b/app/Services/Auth/RoleCapabilityMap.php @@ -26,6 +26,9 @@ class RoleCapabilityMap Capabilities::TENANT_ROLE_MAPPING_VIEW, Capabilities::TENANT_ROLE_MAPPING_MANAGE, + Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, + Capabilities::TENANT_BACKUP_SCHEDULES_RUN, + Capabilities::PROVIDER_VIEW, Capabilities::PROVIDER_MANAGE, Capabilities::PROVIDER_RUN, @@ -39,10 +42,11 @@ class RoleCapabilityMap Capabilities::TENANT_SYNC, Capabilities::TENANT_MEMBERSHIP_VIEW, - Capabilities::TENANT_MEMBERSHIP_MANAGE, Capabilities::TENANT_ROLE_MAPPING_VIEW, - Capabilities::TENANT_ROLE_MAPPING_MANAGE, + + Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, + Capabilities::TENANT_BACKUP_SCHEDULES_RUN, Capabilities::PROVIDER_VIEW, Capabilities::PROVIDER_MANAGE, @@ -58,6 +62,8 @@ class RoleCapabilityMap Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW, + Capabilities::TENANT_BACKUP_SCHEDULES_RUN, + Capabilities::PROVIDER_VIEW, Capabilities::PROVIDER_RUN, @@ -88,6 +94,24 @@ public static function getCapabilities(TenantRole|string $role): array return self::$roleCapabilities[$roleValue] ?? []; } + /** + * Get all role values that grant a given capability. + * + * @return array + */ + public static function rolesWithCapability(string $capability): array + { + $roles = []; + + foreach (self::$roleCapabilities as $role => $capabilities) { + if (in_array($capability, $capabilities, true)) { + $roles[] = $role; + } + } + + return $roles; + } + /** * Check if a role has a specific capability */ diff --git a/app/Services/Auth/TenantMembershipManager.php b/app/Services/Auth/TenantMembershipManager.php index 12ce073..dd4366f 100644 --- a/app/Services/Auth/TenantMembershipManager.php +++ b/app/Services/Auth/TenantMembershipManager.php @@ -6,7 +6,7 @@ use App\Models\TenantMembership; use App\Models\User; use App\Services\Intune\AuditLogger; -use App\Support\TenantRole; +use App\Support\Audit\AuditActionId; use DomainException; use Illuminate\Support\Facades\DB; @@ -18,10 +18,12 @@ public function addMember( Tenant $tenant, User $actor, User $member, - TenantRole $role, + string $role, string $source = 'manual', ?string $sourceRef = null, ): TenantMembership { + $this->assertValidRole($role); + return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership { $existing = TenantMembership::query() ->where('tenant_id', $tenant->getKey()) @@ -29,9 +31,9 @@ public function addMember( ->first(); if ($existing) { - if ($existing->role !== $role->value) { + if ($existing->role !== $role) { $existing->forceFill([ - 'role' => $role->value, + 'role' => $role, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), @@ -39,12 +41,12 @@ public function addMember( $this->auditLogger->log( tenant: $tenant, - action: 'tenant_membership.role_change', + action: AuditActionId::TenantMembershipRoleChange->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), 'from_role' => $existing->getOriginal('role'), - 'to_role' => $role->value, + 'to_role' => $role, 'source' => $source, ], ], @@ -63,7 +65,7 @@ public function addMember( $membership = TenantMembership::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'user_id' => (int) $member->getKey(), - 'role' => $role->value, + 'role' => $role, 'source' => $source, 'source_ref' => $sourceRef, 'created_by_user_id' => (int) $actor->getKey(), @@ -71,11 +73,11 @@ public function addMember( $this->auditLogger->log( tenant: $tenant, - action: 'tenant_membership.add', + action: AuditActionId::TenantMembershipAdd->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), - 'role' => $role->value, + 'role' => $role, 'source' => $source, ], ], @@ -91,82 +93,132 @@ public function addMember( }); } - public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership + public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, string $newRole): TenantMembership { - return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership { - $membership->refresh(); + $this->assertValidRole($newRole); - if ($membership->tenant_id !== (int) $tenant->getKey()) { - throw new DomainException('Membership belongs to a different tenant.'); - } + try { + return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership { + $membership->refresh(); - $oldRole = $membership->role; + if ($membership->tenant_id !== (int) $tenant->getKey()) { + throw new DomainException('Membership belongs to a different tenant.'); + } - if ($oldRole === $newRole->value) { - return $membership; - } + $oldRole = $membership->role; - $this->guardLastOwnerDemotion($tenant, $membership, $newRole); + if ($oldRole === $newRole) { + return $membership; + } - $membership->forceFill([ - 'role' => $newRole->value, - ])->save(); + $this->guardLastOwnerDemotion($tenant, $membership, $newRole); - $this->auditLogger->log( - tenant: $tenant, - action: 'tenant_membership.role_change', - context: [ - 'metadata' => [ - 'member_user_id' => (int) $membership->user_id, - 'from_role' => $oldRole, - 'to_role' => $newRole->value, + $membership->forceFill([ + 'role' => $newRole, + ])->save(); + + $this->auditLogger->log( + tenant: $tenant, + action: AuditActionId::TenantMembershipRoleChange->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => $oldRole, + 'to_role' => $newRole, + ], ], - ], - actorId: (int) $actor->getKey(), - actorEmail: $actor->email, - actorName: $actor->name, - status: 'success', - resourceType: 'tenant', - resourceId: (string) $tenant->getKey(), - ); + actorId: (int) $actor->getKey(), + actorEmail: $actor->email, + actorName: $actor->name, + status: 'success', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); - return $membership->refresh(); - }); + return $membership->refresh(); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot demote the last remaining owner.') { + $this->auditLogger->log( + tenant: $tenant, + action: AuditActionId::TenantMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'from_role' => (string) $membership->role, + 'attempted_to_role' => $newRole, + ], + ], + actorId: (int) $actor->getKey(), + actorEmail: $actor->email, + actorName: $actor->name, + status: 'blocked', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); + } + + throw $exception; + } } public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void { - DB::transaction(function () use ($tenant, $actor, $membership): void { - $membership->refresh(); + try { + DB::transaction(function () use ($tenant, $actor, $membership): void { + $membership->refresh(); - if ($membership->tenant_id !== (int) $tenant->getKey()) { - throw new DomainException('Membership belongs to a different tenant.'); + if ($membership->tenant_id !== (int) $tenant->getKey()) { + throw new DomainException('Membership belongs to a different tenant.'); + } + + $this->guardLastOwnerRemoval($tenant, $membership); + + $memberUserId = (int) $membership->user_id; + $oldRole = (string) $membership->role; + + $membership->delete(); + + $this->auditLogger->log( + tenant: $tenant, + action: AuditActionId::TenantMembershipRemove->value, + context: [ + 'metadata' => [ + 'member_user_id' => $memberUserId, + 'role' => $oldRole, + ], + ], + actorId: (int) $actor->getKey(), + actorEmail: $actor->email, + actorName: $actor->name, + status: 'success', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); + }); + } catch (DomainException $exception) { + if ($exception->getMessage() === 'You cannot remove the last remaining owner.') { + $this->auditLogger->log( + tenant: $tenant, + action: AuditActionId::TenantMembershipLastOwnerBlocked->value, + context: [ + 'metadata' => [ + 'member_user_id' => (int) $membership->user_id, + 'role' => (string) $membership->role, + 'attempted_action' => 'remove', + ], + ], + actorId: (int) $actor->getKey(), + actorEmail: $actor->email, + actorName: $actor->name, + status: 'blocked', + resourceType: 'tenant', + resourceId: (string) $tenant->getKey(), + ); } - $this->guardLastOwnerRemoval($tenant, $membership); - - $memberUserId = (int) $membership->user_id; - $oldRole = (string) $membership->role; - - $membership->delete(); - - $this->auditLogger->log( - tenant: $tenant, - action: 'tenant_membership.remove', - context: [ - 'metadata' => [ - 'member_user_id' => $memberUserId, - 'role' => $oldRole, - ], - ], - actorId: (int) $actor->getKey(), - actorEmail: $actor->email, - actorName: $actor->name, - status: 'success', - resourceType: 'tenant', - resourceId: (string) $tenant->getKey(), - ); - }); + throw $exception; + } } public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership @@ -175,13 +227,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten tenant: $tenant, actor: $actor, member: $member, - role: TenantRole::Owner, + role: 'owner', source: 'break_glass', ); $this->auditLogger->log( tenant: $tenant, - action: 'tenant_membership.bootstrap_recover', + action: AuditActionId::TenantMembershipBootstrapRecover->value, context: [ 'metadata' => [ 'member_user_id' => (int) $member->getKey(), @@ -200,13 +252,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void { - if ($membership->role !== TenantRole::Owner->value) { + if ($membership->role !== 'owner') { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('role', TenantRole::Owner->value) + ->where('role', 'owner') ->count(); if ($owners <= 1) { @@ -214,23 +266,30 @@ private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $members } } - private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void + private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, string $newRole): void { - if ($membership->role !== TenantRole::Owner->value) { + if ($membership->role !== 'owner') { return; } - if ($newRole === TenantRole::Owner) { + if ($newRole === 'owner') { return; } $owners = TenantMembership::query() ->where('tenant_id', (int) $tenant->getKey()) - ->where('role', TenantRole::Owner->value) + ->where('role', 'owner') ->count(); if ($owners <= 1) { throw new DomainException('You cannot demote the last remaining owner.'); } } + + private function assertValidRole(string $role): void + { + if (! in_array($role, ['owner', 'manager', 'operator', 'readonly'], true)) { + throw new DomainException('Invalid role value.'); + } + } } diff --git a/app/Services/Intune/VersionService.php b/app/Services/Intune/VersionService.php index 01992ef..24e8b5c 100644 --- a/app/Services/Intune/VersionService.php +++ b/app/Services/Intune/VersionService.php @@ -10,6 +10,9 @@ use App\Services\Graph\GroupResolver; use App\Services\Graph\ScopeTagResolver; use Carbon\CarbonImmutable; +use Illuminate\Database\QueryException; +use Illuminate\Database\UniqueConstraintViolationException; +use Illuminate\Support\Facades\DB; class VersionService { @@ -30,23 +33,49 @@ public function captureVersion( ?array $assignments = null, ?array $scopeTags = null, ): PolicyVersion { - $versionNumber = $this->nextVersionNumber($policy); + $version = null; + $versionNumber = null; - $version = PolicyVersion::create([ - 'tenant_id' => $policy->tenant_id, - 'policy_id' => $policy->id, - 'version_number' => $versionNumber, - 'policy_type' => $policy->policy_type, - 'platform' => $policy->platform, - 'created_by' => $createdBy, - 'captured_at' => CarbonImmutable::now(), - 'snapshot' => $payload, - 'metadata' => $metadata, - 'assignments' => $assignments, - 'scope_tags' => $scopeTags, - 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, - 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, - ]); + for ($attempt = 1; $attempt <= 3; $attempt++) { + try { + [$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array { + // Serialize version number allocation per policy. + Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first(); + + $versionNumber = $this->nextVersionNumber($policy); + + $version = PolicyVersion::create([ + 'tenant_id' => $policy->tenant_id, + 'policy_id' => $policy->id, + 'version_number' => $versionNumber, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'created_by' => $createdBy, + 'captured_at' => CarbonImmutable::now(), + 'snapshot' => $payload, + 'metadata' => $metadata, + 'assignments' => $assignments, + 'scope_tags' => $scopeTags, + 'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, + 'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, + ]); + + return [$version, $versionNumber]; + }, 3); + + break; + } catch (QueryException $e) { + if (! $this->isUniqueViolation($e) || $attempt === 3) { + throw $e; + } + + usleep(50_000 * $attempt); + } + } + + if (! $version instanceof PolicyVersion || ! is_int($versionNumber)) { + throw new \RuntimeException('Failed to capture policy version.'); + } $this->auditLogger->log( tenant: $policy->tenant, @@ -65,6 +94,23 @@ public function captureVersion( return $version; } + private function isUniqueViolation(QueryException $exception): bool + { + if ($exception instanceof UniqueConstraintViolationException) { + return true; + } + + $sqlState = $exception->getCode(); + + if (is_string($sqlState) && in_array($sqlState, ['23505', '23000'], true)) { + return true; + } + + $errorInfoState = $exception->errorInfo[0] ?? null; + + return is_string($errorInfoState) && in_array($errorInfoState, ['23505', '23000'], true); + } + public function captureFromGraph( Tenant $tenant, Policy $policy, diff --git a/app/Support/Audit/AuditActionId.php b/app/Support/Audit/AuditActionId.php new file mode 100644 index 0000000..724a4b5 --- /dev/null +++ b/app/Support/Audit/AuditActionId.php @@ -0,0 +1,16 @@ +|null + */ + private static ?array $all = null; + // Tenants public const TENANT_VIEW = 'tenant.view'; @@ -29,6 +34,11 @@ class Capabilities public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage'; + // Backup schedules + public const TENANT_BACKUP_SCHEDULES_MANAGE = 'tenant_backup_schedules.manage'; + + public const TENANT_BACKUP_SCHEDULES_RUN = 'tenant_backup_schedules.run'; + // Providers (existing gate names used throughout the app) public const PROVIDER_VIEW = 'provider.view'; @@ -46,8 +56,17 @@ class Capabilities */ public static function all(): array { + if (self::$all !== null) { + return self::$all; + } + $reflection = new \ReflectionClass(self::class); - return array_values($reflection->getConstants()); + return self::$all = array_values($reflection->getConstants()); + } + + public static function isKnown(string $capability): bool + { + return in_array($capability, self::all(), true); } } diff --git a/app/Support/TenantRole.php b/app/Support/TenantRole.php index 16e0db6..cad8a83 100644 --- a/app/Support/TenantRole.php +++ b/app/Support/TenantRole.php @@ -8,57 +8,4 @@ enum TenantRole: string case Manager = 'manager'; case Operator = 'operator'; case Readonly = 'readonly'; - - public function canSync(): bool - { - return match ($this) { - self::Owner, - self::Manager, - self::Operator => true, - self::Readonly => false, - }; - } - - public function canManageBackupSchedules(): bool - { - return match ($this) { - self::Owner, - self::Manager => true, - default => false, - }; - } - - public function canRunBackupSchedules(): bool - { - return match ($this) { - self::Owner, - self::Manager, - self::Operator => true, - self::Readonly => false, - }; - } - - public function canViewProviders(): bool - { - return true; - } - - public function canManageProviders(): bool - { - return match ($this) { - self::Owner, - self::Manager => true, - default => false, - }; - } - - public function canRunProviderOperations(): bool - { - return match ($this) { - self::Owner, - self::Manager, - self::Operator => true, - self::Readonly => false, - }; - } } diff --git a/config/graph.php b/config/graph.php index ddb09ba..f3ccff0 100644 --- a/config/graph.php +++ b/config/graph.php @@ -20,7 +20,7 @@ 'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds ], - // When true (default in local/debug), BackupService will fall back to stub payloads + // When true (default in local), BackupService will fall back to stub payloads // instead of failing the backup entirely if Graph returns an error. - 'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local' || env('APP_DEBUG')), + 'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local'), ]; diff --git a/specs/055-ops-ux-rollout/spec.md b/specs/055-ops-ux-rollout/spec.md index f5bd2c7..acada48 100644 --- a/specs/055-ops-ux-rollout/spec.md +++ b/specs/055-ops-ux-rollout/spec.md @@ -184,7 +184,7 @@ ### Functional Requirements ### Canonical allowed summary keys (single source of truth) The following keys are the ONLY allowed summary keys for Ops-UX rendering: -`total, processed, succeeded, failed, skipped, created, updated, deleted, items, tenants` +`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants` All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places). diff --git a/specs/065-tenant-rbac-v1/checklists/requirements.md b/specs/065-tenant-rbac-v1/checklists/requirements.md new file mode 100644 index 0000000..d119c46 --- /dev/null +++ b/specs/065-tenant-rbac-v1/checklists/requirements.md @@ -0,0 +1,34 @@ +# Specification Quality Checklist: Tenant RBAC v1 + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-01-27 +**Feature**: [specs/065-tenant-rbac-v1/spec.md](specs/065-tenant-rbac-v1/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 + +- The provided spec is very detailed and already meets all quality criteria. No issues were found. diff --git a/specs/065-tenant-rbac-v1/contracts/capabilities.md b/specs/065-tenant-rbac-v1/contracts/capabilities.md new file mode 100644 index 0000000..ceff228 --- /dev/null +++ b/specs/065-tenant-rbac-v1/contracts/capabilities.md @@ -0,0 +1,44 @@ +# Capability Contracts: Tenant RBAC v1 + +This document defines the canonical set of capabilities for the Tenant RBAC system. Feature code MUST use these capability strings when checking permissions with Laravel Gates (e.g., `Gate::allows('tenant.members.manage')`). + +## Naming Convention + +Capabilities follow the format: `tenant..` + +## Capability List (v1) + +### Core +- `tenant.core.view`: View the tenant dashboard and basic information. + +### Membership +- `tenant.members.view`: View the list of members in a tenant. +- `tenant.members.manage`: Add, remove, or change the roles of members in a tenant. (Owner-only) + +### Settings +- `tenant.settings.view`: View tenant settings. +- `tenant.settings.manage`: Modify tenant settings. + +### Providers +- `tenant.providers.view`: View provider connections. +- `tenant.providers.manage`: Add, edit, or remove provider connections. +- `tenant.providers.credentials.rotate`: Rotate credentials for a provider connection. +- `tenant.providers.run_ops`: Execute operations using a provider. + +### Operations & Monitoring +- `tenant.operations.view`: View tenant operations and monitoring data. +- `tenant.operations.start`: Start new tenant operations. + +### Inventory & Drift +- `tenant.inventory.view`: View tenant inventory. +- `tenant.inventory.sync`: Trigger a synchronization of the tenant inventory. +- `tenant.drift.view`: View drift detection reports. +- `tenant.drift.ack`: Acknowledge drift alerts. + +### Policies, Backups, & Restore +- `tenant.policies.view`: View policies. +- `tenant.policies.sync`: Synchronize policies. +- `tenant.policies.delete`: Delete policies. +- `tenant.backups.manage`: Manage backups. +- `tenant.restore.execute`: Execute a restore from a backup. +- `tenant.danger_zone`: Access to destructive "danger zone" actions. (Owner-only) diff --git a/specs/065-tenant-rbac-v1/data-model.md b/specs/065-tenant-rbac-v1/data-model.md new file mode 100644 index 0000000..f5e5b18 --- /dev/null +++ b/specs/065-tenant-rbac-v1/data-model.md @@ -0,0 +1,32 @@ +# Data Model: Tenant RBAC v1 + +This document outlines the data model for the Tenant RBAC feature, as defined in the feature specification. + +## Tables + +### `tenant_memberships` (New Table) + +This table is the source of truth for user membership and roles within a tenant. + +**Columns**: + +| Name | Type | Description | Constraints | +|---|---|---|---| +| `id` | `bigint` or `uuid` | Primary key. Follows repository convention. | Primary Key | +| `tenant_id` | `bigint` | Foreign key to the `tenants` table. | Not Null, FK to `tenants.id` | +| `user_id` | `bigint` | Foreign key to the `users` table. | Not Null, FK to `users.id` | +| `role` | `string` | The user's role within the tenant. | Not Null, Enum: `owner`, `manager`, `operator`, `readonly` | +| `created_at` | `timestamp` | Timestamp of creation. | Not Null | +| `updated_at` | `timestamp` | Timestamp of last update. | Not Null | + +**Indexes**: + +- `tenant_memberships_tenant_id_user_id_unique`: Unique constraint on `(tenant_id, user_id)` to ensure a user has only one role per tenant. +- `tenant_memberships_tenant_id_role_index`: Index on `(tenant_id, role)` for efficient role-based queries within a tenant. +- `tenant_memberships_user_id_index`: Index on `(user_id)` for efficiently finding all tenant memberships for a user. + +## Relationships + +- A `Tenant` has many `TenantMembership` records. +- A `User` has many `TenantMembership` records. +- A `TenantMembership` belongs to one `Tenant` and one `User`. diff --git a/specs/065-tenant-rbac-v1/enforcement-hitlist.md b/specs/065-tenant-rbac-v1/enforcement-hitlist.md new file mode 100644 index 0000000..42c61ba --- /dev/null +++ b/specs/065-tenant-rbac-v1/enforcement-hitlist.md @@ -0,0 +1,212 @@ +#+#+#+#+-------------------------------------------------------------------------- +# Spec 065 Enforcement Hitlist — role-ish helpers sweep +#+#+#+#+-------------------------------------------------------------------------- + +Generated: 2026-01-28 (updated) + +---------------------------------------------------------------------------- +Step-2 (T024) — Filament mutation and operation entry points +---------------------------------------------------------------------------- + +Goal: Enumerate every Filament action/page hook that (a) mutates tenant-scoped state or (b) dispatches jobs / operation runs. +This is the authoritative checklist for the enforcement sweep in T025–T033. + +Legend: +- kind: mutate | dispatch | destructive | secret +- capability (target): + - Use existing App\Support\Auth\Capabilities constants where available. + - Mark missing ones as NEW for addition/mapping in T025/T026. + +Tenant (tenant-plane) + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M001 | app/Filament/Resources/TenantResource.php | syncTenant | dispatch | visible() checks Gate::allows(Capabilities::TENANT_SYNC, record) | Capabilities::TENANT_SYNC | Uses OperationRunService to dispatch SyncPoliciesJob. | +| M002 | app/Filament/Resources/TenantResource.php | syncSelected (bulk) | dispatch | visible()+authorize() checks rolesWithCapability(Capabilities::TENANT_SYNC) | Capabilities::TENANT_SYNC | Dispatches BulkTenantSyncJob. | +| M003 | app/Filament/Resources/TenantResource.php | makeCurrent | mutate | none obvious | NEW | Sets current tenant context; should be capability-gated. | +| M004 | app/Filament/Resources/TenantResource.php | archive / deactivate | destructive | none obvious | Capabilities::TENANT_DELETE (or NEW) | Soft-deletes tenant; confirmation already present. | +| M005 | app/Filament/Resources/TenantResource.php | forceDelete | destructive | none obvious | Capabilities::TENANT_DELETE | Permanent delete; confirmation already present. | +| M006 | app/Filament/Resources/TenantResource.php | verify | mutate/dispatch | none obvious | Capabilities::TENANT_MANAGE | May update status fields; should be capability-gated. | +| M007 | app/Filament/Resources/TenantResource.php | setup_rbac | mutate/dispatch | none obvious | Capabilities::TENANT_MANAGE | Intune RBAC setup; should be capability-gated + confirmed (confirmation present). | + +Tenant membership + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M010 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | add member | mutate | relation manager auth + server-side manager guards | Capabilities::TENANT_MEMBERSHIP_MANAGE | Uses TenantMembershipManager (audited). | +| M011 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | change role | mutate | relation manager auth + last-owner protection | Capabilities::TENANT_MEMBERSHIP_MANAGE | Privilege change; requires confirmation. | +| M012 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | remove member | destructive | relation manager auth + last-owner protection | Capabilities::TENANT_MEMBERSHIP_MANAGE | Requires confirmation; blocked attempts are audited. | + +Providers (provider-plane) + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M020 | app/Filament/Resources/ProviderConnectionResource.php | check_connection | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderConnectionHealthCheckJob via OperationRunService. | +| M021 | app/Filament/Resources/ProviderConnectionResource.php | inventory_sync | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderInventorySyncJob. | +| M022 | app/Filament/Resources/ProviderConnectionResource.php | compliance_snapshot | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderComplianceSnapshotJob. | +| M023 | app/Filament/Resources/ProviderConnectionResource.php | set_default | mutate | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Changes default provider; audited. | +| M024 | app/Filament/Resources/ProviderConnectionResource.php | update_credentials | secret | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Credential/secret handling; audited. | +| M025 | app/Filament/Resources/ProviderConnectionResource.php | enable / disable | mutate | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Connection state change; audited. | + +Backup schedules + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M030 | app/Filament/Resources/BackupScheduleResource.php | create/edit/delete | mutate/destructive | Resource can* + policy guard | Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE | Already mapped in Step-1. | +| M031 | app/Filament/Resources/BackupScheduleResource.php | run now / retry (row + bulk) | dispatch | visible()+abort_unless guards | Capabilities::TENANT_BACKUP_SCHEDULES_RUN | Already mapped in Step-1. | + +Backup sets + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M040 | app/Filament/Resources/BackupSetResource.php | restore | dispatch | none obvious | NEW | Starts restore workflow from a backup set; uses OperationRunService. | +| M041 | app/Filament/Resources/BackupSetResource.php | archive / delete (bulk) | destructive | none obvious | NEW (or Capabilities::TENANT_DELETE) | Bulk job: BulkBackupSetDeleteJob. | +| M042 | app/Filament/Resources/BackupSetResource.php | restore (bulk) | dispatch | none obvious | NEW | BulkBackupSetRestoreJob. | +| M043 | app/Filament/Resources/BackupSetResource.php | force delete (row + bulk) | destructive | none obvious | NEW | Bulk job: BulkBackupSetForceDeleteJob. | + +Restore runs + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M050 | app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php | create/queue restore run | dispatch | tenant match + non-dry-run confirmation | NEW | Dispatches ExecuteRestoreRunJob; emits restore.queued audit. | +| M051 | app/Filament/Resources/RestoreRunResource.php | rerun | dispatch | none obvious | NEW | Starts restore rerun. | +| M052 | app/Filament/Resources/RestoreRunResource.php | archive / restore / forceDelete | destructive | none obvious | NEW (or Capabilities::TENANT_DELETE) | Row-level destructive actions; confirmations exist. | +| M053 | app/Filament/Resources/RestoreRunResource.php | bulk delete / restore / force delete | destructive | none obvious | NEW | Bulk jobs: BulkRestoreRunDeleteJob, BulkRestoreRunRestoreJob, BulkRestoreRunForceDeleteJob. | + +Drift + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M060 | app/Filament/Pages/DriftLanding.php | auto enqueue findings generation (mount) | dispatch | Gate::allows(Capabilities::TENANT_SYNC, tenant) | Capabilities::TENANT_SYNC | Dispatches GenerateDriftFindingsJob when no findings exist. | + +Findings + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M070 | app/Filament/Resources/FindingResource.php | acknowledge (row + bulk) | mutate | policy + tenant scoping | NEW (or policy-only) | Local mutation; decide in T025 whether to require a dedicated capability. | + +Policies + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M080 | app/Filament/Resources/PolicyResource.php | ignore / unignore (row + bulk) | mutate | mixed (some Gate checks) | NEW | Local policy lifecycle; bulk jobs include BulkPolicyDeleteJob / BulkPolicyUnignoreJob (verify naming). | +| M081 | app/Filament/Resources/PolicyResource.php | sync (row + bulk) | dispatch | requires Capabilities::TENANT_SYNC in places | Capabilities::TENANT_SYNC (or NEW) | Dispatches SyncPoliciesJob; ensure all entry points have server-side authorization. | +| M082 | app/Filament/Resources/PolicyResource.php | export (row + bulk) | dispatch | none obvious | NEW | BulkPolicyExportJob; capability needed to prevent data exfil. | +| M083 | app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php | restore_to_intune | dispatch | none obvious | NEW | Calls RestoreService::executeFromPolicyVersion. | + +Entra groups + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M090 | app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php | sync_groups (header) | dispatch | abort(403) when role cannot sync | Capabilities::TENANT_SYNC | Execution guard already present. | +| M091 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php | sync_groups (header) | dispatch | abort(403) when role cannot sync | Capabilities::TENANT_SYNC | Execution guard already present. | + +Inventory + +| ID | Location | Action | Kind | Current guard | Capability (target) | Notes | +|-----|----------|--------|------|---------------|---------------------|-------| +| M100 | app/Filament/Resources/InventorySyncRunResource.php | view runs | read | relies on tenant scoping | Capabilities::TENANT_VIEW (or NEW) | Decide whether listing historical runs needs explicit capability in T025. | + +Scope: discovery only (phase 1). This file enumerates every remaining occurrence matched by the stop-regex: + +`TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync` + +Notes: +- `rg` (ripgrep) is not available in this environment, so discovery uses GNU/BSD `grep`. +- The “allowed” exclusions for sweep progress reporting are: + - `app/Services/Auth/RoleCapabilityMap.php` + - `app/Services/Auth/CapabilityResolver.php` + - `app/Support/Auth/Capabilities.php` + - `app/Support/TenantRole.php` + +## Discovery commands + counts + +### Total matches (all of app/) + +```bash +grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app | wc -l +``` + +Result: **31** + +### Remaining matches (excluding mapping/registry/TenantRole enum definition) + +```bash +grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app \ + | grep -vE '^app/Services/Auth/RoleCapabilityMap\.php:|^app/Services/Auth/CapabilityResolver\.php:|^app/Support/Auth/Capabilities\.php:|^app/Support/TenantRole\.php:' \ + | wc -l +``` + +Result: **21** + +### Top files by remaining match count + +```bash +grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app \ + | grep -vE '^app/Services/Auth/RoleCapabilityMap\.php:|^app/Services/Auth/CapabilityResolver\.php:|^app/Support/Auth/Capabilities\.php:|^app/Support/TenantRole\.php:' \ + | cut -d: -f1 | sort | uniq -c | sort -nr | head -n 20 +``` + +Result: + +```text + 10 app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php + 6 app/Services/Auth/TenantMembershipManager.php + 2 app/Models/User.php + 2 app/Filament/Pages/Tenancy/RegisterTenant.php + 1 app/Filament/Resources/TenantResource/Pages/CreateTenant.php +``` + +## Full remaining match list (excluding mapping/registry/TenantRole enum definition) + +```text +app/Models/User.php:116: return TenantRole::tryFrom($role); +app/Models/User.php:119: public function canSyncTenant(Tenant $tenant): bool +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:66: TenantRole::Owner->value => 'Owner', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:67: TenantRole::Manager->value => 'Manager', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:68: TenantRole::Operator->value => 'Operator', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:69: TenantRole::Readonly->value => 'Readonly', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:100: role: TenantRole::from((string) $data['role']), +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:135: TenantRole::Owner->value => 'Owner', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:136: TenantRole::Manager->value => 'Manager', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:137: TenantRole::Operator->value => 'Operator', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:138: TenantRole::Readonly->value => 'Readonly', +app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:162: newRole: TenantRole::from((string) $data['role']), +app/Filament/Resources/TenantResource/Pages/CreateTenant.php:23: $this->record->getKey() => ['role' => TenantRole::Owner->value], +app/Filament/Pages/Tenancy/RegisterTenant.php:79: 'role' => TenantRole::Owner->value, +app/Filament/Pages/Tenancy/RegisterTenant.php:91: 'role' => TenantRole::Owner->value, +app/Services/Auth/TenantMembershipManager.php:178: role: TenantRole::Owner, +app/Services/Auth/TenantMembershipManager.php:203: if ($membership->role !== TenantRole::Owner->value) { +app/Services/Auth/TenantMembershipManager.php:209: ->where('role', TenantRole::Owner->value) +app/Services/Auth/TenantMembershipManager.php:219: if ($membership->role !== TenantRole::Owner->value) { +app/Services/Auth/TenantMembershipManager.php:223: if ($newRole === TenantRole::Owner) { +app/Services/Auth/TenantMembershipManager.php:229: ->where('role', TenantRole::Owner->value) +``` +| H003 | app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php:62 | `if (! ($role?->canSync() ?? false)) abort(403);` | Header action execution: `sync_groups` | `Capabilities::TENANT_SYNC` | Execution guard. | +| H004 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php:50 | `$role?->canSync() ?? false` | Header action visibility: `sync_groups` | `Capabilities::TENANT_SYNC` | Visible guard only. | +| H005 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php:71 | `if (! ($role?->canSync() ?? false)) abort(403);` | Header action execution: `sync_groups` | `Capabilities::TENANT_SYNC` | Execution guard. | +| H006 | app/Filament/Resources/BackupScheduleResource.php:86 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canCreate()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H007 | app/Filament/Resources/BackupScheduleResource.php:91 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canEdit()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H008 | app/Filament/Resources/BackupScheduleResource.php:96 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canDelete()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H009 | app/Filament/Resources/BackupScheduleResource.php:101 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canDeleteAny()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H010 | app/Filament/Resources/BackupScheduleResource.php:303 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Table row action visibility: `runNow` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H011 | app/Filament/Resources/BackupScheduleResource.php:305 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Table row action execution: `runNow` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H012 | app/Filament/Resources/BackupScheduleResource.php:427 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Table row action visibility: `retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H013 | app/Filament/Resources/BackupScheduleResource.php:429 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Table row action execution: `retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H014 | app/Filament/Resources/BackupScheduleResource.php:548 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Table row action visibility: `EditAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H015 | app/Filament/Resources/BackupScheduleResource.php:550 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Table row action visibility: `DeleteAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H016 | app/Filament/Resources/BackupScheduleResource.php:559 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Bulk action visibility: `bulk_run_now` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H017 | app/Filament/Resources/BackupScheduleResource.php:561 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Bulk action execution: `bulk_run_now` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H018 | app/Filament/Resources/BackupScheduleResource.php:688 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Bulk action visibility: `bulk_retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H019 | app/Filament/Resources/BackupScheduleResource.php:690 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Bulk action execution: `bulk_retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. | +| H020 | app/Filament/Resources/BackupScheduleResource.php:814 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Bulk action visibility: `DeleteBulkAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H021 | app/Policies/BackupSchedulePolicy.php:34 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `create()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H022 | app/Policies/BackupSchedulePolicy.php:39 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `update()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | +| H023 | app/Policies/BackupSchedulePolicy.php:44 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `delete()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. | + +## Step-1 conclusion (guardrails) + +- `canSync()` has a clear mapping: `Capabilities::TENANT_SYNC`. +- `canManageBackupSchedules()` now maps to `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`. +- `canRunBackupSchedules()` now maps to `Capabilities::TENANT_BACKUP_SCHEDULES_RUN`. +- No unmapped `TenantRole::can*()` usages remain in this hitlist. diff --git a/specs/065-tenant-rbac-v1/plan.md b/specs/065-tenant-rbac-v1/plan.md new file mode 100644 index 0000000..7cb35a1 --- /dev/null +++ b/specs/065-tenant-rbac-v1/plan.md @@ -0,0 +1,122 @@ +# Implementation Plan: Tenant RBAC v1 + +**Branch**: `065-tenant-rbac-v1` | **Date**: 2026-01-27 | **Spec**: [spec.md](spec.md) +**Input**: Feature specification from `/specs/[###-feature-name]/spec.md` + +**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts. + +## Summary + +This plan outlines the implementation of a capabilities-first Tenant RBAC system within the existing Laravel and Filament application. The primary requirement is to introduce granular, server-side enforced permissions for tenant users, managed through a new "Members" UI. + +The technical approach involves: +1. Verifying the existing `tenant_memberships` table matches the spec (role + minimal provenance fields). +2. Using the existing central capability registry (`App\Support\Auth\Capabilities`) and role → capability mapping to enforce least privilege. +3. Ensuring Laravel Gates are defined per capability for all registry entries (no hand-maintained capability lists). +4. Auditing / completing the Filament Relation Manager (`TenantMembershipsRelationManager`) so only Owners can manage memberships. +5. Adding/expanding unit + feature tests with Pest to ensure the RBAC system is secure and correct. + +## Phases & Checkpoints + +Phase 1 — Setup & Database +- Done when: `tenant_memberships` schema + relationships are verified and documented, and all related tests pass. + +Phase 2 — Foundational RBAC Core +- Done when: capability registry + role mapping are aligned with least-privilege semantics and Gates are registered from `Capabilities::all()`. +- Done when: request-scope caching prevents repeated membership queries (query-count test). + +Phase 3 — Membership Management UI +- Done when: Owners can add/change/remove members via Filament, last-owner rules are enforced, and all membership mutations require confirmation. +- Done when: members UI render/hydration is DB-only (no outbound HTTP and no jobs dispatched). + +Phase 4 — Authorization Enforcement +- Done when: tenant route scoping is deny-as-not-found for non-members and global search is tenant-scoped. +- Done when: enforcement sweep removes role-ish authorization checks from tenant-plane feature code and replaces them with capability Gates/Policies. + +Phase 5 — Polish & Finalization +- Done when: Pint passes for all changed files and the full test suite passes. + +## Technical Context + +**Language/Version**: PHP 8.4+ +**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Pest 4 +**Storage**: PostgreSQL +**Testing**: Pest +**Target Platform**: Web (Laravel application) +**Project Type**: Web application +**Performance Goals**: Membership/capability evaluation MUST be O(1) per request after initial load (NFR-001). +**Constraints**: RBAC UI surfaces MUST be DB-only at render time (NFR-003). +**Scale/Scope**: The system should be designed to handle a moderate number of tenants and users, with the potential to scale. Initial design should not be a bottleneck for future growth. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- [X] **Inventory-first**: N/A for this feature. +- [X] **Read/write separation**: All membership changes (write operations) are specified to require Owner-level privileges and will be implemented with confirmations and audit logs. +- [X] **Graph contract path**: N/A. The spec explicitly states no Graph calls for RBAC UI. +- [X] **Deterministic capabilities**: The role-to-capability mapping is deterministic and defined in a central place. +- [X] **RBAC Standard**: This feature implements the RBAC standard. It establishes the two-plane separation, capabilities-first authorization, and least-privilege roles. +- [X] **Tenant isolation**: All queries and actions are tenant-scoped through the `tenant_memberships` table. +- [X] **Run observability**: N/A for the core RBAC logic, as it's synchronous. Any long-running operations triggered by authorized users will follow this principle. +- [X] **Automation**: N/A for this feature. +- [X] **Data minimization**: The `tenant_memberships` table stores only essential information (IDs, role, and minimal provenance fields like `source` and `created_by_user_id`). +- [X] **Badge semantics (BADGE-001)**: The role badge in the members list will use the `BadgeCatalog`. + +## Project Structure + +### Documentation (this feature) + +```text +specs/065-tenant-rbac-v1/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ +│ └── capabilities.md # Phase 1 output +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +The project follows a standard Laravel structure. Key files for this feature will be located in: + +```text +app/ +├── Models/ +│ ├── TenantMembership.php # Existing pivot model +│ └── User.php # Existing relationship to tenants/memberships +│ └── Tenant.php # Existing relationship to users/memberships +├── Policies/ +│ └── (optional) TenantMembershipPolicy.php # Optional policy for membership mutations (currently Gate-driven) +├── Providers/ +│ └── AuthServiceProvider.php # Register per-capability Gates +└── Filament/ + └── Resources/ + └── TenantResource/ + └── RelationManagers/ + └── TenantMembershipsRelationManager.php # New Filament relation manager + +database/ +└── migrations/ + └── 2026_01_25_022729_create_tenant_memberships_table.php # Existing migration + +tests/ +├── Feature/ +│ └── Filament/ +│ └── TenantMembersTest.php # New feature test for RBAC UI +└── Unit/ + └── Auth/ # Existing unit tests for capability registry + resolver +``` + +**Structure Decision**: The implementation will use the existing Laravel project structure. New classes and files will be created in their conventional locations. The primary UI for membership management will be a Filament Relation Manager on the `TenantResource`. + +## Complexity Tracking + +> **Fill ONLY if Constitution Check has violations that must be justified** + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| [e.g., 4th project] | [current need] | [why 3 projects insufficient] | +| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] | diff --git a/specs/065-tenant-rbac-v1/quickstart.md b/specs/065-tenant-rbac-v1/quickstart.md new file mode 100644 index 0000000..bdd3226 --- /dev/null +++ b/specs/065-tenant-rbac-v1/quickstart.md @@ -0,0 +1,74 @@ +# Quickstart: Tenant RBAC v1 + +This guide provides a quick overview for developers on how to use the Tenant RBAC v1 system. + +## Checking Permissions + +The core of the RBAC system is a set of defined capabilities. To check if the currently authenticated user has a specific capability, use Laravel's `Gate` facade. + +**NEVER check for roles directly.** Always check for capabilities. + +### In PHP (Controllers, Policies, Livewire Components) + +```php +use Illuminate\Support\Facades\Gate; + +// Check for a specific capability +if (Gate::allows('tenant.members.manage')) { + // User can manage members +} + +// You can also deny access +if (Gate::denies('tenant.settings.manage')) { + abort(403); +} +``` + +### In Blade Views + +You can use the `@can` and `@cannot` directives in your Blade templates to conditionally show UI elements. + +```blade +@can('tenant.members.manage') + +@endcan + +@cannot('tenant.danger_zone') +

You are not authorized to access the danger zone.

+@endcannot +``` + +### In Filament Resources + +Filament actions and pages can be protected using the `can` method. + +```php +use Filament\Actions\Action; +use Filament\Resources\Pages\ListRecords; + +// Protecting an action +Action::make('delete') + ->requiresConfirmation() + ->action(fn ($record) => $record->delete()) + ->visible(fn ($record) => Gate::allows('tenant.policies.delete', $record)); + +// Protecting a page +class ListMembers extends ListRecords +{ + // ... + public static function canView(): bool + { + return Gate::allows('tenant.members.view'); + } +} +``` + +## Capability Reference + +A full list of available capabilities is defined in `specs/065-tenant-rbac-v1/contracts/capabilities.md`. + +## Key Principles + +1. **Capabilities, Not Roles**: All authorization checks MUST be against capabilities, not roles (`owner`, `manager`, etc.). This decouples the application's logic from the role definitions. +2. **Server-Side Enforcement**: UI hiding is not security. Always enforce permissions on the server-side (in controllers, actions, or policies) in addition to hiding UI elements. +3. **Use Policies for Model-Specific Logic**: For authorization logic that depends on a specific model instance, use a Laravel Policy class. diff --git a/specs/065-tenant-rbac-v1/research.md b/specs/065-tenant-rbac-v1/research.md new file mode 100644 index 0000000..931797b --- /dev/null +++ b/specs/065-tenant-rbac-v1/research.md @@ -0,0 +1,7 @@ +# Research: Tenant RBAC v1 + +**Date**: 2026-01-27 + +No significant research was required for this feature. The specification is comprehensive and relies on established technologies and patterns within the project (Laravel, Filament, Pest). + +The implementation will follow standard Laravel practices for Gates, Policies, and database migrations. diff --git a/specs/065-tenant-rbac-v1/spec.md b/specs/065-tenant-rbac-v1/spec.md new file mode 100644 index 0000000..0611967 --- /dev/null +++ b/specs/065-tenant-rbac-v1/spec.md @@ -0,0 +1,400 @@ +--- +description: "Tenant RBAC v1 — Capabilities-first authorization + Membership Management" +feature: "065-tenant-rbac-v1" +version: "1.0.0" +status: "draft" +--- + +# Spec 065 — Tenant RBAC v1 (Capabilities-first) + Membership Management + +**Scope**: `/admin` Tenant Panel (Entra users) + +**Depends on**: +- **063 Entra sign-in v1** (tenant users authenticate via Entra / OIDC) +- **064 Auth structure v1** (separate `/system` platform panel vs `/admin` tenant panel, cross-scope 404) + +**Out of scope (v1)**: +- `/system` platform RBAC expansion (system console / global views) +- Entra group-to-role mapping (v2) +- SCIM provisioning (v2) +- Impersonation (v2) +- Custom per-feature roles UI (v2) +- “Invite token” email onboarding flows (optional v2, depending on your Entra setup) + +--- + +## 0) Goals + +1. **Enterprise-grade tenant authorization**: predictable, auditable, least privilege. +2. **Capabilities-first**: feature code checks only capabilities (Gates/Policies), never raw roles. +3. **Membership management** in tenant panel: tenant Owners manage members & roles. +4. **No regressions**: existing tenant features remain usable; RBAC enforcement becomes consistent. +5. **Testable**: every sensitive permission boundary has regression tests. + +--- + +## 1) Non-goals + +- This spec does **not** create a global “MSP console” across tenants. +- This spec does **not** implement Entra group claims ingestion or Graph-based membership resolution. +- This spec does **not** change provider connection credentials strategy (that stays in Provider foundation specs). +- This spec does **not** redesign UI pages; it adds management and enforcement. + +--- + +## 2) Terms & Principles + +### 2.1 Two planes (already established by 064) + +- **Tenant plane**: `/admin/t/{tenant}` uses Entra users from `users`. +- **Platform plane**: `/system` uses platform operators from `platform_users`. +- Cross-plane access is deny-as-not-found (404). This spec does not change that. + +### 2.2 Capabilities-first + +- Roles exist for UX, but **code checks capabilities**. +- Capabilities are registered in a **central registry**. +- A **role → capability mapping** is the only place that references role names. + +### 2.3 Least privilege + +- Readonly is view-only. +- Operator can run operations but cannot manage configuration/members/credentials or delete. +- Manager can manage tenant configuration and run operations; cannot manage memberships. +- Owner can manage memberships and “danger zone”. + +--- + +## 3) Requirements (Functional) + +### FR-001 Membership source of truth + +Authorization MUST be derived from a tenant membership record for the current (user_id, tenant_id). + +### FR-002 Tenant membership management UI + +Tenant Owners MUST be able to: +- add members +- change roles +- remove members + +### FR-003 Last owner protection + +The system MUST prevent removing or demoting the last remaining Owner for a tenant. + +### FR-004 Capability registry + +A canonical tenant capability registry MUST exist (single source of truth). + +### FR-005 Role to capability mapping + +Tenant roles MUST map to capability sets via a central mapper (no distributed role checks). + +### FR-006 Enforcement in server-side authorization + +All mutations MUST be protected by Policies/Gates. UI hiding is insufficient. + +### FR-007 Operator constraints + +Operators MUST NOT be able to: +- manage members +- manage provider connections/credentials +- change tenant settings +- perform destructive actions (delete/force delete) + +### FR-008 Readonly constraints + +Readonly MUST NOT be able to mutate data OR start tenant operations. + +### FR-009 Operations start permissions + +Starting a tenant operation (enqueue-only actions) MUST require the relevant capability. + +### FR-010 Audit logging for access-control changes + +Membership add/remove/role_change MUST write AuditLog entries with stable action IDs and redacted data. + +### FR-011 Tenant switcher and route scoping + +Only tenants where the user has membership MUST be listable/selectable; non-member tenant routes MUST 404. + +Additionally, tenant-plane global search MUST be tenant-scoped (non-members MUST see no results, and any result URLs must still be deny-as-not-found when accessed directly). + +### FR-012 Regression tests + +RBAC boundaries MUST be covered by tests (positive + negative cases). + +--- + +## 4) Requirements (Non-functional) + +### NFR-001 Performance + +Membership/capability evaluation MUST be O(1) per request after initial load (no N+1). + +### NFR-002 Data minimization + +No user secrets are stored; only Entra identifiers and minimal profile fields. + +### NFR-003 DB-only render guarantee + +RBAC UI surfaces (members listing) MUST be DB-only at render time (no outbound HTTP, no Graph). + +### NFR-004 Observability + +AuditLog and denied actions MUST be diagnosable without leaking secrets. + +--- + +## 5) Data Model + +### 5.1 Table: tenant_memberships + +Note: The `tenant_memberships` table is already present in the repository (introduced by an earlier migration). This feature verifies the schema and treats it as the source of truth for tenant-plane authorization. + +Columns: +- `id` (uuid, primary key) +- `tenant_id` (FK to tenants) +- `user_id` (FK to users) +- `role` (enum: `owner|manager|operator|readonly`) +- `source` (enum: `manual|entra_group|entra_app_role|break_glass`, default `manual`) +- `source_ref` (nullable string) +- `created_by_user_id` (nullable FK to `users`) +- `created_at`, `updated_at` + +Constraints: +- unique `(tenant_id, user_id)` +- index `(tenant_id, role)` +- FK constraints (tenant_id/user_id/created_by_user_id) + +### 5.2 Optional (deferred): tenant_invites + +Not required for v1 unless you want email-based invites without the user existing in DB. + +--- + +## 6) Capability Registry & Role Mapping + +### 6.1 Naming convention + +Capabilities are strings, and this repository’s canonical registry is `App\Support\Auth\Capabilities`. + +Tenant-scoped capabilities are defined as `.`, with some namespaces using underscores (e.g. `tenant_membership.manage`). + +### 6.2 Canonical capabilities (v1 baseline) + +Minimum set (extendable, but these are the baseline contracts as of 2026-01-28): + +Tenant: +- `tenant.view` +- `tenant.manage` +- `tenant.delete` +- `tenant.sync` + +Membership: +- `tenant_membership.view` +- `tenant_membership.manage` + +Tenant role mappings (optional in v1; no Graph resolution at render time): +- `tenant_role_mapping.view` +- `tenant_role_mapping.manage` + +Providers: +- `provider.view` +- `provider.manage` +- `provider.run` + +Audit: +- `audit.view` + +Backup schedules: +- `tenant_backup_schedules.manage` +- `tenant_backup_schedules.run` + +### 6.3 Role → capability mapping (v1) + +Rules: + +Readonly: +- `tenant.view` +- `tenant_membership.view` +- `tenant_role_mapping.view` +- `provider.view` +- `audit.view` + +Operator: +- Readonly + + - `tenant.sync` + - `provider.run` + +Manager: +- Operator + + - `tenant.manage` + - `provider.manage` +- NOT: `tenant_membership.manage` (Owner-only) +- NOT: `tenant_role_mapping.manage` (Owner-only in v1) +- Optional: `tenant.delete` if you explicitly decide managers can delete tenants (default: Owner-only) + +Owner: +- Manager + + - `tenant_membership.manage` + - `tenant_role_mapping.manage` + - `tenant.delete` + +--- + +## 7) Authorization Architecture + +### 7.1 Membership resolution + +Given current user + current tenant (Filament tenant): +- Load membership: `tenant_memberships` row for (user_id, tenant_id) +- If missing: tenant access is deny-as-not-found (404) (membership scoping rule). + +### 7.2 Capability resolution + +- Resolve role from membership +- Map role → capability set +- Cache in-request. Optional: short-lived cache keyed `(user_id, tenant_id)` max 60s (DB-only). + +### 7.3 Gates and Policies + +- Define per-capability Gates for all entries in `App\Support\Auth\Capabilities`. +- Resources MUST call Gate/Policies for: + - pages + - table actions + - bulk actions + - form actions + - relation manager actions + +No feature code checks role strings directly (or uses role helper methods) outside the central mapping/resolver. + +--- + +## 8) UI: Tenant Members Management (Admin Panel) + +### 8.1 Location + +Tenant-scoped settings section: +- `Settings → Members` (or `Tenants → View Tenant → Members` relation manager), consistent with your existing navigation style. + +### 8.2 List view + +Columns: +- user name +- user email +- role (badge) +- added_at + +Actions: +- Add member (Owner only) +- Change role (Owner only) +- Remove member (Owner only) + +### 8.3 Add member flow (v1 minimal, enterprise-safe) + +Input: +- Entra email (UPN) or existing user picker + +Behavior: +- If matching user exists (email match): create membership row. +- If not found: + - v1: Require the user to sign in first (cleaner), to avoid user identity conflicts. + +### 8.4 Last owner protection + +- If membership is last owner: remove/demote blocked with clear message. +- Emit AuditLog `tenant_membership.last_owner_blocked` (optional). + +--- + +## 9) Audit Logging + +### 9.1 Canonical action_ids + +- `tenant_membership.add` +- `tenant_membership.role_change` +- `tenant_membership.remove` +- Optional: `tenant_membership.last_owner_blocked` + +### 9.2 Minimal log payload + +- actor_user_id +- tenant_id +- target_user_id +- before_role/after_role where relevant +- timestamp +- ip (optional) + +No secrets, no tokens. + +--- + +## 10) Repo-wide Enforcement Sweep (must-do) + +### 10.1 Destructive actions policy + +Any destructive action MUST: +- have server-side authorization (Policy/Gate) +- have `requiresConfirmation()` +- have at least one negative test (operator/readonly cannot) + +### 10.2 “Operator cannot manage” + +Specifically enforce: +- Provider connection delete/disable/credential rotate +- Tenant settings mutations +- Membership changes +- Force delete actions + +--- + +## 11) Tests (Pest) + +### 11.1 Unit tests + +- Role → capability mapping invariants: + - readonly has no start/manage + - operator cannot manage members/settings/providers/credentials + - owner has members.manage +- Last owner guard logic + +### 11.2 Feature tests + +- Membership scoping: + - tenant list/switcher shows only memberships + - non-member route returns 404 +- Membership management: + - owner can add/change/remove + - manager/operator/readonly cannot + - last owner cannot be removed/demoted +- Provider constraints: + - operator cannot delete provider connection or rotate credentials +- Operations starts: + - operator can start allowed operation (creates OperationRun) if capability exists + - readonly cannot start operations + +### 11.3 Regression guard (optional but recommended) + +- Architecture/grep test to flag role-string checks in `app/Filament/**` and `app/Jobs/**` (except in the central role→capability mapper). + +--- + +## 12) Acceptance Criteria (Definition of Done) + +- Tenant members management works; last owner rule enforced. +- Operator cannot manage/delete sensitive resources (tested). +- Readonly is view-only across tenant plane (tested). +- All new mutations are Policy/Gate enforced and audit logged. +- No outbound HTTP during render/hydration for tenant RBAC UI. +- No role-string checks exist outside the central mapper/registry. + +--- + +## 13) v2 Roadmap (Explicit) + +- Entra group-to-role mapping (scheduled sync, no render-time Graph calls) +- Invite tokens (email-based) if needed +- Custom roles per tenant +- Impersonation (audited, time-limited) +- System console global views (cross-tenant dashboards) \ No newline at end of file diff --git a/specs/065-tenant-rbac-v1/tasks.md b/specs/065-tenant-rbac-v1/tasks.md new file mode 100644 index 0000000..0b48a24 --- /dev/null +++ b/specs/065-tenant-rbac-v1/tasks.md @@ -0,0 +1,88 @@ +# Tasks for Feature: Tenant RBAC v1 + +This document outlines the implementation tasks for the Tenant RBAC v1 feature, ordered by dependency. + +## Phase 1: Setup & Database + +- [X] T001 Verify `tenant_memberships` schema matches spec (uuid PK, role enum, minimal provenance fields) in `database/migrations/2026_01_25_022729_create_tenant_memberships_table.php` +- [X] T002 Verify `TenantMembership` pivot model + relationships exist and are correct in `app/Models/TenantMembership.php`, `app/Models/User.php`, and `app/Models/Tenant.php` + +## Phase 2: Foundational RBAC Core + +- [X] T005 [P] Ensure canonical capability registry exists and matches v1 contract in `app/Support/Auth/Capabilities.php` +- [X] T006 [P] Ensure role → capability mapping matches least-privilege semantics (Owner-only membership manage) and references only registry constants in `app/Services/Auth/RoleCapabilityMap.php` +- [X] T007 Register tenant capability gates for the full registry using `Capabilities::all()` (no hand-maintained lists) in `app/Providers/AuthServiceProvider.php` +- [X] T008 Add/confirm request-scope cache for membership + capabilities and add a test asserting repeated capability checks do not execute additional membership queries (query count assertion) in `tests/Unit/Auth/CapabilityResolverQueryCountTest.php` +- [X] T009 Create/update unit tests asserting least-privilege invariants (Owner can manage memberships, Manager cannot) in `tests/Unit/Auth/CapabilityResolverTest.php` +- [X] T012 [P] Add contract test that fails fast on unknown/typo capability strings passed to the capability system in `tests/Unit/Auth/UnknownCapabilityGuardTest.php` +- [X] T013 [P] (Optional, recommended) Add guard test forbidding role-string checks outside the mapper (e.g. `'owner'`, `'manager'`) in `tests/Unit/Auth/NoRoleStringChecksTest.php` + +## Phase 3: User Story 1 - Membership Management UI + +**Goal**: As a Tenant Owner, I want to manage members and their roles. +**Independent Test Criteria**: An owner can add, edit, and remove members. Non-owners cannot. The last owner cannot be removed or demoted. + +- [X] T014 [US1] Create/update `TenantMembershipsRelationManager` for `TenantResource` in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T015 [US1] Implement table columns (user name, email, role) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T016 [US1] Ensure "Add Member" action is Owner-only and requires existing user (no placeholder user creation) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T017 [US1] Implement "Change Role" action (Owner-only, server-authorized, requires confirmation + clear validation/feedback) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T018 [US1] Implement "Remove Member" action (Owner-only, server-authorized, requires confirmation + clear validation/feedback) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T019 [US1] Add "last owner" protection logic to change/remove actions (and cover policy edge-cases) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T020 [US1] Add DB-only render guard test for Members UI: use `Http::preventStrayRequests()` (and prevent queued work) while rendering/hydrating members relation manager; assert no outbound HTTP and no jobs dispatched in `tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php` +- [X] T021 [US1] Create feature test for membership management UI and authorization in `tests/Feature/Filament/TenantMembersTest.php` + +## Phase 4: User Story 2 - Authorization Enforcement + +**Goal**: As a user, my actions are authorized based on my role, and I cannot access unauthorized resources. +**Independent Test Criteria**: Routes and actions are protected based on the defined capability matrix. + +- [X] T022 [US2] Harden tenant scoping to enforce FR-011 in the Filament tenant middleware chain (registered in `app/Providers/Filament/AdminPanelProvider.php`) with 404 (not 403) semantics for all `/admin/t/{tenant}/**` routes; enforce global search scoping by overriding global search queries on each Resource (or a shared trait) to restrict results to the current tenant and deny non-members (tests assert non-member sees zero results and result URLs still 404) in `app/Filament/Resources/*Resource.php` +- [X] T022 [US2] Harden tenant scoping to enforce FR-011 in the Filament tenant middleware chain (registered in `app/Providers/Filament/AdminPanelProvider.php`) with 404 (not 403) semantics for all `/admin/t/{tenant}/**` routes; enforce global search scoping by overriding global search queries on each Resource (or a shared trait) to restrict results to the current tenant and deny non-members (tests assert non-member sees zero results and result URLs still 404) in `app/Filament/Resources/*Resource.php` +- [X] T023 [US2] Add non-member 404 tests (direct URL, tenant switcher list, global search scoping) in `tests/Feature/Filament/TenantScopingTest.php` +- [X] T024 [US2] Discovery sweep (required): produce a mutation/start-action hitlist across Filament (Resources/RelationManagers/Actions/BulkActions) in `specs/065-tenant-rbac-v1/enforcement-hitlist.md` +- [ ] T025 [US2] Enforcement pass (required): for each hitlist entry add server-side authorization + UI visibility/disabled rules + confirmations for destructive actions in `app/Filament/**` +- [ ] T026 [US2] Replace role-based authorization helpers (e.g. `TenantRole::can*()` calls) with capability Gates across tenant-plane UI/actions (deny non-member as 404 per middleware; otherwise prefer authorization failures as 403) in `app/Filament/**` and `app/Policies/**` +- [X] T025 [US2] Enforcement pass (required): for each hitlist entry add server-side authorization + UI visibility/disabled rules + confirmations for destructive actions in `app/Filament/**` +- [X] T026 [US2] Replace role-based authorization helpers (e.g. `TenantRole::can*()` calls) with capability Gates across tenant-plane UI/actions (deny non-member as 404 per middleware; otherwise prefer authorization failures as 403) in `app/Filament/**` and `app/Policies/**` +- [X] T027 [P] [US2] Providers enforcement: gate + UI rules for provider CRUD / run ops / credential rotate in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/*.php` +- [X] T028 [P] [US2] Tenants + tenant settings enforcement: gate + UI rules for tenant pages/actions in `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/TenantResource/Pages/*.php` +- [X] T029 [P] [US2] Policies enforcement: gate + UI rules (sync/delete/version views) in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/PolicyResource/Pages/*.php` +- [X] T030 [P] [US2] Backups enforcement: gate + UI rules for backup sets/schedules in `app/Filament/Resources/BackupSetResource.php` and `app/Filament/Resources/BackupScheduleResource.php` +- [X] T031 [P] [US2] Restore enforcement: gate + UI rules for restore creation/execution in `app/Filament/Resources/RestoreRunResource.php` and `app/Jobs/ExecuteRestoreRunJob.php` +- [X] T032 [P] [US2] Drift/Findings enforcement: gate + UI rules for drift findings browsing/ack (if present) in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/*.php` +- [X] T033 [P] [US2] Inventory enforcement: gate + UI rules for inventory browsing/sync runs in `app/Filament/Resources/InventoryItemResource.php` and `app/Filament/Resources/InventorySyncRunResource.php` +- [X] T034 [US2] Add canonical audit action_ids for membership changes (`tenant_membership.add|role_change|remove|last_owner_blocked`) in `app/Support/Audit/AuditActionId.php` +- [X] T035 [US2] Implement audit logging for membership changes (writes `audit_logs.action` + redacted metadata) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php` +- [X] T036 [US2] Add audit logging tests (entry written, action_id stable, metadata minimal + redacted; includes explicit data-minimization assertions: no secrets/tokens, minimal identity fields) in `tests/Feature/Audit/TenantMembershipAuditLogTest.php` +- [X] T037 [US2] Add denial diagnostics: log structured context for authorization denials (capability, tenant_id, actor_user_id) without secrets in `app/Services/Auth/CapabilityResolver.php` (or a dedicated listener) and cover one representative denial path in `tests/Feature/Rbac/DenialDiagnosticsTest.php` +- [X] T038 [P] [US2] Role matrix tests (Readonly: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php` +- [X] T039 [P] [US2] Role matrix tests (Operator: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php` +- [X] T040 [P] [US2] Role matrix tests (Manager: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php` +- [X] T041 [P] [US2] Role matrix tests (Owner: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php` + +### Late enforcement follow-ups (post-sweep) + +- [X] T046 Gate tenant registration + tenant create/edit/delete for non-managers (prevent cross-tenant privilege escalation via Register Tenant) in `app/Filament/Pages/Tenancy/RegisterTenant.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Resources/TenantResource/Pages/*.php` +- [X] T047 Gate policy version maintenance actions (archive/restore/prune/force delete) to `Capabilities::TENANT_MANAGE` with UI disable + server-side abort in `app/Filament/Resources/PolicyVersionResource.php` +- [X] T048 Add regression tests for readonly tenant + policy version maintenance restrictions in `tests/Feature/Rbac/*.php` and update existing bootstrap test in `tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php` + +## Phase 5: Polish & Finalization + +- [X] T042 [P] Review all changed code for adherence to project conventions in `app/` and `tests/` +- [X] T043 [P] Ensure all new UI text is translatable in `resources/lang/*/*.php` +- [X] T044 Run `./vendor/bin/sail bin pint --dirty` to format all changed files in `app/` and `tests/` +- [X] T045 Run the full test suite with `./vendor/bin/sail artisan test --compact` to ensure no regressions + +## Dependencies + +- **US1 (Membership Management)** depends on **Phase 1** and **Phase 2**. +- **US2 (Authorization Enforcement)** depends on **Phase 1** and **Phase 2**. US1 should be completed first to allow for testing with different roles. + +## Parallel Execution + +- Within **Phase 2**, tasks T005, T006, T008, and T012 can be worked on in parallel. +- Within **Phase 4**, the domain enforcement tasks (T024–T030) can be split across agents once T022 (hitlist) is stable. + +## Implementation Strategy + +The implementation will follow an MVP-first approach. The initial focus will be on completing Phase 1 and 2 to establish the core data model and RBAC logic. Then, Phase 3 will be implemented to provide the essential UI for managing memberships. Phase 4 will be a sweep to enforce the new authorization rules across the application. diff --git a/tests/Feature/Audit/TenantMembershipAuditLogTest.php b/tests/Feature/Audit/TenantMembershipAuditLogTest.php new file mode 100644 index 0000000..e9fa7db --- /dev/null +++ b/tests/Feature/Audit/TenantMembershipAuditLogTest.php @@ -0,0 +1,130 @@ +create(); + + /** @var TenantMembershipManager $manager */ + $manager = app(TenantMembershipManager::class); + + $membership = $manager->addMember( + tenant: $tenant, + actor: $owner, + member: $member, + role: 'readonly', + source: 'manual', + ); + + $manager->changeRole( + tenant: $tenant, + actor: $owner, + membership: $membership, + newRole: 'operator', + ); + + $manager->removeMember( + tenant: $tenant, + actor: $owner, + membership: $membership, + ); + + $logs = AuditLog::query() + ->where('tenant_id', $tenant->id) + ->whereIn('action', [ + AuditActionId::TenantMembershipAdd->value, + AuditActionId::TenantMembershipRoleChange->value, + AuditActionId::TenantMembershipRemove->value, + ]) + ->get() + ->keyBy('action'); + + expect($logs)->toHaveCount(3); + + $addLog = $logs->get(AuditActionId::TenantMembershipAdd->value); + $roleChangeLog = $logs->get(AuditActionId::TenantMembershipRoleChange->value); + $removeLog = $logs->get(AuditActionId::TenantMembershipRemove->value); + + expect($addLog)->not->toBeNull(); + expect($roleChangeLog)->not->toBeNull(); + expect($removeLog)->not->toBeNull(); + + expect($addLog->status)->toBe('success'); + expect($roleChangeLog->status)->toBe('success'); + expect($removeLog->status)->toBe('success'); + + expect($addLog->metadata) + ->toHaveKey('member_user_id', $member->id) + ->toHaveKey('role', 'readonly') + ->toHaveKey('source', 'manual') + ->not->toHaveKey('member_email') + ->not->toHaveKey('member_name'); + + expect($roleChangeLog->metadata) + ->toHaveKey('member_user_id', $member->id) + ->toHaveKey('from_role', 'readonly') + ->toHaveKey('to_role', 'operator') + ->not->toHaveKey('member_email') + ->not->toHaveKey('member_name'); + + expect($removeLog->metadata) + ->toHaveKey('member_user_id', $member->id) + ->toHaveKey('role', 'operator') + ->not->toHaveKey('member_email') + ->not->toHaveKey('member_name'); +}); + +it('writes a last-owner-blocked audit log when demoting or removing the last owner', function () { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + + $membership = TenantMembership::query() + ->where('tenant_id', $tenant->id) + ->where('user_id', $owner->id) + ->firstOrFail(); + + /** @var TenantMembershipManager $manager */ + $manager = app(TenantMembershipManager::class); + + expect(fn () => $manager->changeRole( + tenant: $tenant, + actor: $owner, + membership: $membership, + newRole: 'manager', + ))->toThrow(DomainException::class); + + expect(fn () => $manager->removeMember( + tenant: $tenant, + actor: $owner, + membership: $membership, + ))->toThrow(DomainException::class); + + $blockedLogs = AuditLog::query() + ->where('tenant_id', $tenant->id) + ->where('action', AuditActionId::TenantMembershipLastOwnerBlocked->value) + ->where('status', 'blocked') + ->get(); + + expect($blockedLogs->count())->toBeGreaterThanOrEqual(2); + + expect($blockedLogs->contains(fn (AuditLog $log): bool => ( + ($log->metadata['member_user_id'] ?? null) === $owner->id + && ($log->metadata['attempted_to_role'] ?? null) === 'manager' + )))->toBeTrue(); + + expect($blockedLogs->contains(fn (AuditLog $log): bool => ( + ($log->metadata['member_user_id'] ?? null) === $owner->id + && ($log->metadata['attempted_action'] ?? null) === 'remove' + )))->toBeTrue(); + + foreach ($blockedLogs as $log) { + expect($log->metadata) + ->not->toHaveKey('member_email') + ->not->toHaveKey('member_name'); + } +}); diff --git a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php index d93ea1b..aff8996 100644 --- a/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php +++ b/tests/Feature/DirectoryGroups/StartSyncFromGroupsPageTest.php @@ -56,7 +56,7 @@ }); }); -it('hides group sync start action for readonly users', function () { +it('disables group sync start action for readonly users', function () { Queue::fake(); [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -66,7 +66,8 @@ Filament::setTenant($tenant, true); Livewire::test(ListEntraGroups::class) - ->assertActionHidden('sync_groups'); + ->assertActionVisible('sync_groups') + ->assertActionDisabled('sync_groups'); Queue::assertNothingPushed(); }); diff --git a/tests/Feature/Filament/InventoryPagesTest.php b/tests/Feature/Filament/InventoryPagesTest.php index eaf2688..b3869d8 100644 --- a/tests/Feature/Filament/InventoryPagesTest.php +++ b/tests/Feature/Filament/InventoryPagesTest.php @@ -70,6 +70,5 @@ ->assertSee('Coverage') ->assertSee('Policies') ->assertSee('Foundations') - ->assertSee('Dependencies') - ->assertSee('✅'); + ->assertSee('Dependencies'); }); diff --git a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php index 02e9efe..915d41d 100644 --- a/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php +++ b/tests/Feature/Filament/PolicyVersionRestoreViaWizardTest.php @@ -73,6 +73,49 @@ expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']); }); +test('readonly users cannot open restore wizard via policy version row action', function () { + $tenant = Tenant::create([ + 'tenant_id' => 'tenant-policy-version-wizard-readonly', + 'name' => 'Tenant', + 'metadata' => [], + ]); + + $tenant->makeCurrent(); + + $policy = Policy::create([ + 'tenant_id' => $tenant->id, + 'external_id' => 'policy-ro-1', + 'policy_type' => 'settingsCatalogPolicy', + 'display_name' => 'Settings Catalog', + 'platform' => 'windows', + ]); + + $version = PolicyVersion::create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => $policy->policy_type, + 'platform' => $policy->platform, + 'captured_at' => now(), + 'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name], + ]); + + $user = User::factory()->create(['email' => 'readonly@example.com']); + $user->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($user); + Filament::setTenant($tenant, true); + + Livewire::test(ListPolicyVersions::class) + ->assertTableActionDisabled('restore_via_wizard', $version) + ->callTableAction('restore_via_wizard', $version); + + expect(BackupSet::query()->where('metadata->source', 'policy_version')->exists())->toBeFalse(); + expect(BackupItem::query()->exists())->toBeFalse(); +}); + test('restore run wizard can be prefilled from query params for policy version backup set', function () { $tenant = Tenant::create([ 'tenant_id' => 'tenant-policy-version-prefill', diff --git a/tests/Feature/Filament/TenantActionsAuthorizationTest.php b/tests/Feature/Filament/TenantActionsAuthorizationTest.php new file mode 100644 index 0000000..fad1880 --- /dev/null +++ b/tests/Feature/Filament/TenantActionsAuthorizationTest.php @@ -0,0 +1,142 @@ +create([ + 'status' => 'active', + 'is_current' => false, + ]); + + $user->tenants()->syncWithoutDetaching([ + $tenantB->getKey() => ['role' => 'readonly'], + ]); + + $tenantB->makeCurrent(); + + expect($tenantA->fresh()->is_current)->toBeFalse(); + expect($tenantB->fresh()->is_current)->toBeTrue(); + + Livewire::actingAs($user) + ->test(ChooseTenant::class) + ->call('selectTenant', $tenantA->getKey()) + ->assertRedirect(TenantDashboard::getUrl(tenant: $tenantA)); +}); + +test('users cannot switch to a tenant they are not a member of', function () { + [$user] = createUserWithTenant(role: 'readonly'); + + $tenant = Tenant::factory()->create([ + 'status' => 'active', + ]); + + Livewire::actingAs($user) + ->test(ChooseTenant::class) + ->call('selectTenant', $tenant->getKey()) + ->assertStatus(404); +}); + +test('readonly users cannot deactivate tenants (archive)', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('archive', $tenant) + ->callTableAction('archive', $tenant); + + expect($tenant->fresh()->trashed())->toBeFalse(); +}); + +test('readonly users cannot force delete tenants', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + $tenant->delete(); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('forceDelete', $tenant) + ->callTableAction('forceDelete', $tenant); + + expect(Tenant::withTrashed()->find($tenant->getKey()))->not->toBeNull(); +}); + +test('readonly users cannot verify tenant configuration', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('verify', $tenant) + ->callTableAction('verify', $tenant); +}); + +test('readonly users cannot setup intune rbac', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('setup_rbac', $tenant); +}); + +test('readonly users cannot edit tenants', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('edit', $tenant); +}); + +test('readonly users cannot open admin consent', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(\App\Filament\Resources\TenantResource::adminConsentUrl($tenant))->not->toBeNull(); + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('admin_consent', $tenant); +}); + +test('readonly users cannot start tenant sync from tenant menu', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + expect(Gate::forUser($user)->allows(\App\Support\Auth\Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); + + Livewire::actingAs($user) + ->test(ListTenants::class) + ->assertTableActionDisabled('syncTenant', $tenant); +}); diff --git a/tests/Feature/Filament/TenantMakeCurrentTest.php b/tests/Feature/Filament/TenantMakeCurrentTest.php index d9e000f..52929e5 100644 --- a/tests/Feature/Filament/TenantMakeCurrentTest.php +++ b/tests/Feature/Filament/TenantMakeCurrentTest.php @@ -1,46 +1,44 @@ 'tenant-one', - 'name' => 'Tenant One', - 'is_current' => true, +test('choosing a tenant persists last used tenant preference', function () { + $first = Tenant::factory()->create([ + 'status' => 'active', ]); - $second = Tenant::create([ - 'tenant_id' => 'tenant-two', - 'name' => 'Tenant Two', - 'is_current' => false, + $second = Tenant::factory()->create([ + 'status' => 'active', ]); $user = User::factory()->create(); $this->actingAs($user); + $user->tenants()->syncWithoutDetaching([ $first->getKey() => ['role' => 'owner'], $second->getKey() => ['role' => 'owner'], ]); + Filament::setTenant($first, true); - Livewire::test(ListTenants::class) - ->callTableAction('makeCurrent', $second); + Livewire::test(ChooseTenant::class) + ->call('selectTenant', $second->getKey()) + ->assertRedirect(TenantDashboard::getUrl(tenant: $second)); - expect(Tenant::find($second->id)->is_current)->toBeTrue(); - expect(Tenant::find($first->id)->is_current)->toBeFalse(); - expect(Tenant::query()->where('is_current', true)->count())->toBe(1); + $preference = UserTenantPreference::query() + ->where('user_id', $user->getKey()) + ->where('tenant_id', $second->getKey()) + ->first(); - $originalEnv !== false - ? putenv("INTUNE_TENANT_ID={$originalEnv}") - : putenv('INTUNE_TENANT_ID'); + expect($preference)->not->toBeNull(); + expect($preference?->last_used_at)->not->toBeNull(); }); diff --git a/tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php b/tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php new file mode 100644 index 0000000..fed7c4a --- /dev/null +++ b/tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php @@ -0,0 +1,35 @@ +makeCurrent(); + + $member = User::factory()->create(); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $this->actingAs($user); + + Bus::fake(); + Http::preventStrayRequests(); + + Livewire::actingAs($user) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->assertSee($member->name); + + Bus::assertNothingDispatched(); +}); diff --git a/tests/Feature/Filament/TenantMembersTest.php b/tests/Feature/Filament/TenantMembersTest.php new file mode 100644 index 0000000..fd77013 --- /dev/null +++ b/tests/Feature/Filament/TenantMembersTest.php @@ -0,0 +1,111 @@ +makeCurrent(); + + $member = User::factory()->create(['name' => 'Member User']); + + Livewire::actingAs($owner) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->callTableAction('add_member', null, [ + 'user_id' => $member->getKey(), + 'role' => 'readonly', + ]); + + $membership = TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('user_id', $member->getKey()) + ->first(); + + expect($membership)->not->toBeNull(); + expect($membership?->role)->toBe('readonly'); + + Livewire::actingAs($owner) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->callTableAction('change_role', $membership, [ + 'role' => 'manager', + ]); + + expect($membership?->refresh()->role)->toBe('manager'); + + Livewire::actingAs($owner) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->callTableAction('remove', $membership); + + expect(TenantMembership::query()->whereKey($membership?->getKey())->exists())->toBeFalse(); +}); + +it('hides membership management actions from non-owners', function (): void { + [$manager, $tenant] = createUserWithTenant(role: 'manager'); + + $tenant->makeCurrent(); + + $member = User::factory()->create(); + $member->tenants()->syncWithoutDetaching([ + $tenant->getKey() => ['role' => 'readonly'], + ]); + + $membership = TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('user_id', $member->getKey()) + ->firstOrFail(); + + Livewire::actingAs($manager) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->assertTableActionHidden('add_member') + ->assertTableActionHidden('change_role', $membership) + ->assertTableActionHidden('remove', $membership); +}); + +it('prevents removing or demoting the last owner', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + + $tenant->makeCurrent(); + + $ownerMembership = TenantMembership::query() + ->where('tenant_id', $tenant->getKey()) + ->where('user_id', $owner->getKey()) + ->firstOrFail(); + + Livewire::actingAs($owner) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->callTableAction('change_role', $ownerMembership, [ + 'role' => 'manager', + ]); + + expect($ownerMembership->refresh()->role)->toBe('owner'); + + Livewire::actingAs($owner) + ->test(TenantMembershipsRelationManager::class, [ + 'ownerRecord' => $tenant, + 'pageClass' => ViewTenant::class, + ]) + ->callTableAction('remove', $ownerMembership); + + expect(TenantMembership::query()->whereKey($ownerMembership->getKey())->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Filament/TenantScopingTest.php b/tests/Feature/Filament/TenantScopingTest.php new file mode 100644 index 0000000..2804b0c --- /dev/null +++ b/tests/Feature/Filament/TenantScopingTest.php @@ -0,0 +1,70 @@ +create(['name' => 'Tenant A']); + $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $this->actingAs($user) + ->get(ProviderConnectionResource::getUrl('index', tenant: $tenantB)) + ->assertNotFound(); +}); + +it('does not show non-member tenants in the choose-tenant list', function (): void { + $tenantA = Tenant::factory()->create(['name' => 'Tenant A']); + $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + $this->actingAs($user) + ->get('/admin/choose-tenant') + ->assertOk() + ->assertSee('Tenant A') + ->assertDontSee('Tenant B'); +}); + +it('scopes global search results to the current tenant and denies non-members', function (): void { + $tenantA = Tenant::factory()->create(['name' => 'Tenant A']); + $tenantB = Tenant::factory()->create(['name' => 'Tenant B']); + + [$user] = createUserWithTenant($tenantA, role: 'owner'); + + ProviderConnection::factory()->create([ + 'tenant_id' => $tenantA->getKey(), + 'display_name' => 'Acme Connection A', + ]); + + ProviderConnection::factory()->create([ + 'tenant_id' => $tenantB->getKey(), + 'display_name' => 'Acme Connection B', + ]); + + $this->actingAs($user); + + Filament::setTenant($tenantA, true); + + $resultsA = ProviderConnectionResource::getGlobalSearchResults('Acme'); + + expect($resultsA)->toHaveCount(1); + expect((string) $resultsA->first()?->title)->toBe('Acme Connection A'); + + Filament::setTenant($tenantB, true); + + $resultsB = ProviderConnectionResource::getGlobalSearchResults('Acme'); + + expect($resultsB)->toHaveCount(0); + + Filament::setTenant(null, true); + + $resultsNone = ProviderConnectionResource::getGlobalSearchResults('Acme'); + + expect($resultsNone)->toHaveCount(0); +}); diff --git a/tests/Feature/Filament/TenantSetupTest.php b/tests/Feature/Filament/TenantSetupTest.php index b950406..e53cdd9 100644 --- a/tests/Feature/Filament/TenantSetupTest.php +++ b/tests/Feature/Filament/TenantSetupTest.php @@ -194,7 +194,7 @@ public function request(string $method, string $path, array $options = []): Grap $response->assertSee('Actions'); $response->assertSee($firstKey); $response->assertSee('ok'); - $response->assertSee('missing'); + $response->assertSee('Missing'); }); test('tenant list shows Open in Entra action', function () { diff --git a/tests/Feature/Inventory/InventorySyncButtonTest.php b/tests/Feature/Inventory/InventorySyncButtonTest.php index b4e3171..449db2f 100644 --- a/tests/Feature/Inventory/InventorySyncButtonTest.php +++ b/tests/Feature/Inventory/InventorySyncButtonTest.php @@ -204,7 +204,7 @@ expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); }); -it('forbids unauthorized users from starting inventory sync', function () { +it('disables inventory sync start action for readonly users', function () { Queue::fake(); [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -212,7 +212,8 @@ Filament::setTenant($tenant, true); Livewire::test(ListInventoryItems::class) - ->assertActionHidden('run_inventory_sync'); + ->assertActionVisible('run_inventory_sync') + ->assertActionDisabled('run_inventory_sync'); Queue::assertNothingPushed(); expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); diff --git a/tests/Feature/OperationRunServiceTest.php b/tests/Feature/OperationRunServiceTest.php index 13fc55b..0197f58 100644 --- a/tests/Feature/OperationRunServiceTest.php +++ b/tests/Feature/OperationRunServiceTest.php @@ -166,5 +166,5 @@ $message = (string) (($fresh?->failure_summary[0]['message'] ?? '')); expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz'); - expect($message)->toContain('[REDACTED]'); + expect($message)->toContain('[REDACTED'); }); diff --git a/tests/Feature/PolicySyncStartSurfaceTest.php b/tests/Feature/PolicySyncStartSurfaceTest.php index c457e3e..8f9b58a 100644 --- a/tests/Feature/PolicySyncStartSurfaceTest.php +++ b/tests/Feature/PolicySyncStartSurfaceTest.php @@ -165,7 +165,7 @@ }); }); -it('hides policy sync start action for readonly users', function () { +it('disables policy sync start action for readonly users', function () { Queue::fake(); [$user, $tenant] = createUserWithTenant(role: 'readonly'); @@ -175,7 +175,8 @@ Filament::setTenant($tenant, true); Livewire::test(ListPolicies::class) - ->assertActionHidden('sync'); + ->assertActionVisible('sync') + ->assertActionDisabled('sync'); Queue::assertNothingPushed(); }); diff --git a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php index fb669f3..5780047 100644 --- a/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php +++ b/tests/Feature/ProviderConnections/ProviderConnectionHealthCheckStartSurfaceTest.php @@ -91,3 +91,29 @@ Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); }); + +it('disables connection check action for readonly users', function (): void { + Queue::fake(); + + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + $this->actingAs($user); + + $tenant->makeCurrent(); + Filament::setTenant($tenant, true); + + $connection = ProviderConnection::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'provider' => 'microsoft', + 'entra_tenant_id' => fake()->uuid(), + 'status' => 'connected', + ]); + + Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()]) + ->assertActionVisible('check_connection') + ->assertActionDisabled('check_connection') + ->assertActionVisible('compliance_snapshot') + ->assertActionDisabled('compliance_snapshot'); + + Queue::assertNothingPushed(); + expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0); +}); diff --git a/tests/Feature/Rbac/DenialDiagnosticsTest.php b/tests/Feature/Rbac/DenialDiagnosticsTest.php new file mode 100644 index 0000000..20399c9 --- /dev/null +++ b/tests/Feature/Rbac/DenialDiagnosticsTest.php @@ -0,0 +1,28 @@ +allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + + Log::shouldHaveReceived('warning') + ->withArgs(function (string $message, array $context) use ($tenant, $user): bool { + if ($message !== 'rbac.denied') { + return false; + } + + return ($context['capability'] ?? null) === Capabilities::TENANT_MANAGE + && ($context['tenant_id'] ?? null) === (int) $tenant->getKey() + && ($context['actor_user_id'] ?? null) === (int) $user->getKey(); + }) + ->once(); +}); diff --git a/tests/Feature/Rbac/FilamentManageEnforcementTest.php b/tests/Feature/Rbac/FilamentManageEnforcementTest.php new file mode 100644 index 0000000..b991846 --- /dev/null +++ b/tests/Feature/Rbac/FilamentManageEnforcementTest.php @@ -0,0 +1,111 @@ + $tenant->id, + 'name' => 'Backup 1', + 'status' => 'completed', + 'item_count' => 0, + ]); + + Livewire::actingAs($user) + ->test(ListBackupSets::class) + ->assertTableActionDisabled('archive', $set) + ->callTableAction('archive', $set); + + expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeFalse(); +}); + +test('readonly users cannot create backup sets', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + $this->actingAs($user) + ->get(BackupSetResource::getUrl('create', tenant: $tenant)) + ->assertForbidden(); + + Livewire::actingAs($user) + ->test(CreateBackupSet::class) + ->assertStatus(403); +}); + +test('readonly users cannot export policies to backup', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create([ + 'tenant_id' => $tenant->id, + 'ignored_at' => null, + ]); + + Livewire::actingAs($user) + ->test(ListPolicies::class) + ->assertTableActionDisabled('export', $policy) + ->callTableAction('export', $policy, data: [ + 'backup_name' => 'Readonly Export', + ]); + + expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'policy.export')->exists())->toBeFalse(); +}); + +test('operator users cannot access the restore run wizard (create)', function () { + [$user, $tenant] = createUserWithTenant(role: 'operator'); + + Filament::setTenant($tenant, true); + + Livewire::actingAs($user) + ->test(CreateRestoreRun::class) + ->assertStatus(403); +}); + +test('readonly users cannot force delete restore runs', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + $set = BackupSet::create([ + 'tenant_id' => $tenant->id, + 'name' => 'Backup for Restore Run', + 'status' => 'completed', + 'item_count' => 0, + ]); + + $run = RestoreRun::create([ + 'tenant_id' => $tenant->id, + 'backup_set_id' => $set->id, + 'status' => 'completed', + 'is_dry_run' => true, + 'requested_by' => 'tester@example.com', + ]); + + $run->delete(); + + Livewire::actingAs($user) + ->test(ListRestoreRuns::class) + ->assertTableActionDisabled('forceDelete', $run) + ->callTableAction('forceDelete', $run); + + expect(RestoreRun::withTrashed()->find($run->id))->not->toBeNull(); +}); diff --git a/tests/Feature/Rbac/PolicyVersionMaintenanceAuthorizationTest.php b/tests/Feature/Rbac/PolicyVersionMaintenanceAuthorizationTest.php new file mode 100644 index 0000000..4f1a51f --- /dev/null +++ b/tests/Feature/Rbac/PolicyVersionMaintenanceAuthorizationTest.php @@ -0,0 +1,60 @@ +create(['tenant_id' => $tenant->id]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + ]); + + Livewire::actingAs($user) + ->test(ListPolicyVersions::class) + ->assertTableActionDisabled('archive', $version) + ->callTableAction('archive', $version); + + expect($version->refresh()->trashed())->toBeFalse(); +}); + +test('readonly users cannot bulk prune policy versions', function () { + [$user, $tenant] = createUserWithTenant(role: 'readonly'); + + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->create(['tenant_id' => $tenant->id]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'captured_at' => now()->subDays(120), + ]); + + Livewire::actingAs($user) + ->test(ListPolicyVersions::class) + ->callTableBulkAction('bulk_prune_versions', collect([$version]), data: [ + 'retention_days' => 90, + ]); + + expect(OperationRun::query() + ->where('tenant_id', $tenant->id) + ->where('type', 'policy_version.prune') + ->exists())->toBeFalse(); + + expect($version->refresh()->trashed())->toBeFalse(); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php new file mode 100644 index 0000000..71ead6c --- /dev/null +++ b/tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php @@ -0,0 +1,26 @@ +allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::AUDIT_VIEW, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php new file mode 100644 index 0000000..ebc7c0f --- /dev/null +++ b/tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php @@ -0,0 +1,26 @@ +allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::AUDIT_VIEW, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); + + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::PROVIDER_MANAGE, $tenant))->toBeFalse(); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php new file mode 100644 index 0000000..fb024ba --- /dev/null +++ b/tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php @@ -0,0 +1,25 @@ +allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_RUN, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_MANAGE, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::AUDIT_VIEW, $tenant))->toBeTrue(); +}); diff --git a/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php new file mode 100644 index 0000000..dfc7a16 --- /dev/null +++ b/tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php @@ -0,0 +1,25 @@ +allows(Capabilities::TENANT_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::PROVIDER_VIEW, $tenant))->toBeTrue(); + expect($gate->allows(Capabilities::AUDIT_VIEW, $tenant))->toBeTrue(); + + expect($gate->allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant))->toBeFalse(); + + expect($gate->allows(Capabilities::TENANT_SYNC, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_DELETE, $tenant))->toBeFalse(); + + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::PROVIDER_RUN, $tenant))->toBeFalse(); + expect($gate->allows(Capabilities::PROVIDER_MANAGE, $tenant))->toBeFalse(); +}); diff --git a/tests/Feature/Rbac/TenantAdminAuthorizationTest.php b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php new file mode 100644 index 0000000..281fdbc --- /dev/null +++ b/tests/Feature/Rbac/TenantAdminAuthorizationTest.php @@ -0,0 +1,30 @@ +actingAs($user); + + expect(RegisterTenant::canView())->toBeFalse(); + + Livewire::actingAs($user) + ->test(RegisterTenant::class) + ->assertStatus(404); +}); + +test('readonly users cannot create tenants', function () { + [$user] = createUserWithTenant(role: 'readonly'); + + $this->actingAs($user); + + Livewire::actingAs($user) + ->test(CreateTenant::class) + ->assertStatus(403); +}); diff --git a/tests/Feature/TenantRBAC/LastOwnerGuardTest.php b/tests/Feature/TenantRBAC/LastOwnerGuardTest.php index 4a8a639..7651353 100644 --- a/tests/Feature/TenantRBAC/LastOwnerGuardTest.php +++ b/tests/Feature/TenantRBAC/LastOwnerGuardTest.php @@ -2,7 +2,6 @@ use App\Models\TenantMembership; use App\Services\Auth\TenantMembershipManager; -use App\Support\TenantRole; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -17,7 +16,7 @@ $manager = app(TenantMembershipManager::class); - $callback = fn () => $manager->changeRole($tenant, $actor, $membership, TenantRole::Readonly); + $callback = fn () => $manager->changeRole($tenant, $actor, $membership, 'readonly'); expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.'); }); diff --git a/tests/Feature/TenantRBAC/MembershipAuditLogTest.php b/tests/Feature/TenantRBAC/MembershipAuditLogTest.php index bcae397..799d784 100644 --- a/tests/Feature/TenantRBAC/MembershipAuditLogTest.php +++ b/tests/Feature/TenantRBAC/MembershipAuditLogTest.php @@ -3,7 +3,6 @@ use App\Models\AuditLog; use App\Models\User; use App\Services\Auth\TenantMembershipManager; -use App\Support\TenantRole; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -14,8 +13,8 @@ $manager = app(TenantMembershipManager::class); - $membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly); - $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator); + $membership = $manager->addMember($tenant, $actor, $member, 'readonly'); + $manager->changeRole($tenant, $actor, $membership, 'operator'); $manager->removeMember($tenant, $actor, $membership); $actions = AuditLog::query() diff --git a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php index 5f4b3ae..c8d216d 100644 --- a/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php +++ b/tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php @@ -12,6 +12,11 @@ it('bootstraps tenant creator as owner and audits the assignment', function () { $user = User::factory()->create(); + $existingTenant = Tenant::factory()->create(); + $user->tenants()->syncWithoutDetaching([ + $existingTenant->getKey() => ['role' => 'owner'], + ]); + $this->actingAs($user); $tenantGuid = '11111111-1111-1111-1111-111111111111'; diff --git a/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php b/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php index 24eb8ae..56bfda4 100644 --- a/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php +++ b/tests/Feature/TenantRBAC/TenantMembershipCrudTest.php @@ -2,7 +2,6 @@ use App\Models\User; use App\Services\Auth\TenantMembershipManager; -use App\Support\TenantRole; use Illuminate\Foundation\Testing\RefreshDatabase; uses(RefreshDatabase::class); @@ -13,7 +12,7 @@ $manager = app(TenantMembershipManager::class); - $membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly); + $membership = $manager->addMember($tenant, $actor, $member, 'readonly'); $this->assertDatabaseHas('tenant_memberships', [ 'id' => $membership->getKey(), @@ -23,7 +22,7 @@ 'source' => 'manual', ]); - $updated = $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator); + $updated = $manager->changeRole($tenant, $actor, $membership, 'operator'); expect($updated->role)->toBe('operator'); diff --git a/tests/Unit/Auth/CapabilityResolverQueryCountTest.php b/tests/Unit/Auth/CapabilityResolverQueryCountTest.php new file mode 100644 index 0000000..c05fdb7 --- /dev/null +++ b/tests/Unit/Auth/CapabilityResolverQueryCountTest.php @@ -0,0 +1,37 @@ +create(); + + $user = User::factory()->create(); + $user->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']); + + $resolver = app(CapabilityResolver::class); + + $membershipSelects = 0; + + DB::listen(function ($query) use (&$membershipSelects): void { + if (str_contains($query->sql, 'tenant_memberships')) { + $membershipSelects++; + } + }); + + expect($resolver->can($user, $tenant, Capabilities::TENANT_VIEW))->toBeTrue(); + + for ($i = 0; $i < 10; $i++) { + expect($resolver->can($user, $tenant, Capabilities::TENANT_VIEW))->toBeTrue(); + expect($resolver->can($user, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue(); + } + + expect($membershipSelects)->toBe(1); +}); diff --git a/tests/Unit/Auth/CapabilityResolverTest.php b/tests/Unit/Auth/CapabilityResolverTest.php index b3bae81..0f229d4 100644 --- a/tests/Unit/Auth/CapabilityResolverTest.php +++ b/tests/Unit/Auth/CapabilityResolverTest.php @@ -15,18 +15,38 @@ $owner = User::factory()->create(); $owner->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']); + $manager = User::factory()->create(); + $manager->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Manager->value, 'source' => 'manual']); + $readonly = User::factory()->create(); $readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']); + $operator = User::factory()->create(); + $operator->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Operator->value, 'source' => 'manual']); + $resolver = app(CapabilityResolver::class); expect($resolver->isMember($owner, $tenant))->toBeTrue(); expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue(); expect($resolver->can($owner, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeTrue(); + expect($resolver->can($owner, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeTrue(); + + expect($resolver->isMember($manager, $tenant))->toBeTrue(); + expect($resolver->can($manager, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue(); + expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeTrue(); + expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue(); + expect($resolver->can($manager, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse(); + expect($resolver->can($manager, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeFalse(); + + expect($resolver->isMember($operator, $tenant))->toBeTrue(); + expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue(); + expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse(); expect($resolver->isMember($readonly, $tenant))->toBeTrue(); expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue(); expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_MANAGE))->toBeFalse(); + expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeFalse(); + expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse(); expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse(); $outsider = User::factory()->create(); diff --git a/tests/Unit/Auth/NoRoleStringChecksTest.php b/tests/Unit/Auth/NoRoleStringChecksTest.php new file mode 100644 index 0000000..3afca09 --- /dev/null +++ b/tests/Unit/Auth/NoRoleStringChecksTest.php @@ -0,0 +1,62 @@ +map(fn (string $path) => realpath($path) ?: $path)->all(); + + $roleValuePattern = '(owner|manager|operator|readonly)'; + + $patterns = [ + // $membership->role === 'owner' / !== 'owner' + '/->role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + + // $role === 'owner' + '/\$role\s*(===|==|!==|!=)\s*[\"\']?'.$roleValuePattern.'[\"\']?/i', + + // case 'owner': + '/\bcase\s*[\"\']?'.$roleValuePattern.'[\"\']?\s*:/i', + + // match (...) { 'owner' => ... } + '/\bmatch\b[\s\S]*?\{[\s\S]*?[\"\']?'.$roleValuePattern.'[\"\']?\s*=>/i', + ]; + + $filesystem = new Filesystem; + + $violations = []; + + foreach ($filesystem->allFiles(app_path()) as $file) { + $path = $file->getRealPath(); + + if ($path === false) { + continue; + } + + if (in_array($path, $allowedFiles, true)) { + continue; + } + + $contents = $filesystem->get($path); + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $contents) === 1) { + $violations[] = $path; + break; + } + } + } + + if ($violations !== []) { + throw new RuntimeException('Role-string checks must live in RoleCapabilityMap / TenantMembershipManager only. Offenders: '.implode(', ', $violations)); + } + + expect($violations)->toBeEmpty(); +}); diff --git a/tests/Unit/Auth/UnknownCapabilityGuardTest.php b/tests/Unit/Auth/UnknownCapabilityGuardTest.php new file mode 100644 index 0000000..544b449 --- /dev/null +++ b/tests/Unit/Auth/UnknownCapabilityGuardTest.php @@ -0,0 +1,20 @@ +create(); + + $user = User::factory()->create(); + $user->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']); + + $resolver = app(CapabilityResolver::class); + + $resolver->can($user, $tenant, 'tenant_membership.managee'); +})->throws(InvalidArgumentException::class); diff --git a/tests/Unit/Intune/VersionServiceConcurrencyTest.php b/tests/Unit/Intune/VersionServiceConcurrencyTest.php new file mode 100644 index 0000000..0923138 --- /dev/null +++ b/tests/Unit/Intune/VersionServiceConcurrencyTest.php @@ -0,0 +1,82 @@ +create(); + $policy = Policy::factory()->for($tenant)->create(); + $policy->load('tenant'); + + $service = new VersionService( + auditLogger: new AuditLogger, + snapshotService: Mockery::mock(PolicySnapshotService::class), + assignmentFetcher: Mockery::mock(AssignmentFetcher::class), + groupResolver: Mockery::mock(GroupResolver::class), + assignmentFilterResolver: Mockery::mock(AssignmentFilterResolver::class), + scopeTagResolver: Mockery::mock(ScopeTagResolver::class), + ); + + $fired = false; + $collisionInserted = false; + $dispatcher = PolicyVersion::getEventDispatcher(); + + PolicyVersion::creating(function (PolicyVersion $model) use (&$fired, &$collisionInserted): void { + if ($fired) { + return; + } + + $fired = true; + + PolicyVersion::withoutEvents(function () use ($model, &$collisionInserted): void { + PolicyVersion::query()->create([ + 'tenant_id' => $model->tenant_id, + 'policy_id' => $model->policy_id, + 'version_number' => $model->version_number, + 'policy_type' => $model->policy_type, + 'platform' => $model->platform, + 'created_by' => $model->created_by, + 'captured_at' => now(), + 'snapshot' => $model->snapshot, + 'metadata' => $model->metadata, + 'assignments' => $model->assignments, + 'scope_tags' => $model->scope_tags, + 'assignments_hash' => $model->assignments_hash, + 'scope_tags_hash' => $model->scope_tags_hash, + ]); + + $collisionInserted = true; + }); + }); + + try { + $version = $service->captureVersion( + policy: $policy, + payload: ['id' => 'p-1'], + createdBy: 'tester@example.com', + metadata: ['source' => 'test'], + assignments: null, + scopeTags: null, + ); + } finally { + PolicyVersion::flushEventListeners(); + PolicyVersion::setEventDispatcher($dispatcher); + } + + expect($fired)->toBeTrue(); + expect($collisionInserted)->toBeTrue(); + expect($version->policy_id)->toBe($policy->getKey()); + expect($version->version_number)->toBe(1); + expect(PolicyVersion::query()->where('policy_id', $policy->getKey())->count())->toBe(1); +});