065-tenant-rbac-v1 #79

Merged
ahmido merged 3 commits from 065-tenant-rbac-v1 into dev 2026-01-28 21:09:48 +00:00
85 changed files with 3853 additions and 450 deletions

View File

@ -141,7 +141,7 @@ ### Spec-First Workflow
## Quality Gates ## Quality Gates
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`. - 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 ## Governance

View File

@ -896,9 +896,9 @@ ### Replaced Utilities
</laravel-boost-guidelines> </laravel-boost-guidelines>
## Recent Changes ## 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 - 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` - 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 ## 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)

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Concerns;
use Filament\Facades\Filament;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
trait ScopesGlobalSearchToTenant
{
/**
* The Eloquent relationship name used to scope records to the current tenant.
*/
protected static string $globalSearchTenantRelationship = 'tenant';
public static function getGlobalSearchEloquentQuery(): Builder
{
$query = static::getModel()::query();
if (! static::isScopedToTenant()) {
$panel = Filament::getCurrentOrDefaultPanel();
if ($panel?->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);
}
}

View File

@ -13,6 +13,7 @@
use App\Services\Drift\DriftRunSelector; use App\Services\Drift\DriftRunSelector;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
@ -20,6 +21,7 @@
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Pages\Page; use Filament\Pages\Page;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class DriftLanding extends Page 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->state = 'blocked';
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.'; $this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';

View File

@ -5,11 +5,12 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\TenantRole; use App\Support\Auth\Capabilities;
use Filament\Forms; use Filament\Forms;
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant; use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
class RegisterTenant extends BaseRegisterTenant class RegisterTenant extends BaseRegisterTenant
{ {
@ -20,7 +21,25 @@ public static function getLabel(): string
public static function canView(): bool 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 public function form(Schema $schema): Schema
@ -69,6 +88,8 @@ public function form(Schema $schema): Schema
*/ */
protected function handleRegistration(array $data): Model protected function handleRegistration(array $data): Model
{ {
abort_unless(static::canView(), 403);
$tenant = Tenant::create($data); $tenant = Tenant::create($data);
$user = auth()->user(); $user = auth()->user();
@ -76,7 +97,7 @@ protected function handleRegistration(array $data): Model
if ($user instanceof User) { if ($user instanceof User) {
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$tenant->getKey() => [ $tenant->getKey() => [
'role' => TenantRole::Owner->value, 'role' => 'owner',
'source' => 'manual', 'source' => 'manual',
'created_by_user_id' => $user->getKey(), 'created_by_user_id' => $user->getKey(),
], ],
@ -88,7 +109,7 @@ protected function handleRegistration(array $data): Model
context: [ context: [
'metadata' => [ 'metadata' => [
'user_id' => (int) $user->getKey(), 'user_id' => (int) $user->getKey(),
'role' => TenantRole::Owner->value, 'role' => 'owner',
'source' => 'manual', 'source' => 'manual',
], ],
], ],

View File

@ -15,6 +15,7 @@
use App\Services\BackupScheduling\ScheduleTimeService; use App\Services\BackupScheduling\ScheduleTimeService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -22,7 +23,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\TenantRole;
use BackedEnum; use BackedEnum;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use DateTimeZone; use DateTimeZone;
@ -50,6 +50,7 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
@ -60,45 +61,63 @@ class BackupScheduleResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; 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 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 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 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 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 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 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 public static function form(Schema $schema): Schema
@ -300,11 +319,18 @@ public static function table(Table $table): Table
->label('Run now') ->label('Run now')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->visible(function (): bool {
->action(function (BackupSchedule $record, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current(); $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(); $user = auth()->user();
$userId = auth()->id(); $userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
@ -424,11 +450,18 @@ public static function table(Table $table): Table
->label('Retry') ->label('Retry')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->color('warning')
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false) ->visible(function (): bool {
->action(function (BackupSchedule $record, HasTable $livewire): void {
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
$tenant = Tenant::current(); $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(); $user = auth()->user();
$userId = auth()->id(); $userId = auth()->id();
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null); $userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
@ -545,9 +578,19 @@ public static function table(Table $table): Table
->send(); ->send();
}), }),
EditAction::make() 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() 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'), ])->icon('heroicon-o-ellipsis-vertical'),
]) ])
->bulkActions([ ->bulkActions([
@ -556,9 +599,17 @@ public static function table(Table $table): Table
->label('Run now') ->label('Run now')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->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 { ->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()) { if ($records->isEmpty()) {
return; return;
@ -685,9 +736,17 @@ public static function table(Table $table): Table
->label('Retry') ->label('Retry')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('warning') ->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 { ->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()) { if ($records->isEmpty()) {
return; return;
@ -811,7 +870,12 @@ public static function table(Table $table): Table
} }
}), }),
DeleteBulkAction::make('bulk_delete') 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);
}),
]), ]),
]); ]);
} }

View File

@ -14,6 +14,7 @@
use App\Services\Intune\BackupService; use App\Services\Intune\BackupService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -33,6 +34,7 @@
use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class BackupSetResource extends Resource class BackupSetResource extends Resource
@ -43,6 +45,12 @@ class BackupSetResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; 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 public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -87,8 +95,14 @@ public static function table(Table $table): Table
->color('success') ->color('success')
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation() ->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) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->restore(); $record->restore();
$record->items()->withTrashed()->restore(); $record->items()->withTrashed()->restore();
@ -113,8 +127,14 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation() ->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) { ->action(function (BackupSet $record, AuditLogger $auditLogger) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->delete(); $record->delete();
if ($record->tenant) { if ($record->tenant) {
@ -138,8 +158,14 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->requiresConfirmation() ->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) { ->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()) { if ($record->restoreRuns()->withTrashed()->exists()) {
Notification::make() Notification::make()
->title('Cannot force delete backup set') ->title('Cannot force delete backup set')
@ -178,6 +204,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -212,6 +240,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -258,6 +288,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -278,6 +310,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -324,6 +358,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -359,6 +395,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */

View File

@ -13,7 +13,9 @@ class ListBackupSets extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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.'),
]; ];
} }
} }

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -23,6 +24,7 @@
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class BackupItemsRelationManager extends RelationManager class BackupItemsRelationManager extends RelationManager
{ {
@ -132,6 +134,10 @@ public function table(Table $table): Table
Actions\Action::make('addPolicies') Actions\Action::make('addPolicies')
->label('Add Policies') ->label('Add Policies')
->icon('heroicon-o-plus') ->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') ->modalHeading('Add Policies')
->modalSubmitAction(false) ->modalSubmitAction(false)
->modalCancelActionLabel('Close') ->modalCancelActionLabel('Close')
@ -173,7 +179,11 @@ public function table(Table $table): Table
$tenant = $backupSet->tenant ?? Tenant::current(); $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); abort(403);
} }
@ -252,7 +262,11 @@ public function table(Table $table): Table
$tenant = $backupSet->tenant ?? Tenant::current(); $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); abort(403);
} }

View File

@ -10,10 +10,12 @@
use App\Models\User; use App\Models\User;
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroups extends ListRecords class ListEntraGroups extends ListRecords
{ {
@ -45,9 +47,43 @@ protected function getHeaderActions(): array
return false; 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 { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
@ -66,11 +102,7 @@ protected function getHeaderActions(): array
abort(403); abort(403);
} }
$role = $user->tenantRole($tenant); abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
if (! ($role?->canSync() ?? false)) {
abort(403);
}
$selectionKey = EntraGroupSelection::allGroupsV1(); $selectionKey = EntraGroupSelection::allGroupsV1();

View File

@ -9,8 +9,10 @@
use App\Models\User; use App\Models\User;
use App\Notifications\RunStatusChangedNotification; use App\Notifications\RunStatusChangedNotification;
use App\Services\Directory\EntraGroupSelection; use App\Services\Directory\EntraGroupSelection;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroupSyncRuns extends ListRecords class ListEntraGroupSyncRuns extends ListRecords
{ {
@ -36,9 +38,11 @@ protected function getHeaderActions(): array
return false; 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 { ->action(function (): void {
$user = auth()->user(); $user = auth()->user();
@ -57,11 +61,7 @@ protected function getHeaderActions(): array
abort(403); abort(403);
} }
$role = $user->tenantRole($tenant); abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
if (! ($role?->canSync() ?? false)) {
abort(403);
}
$selectionKey = EntraGroupSelection::allGroupsV1(); $selectionKey = EntraGroupSelection::allGroupsV1();

View File

@ -9,6 +9,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder; use App\Services\Drift\DriftFindingDiffBuilder;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use BackedEnum; use BackedEnum;
@ -25,6 +26,7 @@
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
@ -40,6 +42,33 @@ class FindingResource extends Resource
protected static ?string $navigationLabel = 'Findings'; 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 public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -389,7 +418,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()->getKey(); $tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->addSelect([ ->addSelect([

View File

@ -8,6 +8,7 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService; use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -24,6 +25,8 @@
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class InventoryItemResource extends Resource class InventoryItemResource extends Resource
@ -38,6 +41,33 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; 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 public static function form(Schema $schema): Schema
{ {
return $schema; return $schema;
@ -225,7 +255,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()->getKey(); $tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId)) ->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -11,6 +11,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService; use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
@ -23,6 +24,7 @@
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Size; use Filament\Support\Enums\Size;
use Illuminate\Support\Facades\Gate;
class ListInventoryItems extends ListRecords class ListInventoryItems extends ListRecords
{ {
@ -109,17 +111,57 @@ protected function getHeaderActions(): array
return false; 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 { ->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$user = auth()->user(); $user = auth()->user();
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403, 'Not allowed'); 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'); abort(403, 'Not allowed');
} }

View File

@ -6,6 +6,7 @@
use App\Filament\Resources\InventorySyncRunResource\Pages; use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun; use App\Models\InventorySyncRun;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -19,6 +20,8 @@
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class InventorySyncRunResource extends Resource class InventorySyncRunResource extends Resource
@ -35,6 +38,33 @@ class InventorySyncRunResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory'; 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 public static function getNavigationLabel(): string
{ {
return 'Sync History'; return 'Sync History';
@ -155,7 +185,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder public static function getEloquentQuery(): Builder
{ {
$tenantId = Tenant::current()->getKey(); $tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery() return parent::getEloquentQuery()
->with('user') ->with('user')

View File

@ -14,6 +14,7 @@
use App\Services\Intune\PolicyNormalizer; use App\Services\Intune\PolicyNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -40,6 +41,7 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class PolicyResource extends Resource class PolicyResource extends Resource
@ -366,8 +368,14 @@ public static function table(Table $table): Table
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation() ->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) { ->action(function (Policy $record, HasTable $livewire) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->ignore(); $record->ignore();
Notification::make() Notification::make()
@ -380,8 +388,14 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->color('success') ->color('success')
->requiresConfirmation() ->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) { ->action(function (Policy $record) {
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$record->unignore(); $record->unignore();
Notification::make() Notification::make()
@ -406,18 +420,25 @@ public static function table(Table $table): Table
} }
$tenant = Tenant::current(); $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 { ->action(function (Policy $record, HasTable $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403); abort(403);
} }
@ -461,7 +482,9 @@ public static function table(Table $table): Table
Actions\Action::make('export') Actions\Action::make('export')
->label('Export to Backup') ->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down') ->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([ ->form([
Forms\Components\TextInput::make('backup_name') Forms\Components\TextInput::make('backup_name')
->label('Backup Name') ->label('Backup Name')
@ -476,6 +499,8 @@ public static function table(Table $table): Table
abort(403); abort(403);
} }
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$ids = [(int) $record->getKey()]; $ids = [(int) $record->getKey()];
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -533,6 +558,16 @@ public static function table(Table $table): Table
return $value === 'ignored'; 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) { ->form(function (Collection $records) {
if ($records->count() >= 20) { if ($records->count() >= 20) {
return [ return [
@ -558,6 +593,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class); $selection = app(BulkSelectionIdentity::class);
@ -616,6 +653,16 @@ public static function table(Table $table): Table
return ! in_array($value, [null, 'ignored'], true); 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) { ->action(function (Collection $records, HasTable $livewire) {
$tenant = Tenant::current(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
@ -626,6 +673,8 @@ public static function table(Table $table): Table
abort(403); abort(403);
} }
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class); $selection = app(BulkSelectionIdentity::class);
@ -704,8 +753,11 @@ public static function table(Table $table): Table
} }
$tenant = Tenant::current(); $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; return true;
} }
@ -719,11 +771,15 @@ public static function table(Table $table): Table
$user = auth()->user(); $user = auth()->user();
$count = $records->count(); $count = $records->count();
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! $user instanceof User) { if (! $user instanceof User) {
abort(403); abort(403);
} }
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) { if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403); abort(403);
} }
@ -777,6 +833,16 @@ public static function table(Table $table): Table
BulkAction::make('bulk_export') BulkAction::make('bulk_export')
->label('Export to Backup') ->label('Export to Backup')
->icon('heroicon-o-archive-box-arrow-down') ->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([ ->form([
Forms\Components\TextInput::make('backup_name') Forms\Components\TextInput::make('backup_name')
->label('Backup Name') ->label('Backup Name')
@ -793,6 +859,8 @@ public static function table(Table $table): Table
abort(403); abort(403);
} }
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class); $selection = app(BulkSelectionIdentity::class);

View File

@ -7,12 +7,14 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions; use Filament\Actions;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListPolicies extends ListRecords class ListPolicies extends ListRecords
{ {
@ -35,7 +37,28 @@ protected function getHeaderActions(): array
$tenant = Tenant::current(); $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 { ->action(function (self $livewire): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
@ -45,7 +68,15 @@ protected function getHeaderActions(): array
abort(403); 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); abort(403);
} }

View File

@ -6,6 +6,7 @@
use App\Models\PolicyVersion; use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use Filament\Actions; use Filament\Actions;
@ -14,6 +15,7 @@
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Gate;
class VersionsRelationManager extends RelationManager class VersionsRelationManager extends RelationManager
{ {
@ -42,6 +44,8 @@ public function table(Table $table): Table
->color('danger') ->color('danger')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') ->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() ->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?") ->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
->modalSubheading('Creates a restore run using this policy version snapshot.') ->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) { ->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
$tenant = Tenant::current(); $tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if ($record->tenant_id !== $tenant->id) { if ($record->tenant_id !== $tenant->id) {
Notification::make() Notification::make()
->title('Policy version belongs to a different tenant') ->title('Policy version belongs to a different tenant')

View File

@ -16,6 +16,7 @@
use App\Services\Intune\VersionDiff; use App\Services\Intune\VersionDiff;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -38,6 +39,7 @@
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use UnitEnum; use UnitEnum;
class PolicyVersionResource extends Resource class PolicyVersionResource extends Resource
@ -210,8 +212,19 @@ public static function table(Table $table): Table
->label('Restore via Wizard') ->label('Restore via Wizard')
->icon('heroicon-o-arrow-path-rounded-square') ->icon('heroicon-o-arrow-path-rounded-square')
->color('primary') ->color('primary')
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only') ->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only'
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).') || ! (($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() ->requiresConfirmation()
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?") ->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.') ->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(); $tenant = Tenant::current();
$user = auth()->user(); $user = auth()->user();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
if (! $tenant || $record->tenant_id !== $tenant->id) { if (! $tenant || $record->tenant_id !== $tenant->id) {
Notification::make() Notification::make()
->title('Policy version belongs to a different tenant') ->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') ->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (PolicyVersion $record) => ! $record->trashed()) ->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) { ->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(); $record->delete();
if ($record->tenant) { if ($record->tenant) {
@ -329,7 +372,35 @@ public static function table(Table $table): Table
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed()) ->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) { ->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) { if ($record->tenant) {
$auditLogger->log( $auditLogger->log(
tenant: $record->tenant, tenant: $record->tenant,
@ -355,7 +426,35 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (PolicyVersion $record) => $record->trashed()) ->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) { ->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(); $record->restore();
if ($record->tenant) { if ($record->tenant) {
@ -392,6 +491,28 @@ public static function table(Table $table): Table
return $isOnlyTrashed; 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) { ->form(function (Collection $records) {
$fields = [ $fields = [
Forms\Components\TextInput::make('retention_days') Forms\Components\TextInput::make('retention_days')
@ -427,6 +548,9 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -499,6 +623,28 @@ public static function table(Table $table): Table
return ! $isOnlyTrashed; 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?") ->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.') ->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
->action(function (Collection $records) { ->action(function (Collection $records) {
@ -511,6 +657,9 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -565,6 +714,28 @@ public static function table(Table $table): Table
return ! $isOnlyTrashed; 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?") ->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.') ->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
->form([ ->form([
@ -586,6 +757,9 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless($user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */

View File

@ -2,6 +2,7 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
use App\Filament\Resources\ProviderConnectionResource\Pages; use App\Filament\Resources\ProviderConnectionResource\Pages;
use App\Jobs\ProviderComplianceSnapshotJob; use App\Jobs\ProviderComplianceSnapshotJob;
use App\Jobs\ProviderConnectionHealthCheckJob; use App\Jobs\ProviderConnectionHealthCheckJob;
@ -13,6 +14,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -32,6 +34,8 @@
class ProviderConnectionResource extends Resource class ProviderConnectionResource extends Resource
{ {
use ScopesGlobalSearchToTenant;
protected static bool $isScopedToTenant = false; protected static bool $isScopedToTenant = false;
protected static ?string $model = ProviderConnection::class; protected static ?string $model = ProviderConnection::class;
@ -51,17 +55,17 @@ public static function form(Schema $schema): Schema
TextInput::make('display_name') TextInput::make('display_name')
->label('Display name') ->label('Display name')
->required() ->required()
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->maxLength(255), ->maxLength(255),
TextInput::make('entra_tenant_id') TextInput::make('entra_tenant_id')
->label('Entra tenant ID') ->label('Entra tenant ID')
->required() ->required()
->maxLength(255) ->maxLength(255)
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current())) ->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
->rules(['uuid']), ->rules(['uuid']),
Toggle::make('is_default') Toggle::make('is_default')
->label('Default connection') ->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.'), ->helperText('Exactly one default connection is required per tenant/provider.'),
TextInput::make('status') TextInput::make('status')
->label('Status') ->label('Status')
@ -148,15 +152,49 @@ public static function table(Table $table): Table
->label('Check connection') ->label('Check connection')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) ->visible(function (ProviderConnection $record): bool {
&& $record->status !== 'disabled') $user = auth()->user();
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current(); $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(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
$initiator = $user; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -220,15 +258,49 @@ public static function table(Table $table): Table
->label('Inventory sync') ->label('Inventory sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) ->visible(function (ProviderConnection $record): bool {
&& $record->status !== 'disabled') $user = auth()->user();
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current(); $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(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
$initiator = $user; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -292,15 +364,49 @@ public static function table(Table $table): Table
->label('Compliance snapshot') ->label('Compliance snapshot')
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current()) ->visible(function (ProviderConnection $record): bool {
&& $record->status !== 'disabled') $user = auth()->user();
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
if (! $user instanceof User) {
return false;
}
$tenant = Tenant::current(); $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(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); abort_unless($user instanceof User, 403);
abort_unless($user->canAccessTenant($tenant), 404);
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
$initiator = $user; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -364,13 +470,13 @@ public static function table(Table $table): Table
->label('Set as default') ->label('Set as default')
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->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->status !== 'disabled'
&& ! $record->is_default) && ! $record->is_default)
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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(); $record->makeDefault();
@ -407,7 +513,7 @@ public static function table(Table $table): Table
->icon('heroicon-o-key') ->icon('heroicon-o-key')
->color('primary') ->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.') ->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([ ->form([
TextInput::make('client_id') TextInput::make('client_id')
->label('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 { ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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( $credentials->upsertClientSecretCredential(
connection: $record, connection: $record,
@ -462,12 +568,12 @@ public static function table(Table $table): Table
->label('Enable connection') ->label('Enable connection')
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->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') && $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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(); $hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent'; $status = $hadCredentials ? 'connected' : 'needs_consent';
@ -527,12 +633,12 @@ public static function table(Table $table): Table
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->color('danger') ->color('danger')
->requiresConfirmation() ->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') && $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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; $previousStatus = (string) $record->status;

View File

@ -13,6 +13,7 @@
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Providers\CredentialManager; use App\Services\Providers\CredentialManager;
use App\Services\Providers\ProviderOperationStartGate; use App\Services\Providers\ProviderOperationStartGate;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\Action; use Filament\Actions\Action;
@ -119,7 +120,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-eye') ->icon('heroicon-o-eye')
->color('gray') ->color('gray')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.view', $tenant) && Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
&& OperationRun::query() && OperationRun::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
->where('type', 'provider.connection.check') ->where('type', 'provider.connection.check')
@ -150,16 +151,45 @@ protected function getHeaderActions(): array
->label('Check connection') ->label('Check connection')
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(function (ProviderConnection $record): bool {
&& Gate::allows('provider.run', $tenant) $tenant = Tenant::current();
&& $record->status !== 'disabled') $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 { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); 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; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -224,7 +254,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-key') ->icon('heroicon-o-key')
->color('primary') ->color('primary')
->modalDescription('Client secret is stored encrypted and will never be shown again.') ->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([ ->form([
TextInput::make('client_id') TextInput::make('client_id')
->label('Client ID') ->label('Client ID')
@ -239,7 +269,7 @@ protected function getHeaderActions(): array
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void { ->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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( $credentials->upsertClientSecretCredential(
connection: $record, connection: $record,
@ -280,7 +310,7 @@ protected function getHeaderActions(): array
->icon('heroicon-o-star') ->icon('heroicon-o-star')
->color('primary') ->color('primary')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant) && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status !== 'disabled' && $record->status !== 'disabled'
&& ! $record->is_default && ! $record->is_default
&& ProviderConnection::query() && ProviderConnection::query()
@ -290,7 +320,7 @@ protected function getHeaderActions(): array
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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(); $record->makeDefault();
@ -326,16 +356,45 @@ protected function getHeaderActions(): array
->label('Inventory sync') ->label('Inventory sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(function (ProviderConnection $record): bool {
&& Gate::allows('provider.run', $tenant) $tenant = Tenant::current();
&& $record->status !== 'disabled') $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 { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); 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; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -399,16 +458,45 @@ protected function getHeaderActions(): array
->label('Compliance snapshot') ->label('Compliance snapshot')
->icon('heroicon-o-shield-check') ->icon('heroicon-o-shield-check')
->color('info') ->color('info')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(function (ProviderConnection $record): bool {
&& Gate::allows('provider.run', $tenant) $tenant = Tenant::current();
&& $record->status !== 'disabled') $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 { ->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
$tenant = Tenant::current(); $tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
$user = auth()->user(); $user = auth()->user();
abort_unless($tenant instanceof Tenant, 404);
abort_unless($user instanceof User, 403); 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; $initiator = $user;
$result = $gate->start( $result = $gate->start(
@ -473,12 +561,12 @@ protected function getHeaderActions(): array
->icon('heroicon-o-play') ->icon('heroicon-o-play')
->color('success') ->color('success')
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant) && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status === 'disabled') && $record->status === 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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(); $hadCredentials = $record->credential()->exists();
$status = $hadCredentials ? 'connected' : 'needs_consent'; $status = $hadCredentials ? 'connected' : 'needs_consent';
@ -539,12 +627,12 @@ protected function getHeaderActions(): array
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant ->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
&& Gate::allows('provider.manage', $tenant) && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
&& $record->status !== 'disabled') && $record->status !== 'disabled')
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void { ->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
$tenant = Tenant::current(); $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; $previousStatus = (string) $record->status;
@ -591,7 +679,7 @@ protected function getFormActions(): array
{ {
$tenant = Tenant::current(); $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(); return parent::getFormActions();
} }
@ -604,7 +692,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model
{ {
$tenant = Tenant::current(); $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); return parent::handleRecordUpdate($record, $data);
} }

View File

@ -13,7 +13,11 @@ class ListProviderConnections extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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.'),
]; ];
} }
} }

View File

@ -21,6 +21,7 @@
use App\Services\Intune\RestoreService; use App\Services\Intune\RestoreService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
@ -49,6 +50,7 @@
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\QueryException; use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use UnitEnum; use UnitEnum;
@ -61,6 +63,12 @@ class RestoreRunResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore'; 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 public static function form(Schema $schema): Schema
{ {
return $schema return $schema
@ -753,12 +761,18 @@ public static function table(Table $table): Table
&& $backupSet !== null && $backupSet !== null
&& ! $backupSet->trashed(); && ! $backupSet->trashed();
}) })
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->action(function ( ->action(function (
RestoreRun $record, RestoreRun $record,
RestoreService $restoreService, RestoreService $restoreService,
\App\Services\Intune\AuditLogger $auditLogger, \App\Services\Intune\AuditLogger $auditLogger,
HasTable $livewire HasTable $livewire
) { ) {
$currentTenant = Tenant::current();
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
$tenant = $record->tenant; $tenant = $record->tenant;
$backupSet = $record->backupSet; $backupSet = $record->backupSet;
@ -924,8 +938,14 @@ public static function table(Table $table): Table
->color('success') ->color('success')
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation() ->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) { ->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(); $record->restore();
if ($record->tenant) { if ($record->tenant) {
@ -949,8 +969,14 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation() ->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) { ->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()) { if (! $record->isDeletable()) {
Notification::make() Notification::make()
->title('Restore run cannot be archived') ->title('Restore run cannot be archived')
@ -984,8 +1010,14 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->requiresConfirmation() ->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) { ->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) { if ($record->tenant) {
$auditLogger->log( $auditLogger->log(
tenant: $record->tenant, tenant: $record->tenant,
@ -1013,6 +1045,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -1046,6 +1080,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -1092,6 +1128,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-arrow-uturn-left') ->icon('heroicon-o-arrow-uturn-left')
->color('success') ->color('success')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -1112,6 +1150,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -1169,6 +1209,8 @@ public static function table(Table $table): Table
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->color('danger') ->color('danger')
->requiresConfirmation() ->requiresConfirmation()
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
->hidden(function (HasTable $livewire): bool { ->hidden(function (HasTable $livewire): bool {
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? []; $trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
$value = $trashedFilterState['value'] ?? null; $value = $trashedFilterState['value'] ?? null;
@ -1198,6 +1240,8 @@ public static function table(Table $table): Table
return; return;
} }
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
$initiator = $user instanceof User ? $user : null; $initiator = $user instanceof User ? $user : null;
/** @var BulkSelectionIdentity $selection */ /** @var BulkSelectionIdentity $selection */
@ -1450,6 +1494,8 @@ public static function createRestoreRun(array $data): RestoreRun
/** @var Tenant $tenant */ /** @var Tenant $tenant */
$tenant = Tenant::current(); $tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
/** @var BackupSet $backupSet */ /** @var BackupSet $backupSet */
$backupSet = BackupSet::findOrFail($data['backup_set_id']); $backupSet = BackupSet::findOrFail($data['backup_set_id']);

View File

@ -5,10 +5,12 @@
use App\Filament\Resources\RestoreRunResource; use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet; use App\Models\BackupSet;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard; use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\On; use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord class CreateRestoreRun extends CreateRecord
@ -17,6 +19,13 @@ class CreateRestoreRun extends CreateRecord
protected static string $resource = RestoreRunResource::class; 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 public function getSteps(): array
{ {
return RestoreRunResource::getWizardSteps(); return RestoreRunResource::getWizardSteps();

View File

@ -13,7 +13,9 @@ class ListRestoreRuns extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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.'),
]; ];
} }
} }

View File

@ -9,6 +9,7 @@
use App\Jobs\SyncPoliciesJob; use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver; use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface; use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
@ -18,6 +19,7 @@
use App\Services\Intune\TenantPermissionService; use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -25,7 +27,6 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\TenantRole;
use BackedEnum; use BackedEnum;
use Filament\Actions; use Filament\Actions;
use Filament\Actions\ActionGroup; use Filament\Actions\ActionGroup;
@ -39,8 +40,10 @@
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Throwable; use Throwable;
@ -57,6 +60,84 @@ class TenantResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Settings'; 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 public static function form(Schema $schema): Schema
{ {
// ... [Schema Omitted - No Change] ... // ... [Schema Omitted - No Change] ...
@ -188,8 +269,11 @@ public static function table(Table $table): Table
]), ]),
]) ])
->actions([ ->actions([
Actions\ViewAction::make(),
ActionGroup::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') Actions\Action::make('syncTenant')
->label('Sync') ->label('Sync')
->icon('heroicon-o-arrow-path') ->icon('heroicon-o-arrow-path')
@ -206,10 +290,34 @@ public static function table(Table $table): Table
return false; 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 { ->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 */ /** @var OperationRunService $opService */
$opService = app(OperationRunService::class); $opService = app(OperationRunService::class);
@ -224,6 +332,7 @@ public static function table(Table $table): Table
'scope' => 'full', 'scope' => 'full',
'types' => $typeNames, 'types' => $typeNames,
]; ];
$opRun = $opService->ensureRun( $opRun = $opService->ensureRun(
tenant: $record, tenant: $record,
type: 'policy.sync', type: 'policy.sync',
@ -288,12 +397,40 @@ public static function table(Table $table): Table
->color('primary') ->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record)) ->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()), ->visible(fn (Tenant $record) => $record->isActive()),
Actions\EditAction::make(), Actions\Action::make('edit')
Actions\RestoreAction::make() ->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') ->label('Restore')
->color('success') ->color('success')
->successNotificationTitle('Tenant reactivated') ->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( $auditLogger->log(
tenant: $record, tenant: $record,
action: 'tenant.restored', action: 'tenant.restored',
@ -303,34 +440,31 @@ public static function table(Table $table): Table
context: ['metadata' => ['tenant_id' => $record->tenant_id]] 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') Actions\Action::make('admin_consent')
->label('Admin consent') ->label('Admin consent')
->icon('heroicon-o-clipboard-document') ->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->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(), ->openUrlInNewTab(),
Actions\Action::make('open_in_entra') Actions\Action::make('open_in_entra')
->label('Open in Entra') ->label('Open in Entra')
@ -343,6 +477,16 @@ public static function table(Table $table): Table
->icon('heroicon-o-check-badge') ->icon('heroicon-o-check-badge')
->color('primary') ->color('primary')
->requiresConfirmation() ->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 ( ->action(function (
Tenant $record, Tenant $record,
TenantConfigService $configService, TenantConfigService $configService,
@ -350,6 +494,16 @@ public static function table(Table $table): Table
RbacHealthService $rbacHealthService, RbacHealthService $rbacHealthService,
AuditLogger $auditLogger 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::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}), }),
static::rbacAction(), static::rbacAction(),
@ -358,8 +512,27 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-archive-box-x-mark') ->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation() ->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) { ->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(); $record->delete();
$auditLogger->log( $auditLogger->log(
@ -382,12 +555,35 @@ public static function table(Table $table): Table
->color('danger') ->color('danger')
->icon('heroicon-o-trash') ->icon('heroicon-o-trash')
->requiresConfirmation() ->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) { ->action(function (?Tenant $record, AuditLogger $auditLogger) {
if ($record === null) { if ($record === null) {
return; 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); $tenant = Tenant::withTrashed()->find($record->id);
if (! $tenant?->trashed()) { if (! $tenant?->trashed()) {
@ -415,7 +611,7 @@ public static function table(Table $table): Table
->success() ->success()
->send(); ->send();
}), }),
])->icon('heroicon-o-ellipsis-vertical'), ]),
]) ])
->bulkActions([ ->bulkActions([
Actions\BulkAction::make('syncSelected') Actions\BulkAction::make('syncSelected')
@ -431,11 +627,7 @@ public static function table(Table $table): Table
} }
return $user->tenants() return $user->tenants()
->whereIn('role', [ ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists(); ->exists();
}) })
->authorize(function (): bool { ->authorize(function (): bool {
@ -446,11 +638,7 @@ public static function table(Table $table): Table
} }
return $user->tenants() return $user->tenants()
->whereIn('role', [ ->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists(); ->exists();
}) })
->action(function (Collection $records, AuditLogger $auditLogger): void { ->action(function (Collection $records, AuditLogger $auditLogger): void {
@ -462,7 +650,7 @@ public static function table(Table $table): Table
$eligible = $records $eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive()) ->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()) { if ($eligible->isEmpty()) {
Notification::make() Notification::make()
@ -697,7 +885,16 @@ public static function rbacAction(): Actions\Action
->noSearchResultsMessage('No security groups found') ->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'), ->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() ->requiresConfirmation()
->action(function ( ->action(function (
array $data, array $data,
@ -705,6 +902,16 @@ public static function rbacAction(): Actions\Action
RbacOnboardingService $service, RbacOnboardingService $service,
AuditLogger $auditLogger 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()); $cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($cacheKey); $token = Cache::get($cacheKey);

View File

@ -4,7 +4,6 @@
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\User; use App\Models\User;
use App\Support\TenantRole;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateTenant extends CreateRecord class CreateTenant extends CreateRecord
@ -20,7 +19,7 @@ protected function afterCreate(): void
} }
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$this->record->getKey() => ['role' => TenantRole::Owner->value], $this->record->getKey() => ['role' => 'owner'],
]); ]);
} }
} }

View File

@ -3,8 +3,12 @@
namespace App\Filament\Resources\TenantResource\Pages; namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource; use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use Filament\Actions; use Filament\Actions;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Gate;
class EditTenant extends EditRecord class EditTenant extends EditRecord
{ {
@ -14,7 +18,42 @@ protected function getHeaderActions(): array
{ {
return [ return [
Actions\ViewAction::make(), 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();
}),
]; ];
} }
} }

View File

@ -13,7 +13,9 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ 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.'),
]; ];
} }
} }

View File

@ -6,7 +6,7 @@
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TenantMembershipManager; use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole; use App\Support\Auth\Capabilities;
use Filament\Actions; use Filament\Actions;
use Filament\Forms; use Filament\Forms;
use Filament\Notifications\Notification; use Filament\Notifications\Notification;
@ -26,10 +26,10 @@ public function table(Table $table): Table
->modifyQueryUsing(fn (Builder $query) => $query->with('user')) ->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
->columns([ ->columns([
Tables\Columns\TextColumn::make('user.name') Tables\Columns\TextColumn::make('user.name')
->label('User') ->label(__('User'))
->searchable(), ->searchable(),
Tables\Columns\TextColumn::make('user.email') Tables\Columns\TextColumn::make('user.email')
->label('Email') ->label(__('Email'))
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('role') Tables\Columns\TextColumn::make('role')
->badge() ->badge()
@ -41,7 +41,7 @@ public function table(Table $table): Table
]) ])
->headerActions([ ->headerActions([
Actions\Action::make('add_member') Actions\Action::make('add_member')
->label('Add member') ->label(__('Add member'))
->icon('heroicon-o-plus') ->icon('heroicon-o-plus')
->visible(function (): bool { ->visible(function (): bool {
$tenant = $this->getOwnerRecord(); $tenant = $this->getOwnerRecord();
@ -50,22 +50,22 @@ public function table(Table $table): Table
return false; return false;
} }
return Gate::allows('tenant_membership.manage', $tenant); return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
}) })
->form([ ->form([
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')
->label('User') ->label(__('User'))
->required() ->required()
->searchable() ->searchable()
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()), ->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
Forms\Components\Select::make('role') Forms\Components\Select::make('role')
->label('Role') ->label(__('Role'))
->required() ->required()
->options([ ->options([
TenantRole::Owner->value => 'Owner', 'owner' => __('Owner'),
TenantRole::Manager->value => 'Manager', 'manager' => __('Manager'),
TenantRole::Operator->value => 'Operator', 'operator' => __('Operator'),
TenantRole::Readonly->value => 'Readonly', 'readonly' => __('Readonly'),
]), ]),
]) ])
->action(function (array $data, TenantMembershipManager $manager): void { ->action(function (array $data, TenantMembershipManager $manager): void {
@ -80,13 +80,13 @@ public function table(Table $table): Table
abort(403); abort(403);
} }
if (! Gate::allows('tenant_membership.manage', $tenant)) { if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403); abort(403);
} }
$member = User::query()->find((int) $data['user_id']); $member = User::query()->find((int) $data['user_id']);
if (! $member) { if (! $member) {
Notification::make()->title('User not found')->danger()->send(); Notification::make()->title(__('User not found'))->danger()->send();
return; return;
} }
@ -96,12 +96,12 @@ public function table(Table $table): Table
tenant: $tenant, tenant: $tenant,
actor: $actor, actor: $actor,
member: $member, member: $member,
role: TenantRole::from((string) $data['role']), role: (string) $data['role'],
source: 'manual', source: 'manual',
); );
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make() Notification::make()
->title('Failed to add member') ->title(__('Failed to add member'))
->body($throwable->getMessage()) ->body($throwable->getMessage())
->danger() ->danger()
->send(); ->send();
@ -109,14 +109,15 @@ public function table(Table $table): Table
return; return;
} }
Notification::make()->title('Member added')->success()->send(); Notification::make()->title(__('Member added'))->success()->send();
$this->resetTable(); $this->resetTable();
}), }),
]) ])
->actions([ ->actions([
Actions\Action::make('change_role') Actions\Action::make('change_role')
->label('Change role') ->label(__('Change role'))
->icon('heroicon-o-pencil') ->icon('heroicon-o-pencil')
->requiresConfirmation()
->visible(function (): bool { ->visible(function (): bool {
$tenant = $this->getOwnerRecord(); $tenant = $this->getOwnerRecord();
@ -124,17 +125,17 @@ public function table(Table $table): Table
return false; return false;
} }
return Gate::allows('tenant_membership.manage', $tenant); return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
}) })
->form([ ->form([
Forms\Components\Select::make('role') Forms\Components\Select::make('role')
->label('Role') ->label(__('Role'))
->required() ->required()
->options([ ->options([
TenantRole::Owner->value => 'Owner', 'owner' => __('Owner'),
TenantRole::Manager->value => 'Manager', 'manager' => __('Manager'),
TenantRole::Operator->value => 'Operator', 'operator' => __('Operator'),
TenantRole::Readonly->value => 'Readonly', 'readonly' => __('Readonly'),
]), ]),
]) ])
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void { ->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
@ -149,7 +150,7 @@ public function table(Table $table): Table
abort(403); abort(403);
} }
if (! Gate::allows('tenant_membership.manage', $tenant)) { if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403); abort(403);
} }
@ -158,11 +159,11 @@ public function table(Table $table): Table
tenant: $tenant, tenant: $tenant,
actor: $actor, actor: $actor,
membership: $record, membership: $record,
newRole: TenantRole::from((string) $data['role']), newRole: (string) $data['role'],
); );
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make() Notification::make()
->title('Failed to change role') ->title(__('Failed to change role'))
->body($throwable->getMessage()) ->body($throwable->getMessage())
->danger() ->danger()
->send(); ->send();
@ -170,11 +171,11 @@ public function table(Table $table): Table
return; return;
} }
Notification::make()->title('Role updated')->success()->send(); Notification::make()->title(__('Role updated'))->success()->send();
$this->resetTable(); $this->resetTable();
}), }),
Actions\Action::make('remove') Actions\Action::make('remove')
->label('Remove') ->label(__('Remove'))
->color('danger') ->color('danger')
->icon('heroicon-o-x-mark') ->icon('heroicon-o-x-mark')
->requiresConfirmation() ->requiresConfirmation()
@ -185,7 +186,7 @@ public function table(Table $table): Table
return false; return false;
} }
return Gate::allows('tenant_membership.manage', $tenant); return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
}) })
->action(function (TenantMembership $record, TenantMembershipManager $manager): void { ->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
$tenant = $this->getOwnerRecord(); $tenant = $this->getOwnerRecord();
@ -199,7 +200,7 @@ public function table(Table $table): Table
abort(403); abort(403);
} }
if (! Gate::allows('tenant_membership.manage', $tenant)) { if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
abort(403); abort(403);
} }
@ -207,7 +208,7 @@ public function table(Table $table): Table
$manager->removeMember($tenant, $actor, $record); $manager->removeMember($tenant, $actor, $record);
} catch (\Throwable $throwable) { } catch (\Throwable $throwable) {
Notification::make() Notification::make()
->title('Failed to remove member') ->title(__('Failed to remove member'))
->body($throwable->getMessage()) ->body($throwable->getMessage())
->danger() ->danger()
->send(); ->send();
@ -215,7 +216,7 @@ public function table(Table $table): Table
return; return;
} }
Notification::make()->title('Member removed')->success()->send(); Notification::make()->title(__('Member removed'))->success()->send();
$this->resetTable(); $this->resetTable();
}), }),
]) ])

View File

@ -8,11 +8,13 @@
use App\Services\Intune\PolicySyncService; use App\Services\Intune\PolicySyncService;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter; use App\Services\Operations\TargetScopeConcurrencyLimiter;
use App\Support\Auth\Capabilities;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels; use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Gate;
use RuntimeException; use RuntimeException;
use Throwable; use Throwable;
@ -94,7 +96,7 @@ public function handle(
$user = User::query()->whereKey($this->userId)->first(); $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, [ $runs->incrementSummaryCounts($this->operationRun, [
'processed' => 1, 'processed' => 1,
'skipped' => 1, 'skipped' => 1,

View File

@ -9,6 +9,7 @@
use App\Models\User; use App\Models\User;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity; use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
@ -26,6 +27,7 @@
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class BackupSetPolicyPickerTable extends TableComponent class BackupSetPolicyPickerTable extends TableComponent
@ -201,7 +203,11 @@ public function table(Table $table): Table
return false; return false;
} }
if (! $user->canSyncTenant($tenant)) { if (! $tenant instanceof Tenant) {
return false;
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
return false; return false;
} }
@ -248,7 +254,7 @@ public function table(Table $table): Table
return; return;
} }
if (! $user->canSyncTenant($tenant)) { if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
Notification::make() Notification::make()
->title('Not allowed') ->title('Not allowed')
->danger() ->danger()

View File

@ -2,7 +2,7 @@
namespace App\Models; namespace App\Models;
use App\Support\TenantRole; use App\Support\Auth\Capabilities;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasDefaultTenant; use Filament\Models\Contracts\HasDefaultTenant;
use Filament\Models\Contracts\HasTenants; use Filament\Models\Contracts\HasTenants;
@ -15,6 +15,7 @@
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants
@ -97,7 +98,7 @@ private function tenantPreferencesTableExists(): bool
return $exists ??= Schema::hasTable('user_tenant_preferences'); return $exists ??= Schema::hasTable('user_tenant_preferences');
} }
public function tenantRole(Tenant $tenant): ?TenantRole public function tenantRoleValue(Tenant $tenant): ?string
{ {
if (! $this->tenantPivotTableExists()) { if (! $this->tenantPivotTableExists()) {
return null; return null;
@ -111,14 +112,12 @@ public function tenantRole(Tenant $tenant): ?TenantRole
return null; 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 Gate::forUser($this)->allows(Capabilities::TENANT_SYNC, $tenant);
return $role?->canSync() ?? false;
} }
public function canAccessTenant(Model $tenant): bool public function canAccessTenant(Model $tenant): bool

View File

@ -5,42 +5,59 @@
use App\Models\BackupSchedule; use App\Models\BackupSchedule;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\TenantRole; use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
class BackupSchedulePolicy class BackupSchedulePolicy
{ {
use HandlesAuthorization; 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 public function viewAny(User $user): bool
{ {
return $this->resolveRole($user) !== null; return $this->isTenantMember($user);
} }
public function view(User $user, BackupSchedule $schedule): bool 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 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 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 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);
} }
} }

View File

@ -5,8 +5,9 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\TenantRole; use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization; use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
class FindingPolicy class FindingPolicy
{ {
@ -42,7 +43,7 @@ public function update(User $user, Finding $finding): bool
{ {
$tenant = Tenant::current(); $tenant = Tenant::current();
if (! $tenant) { if (! $tenant instanceof Tenant) {
return false; return false;
} }
@ -54,13 +55,6 @@ public function update(User $user, Finding $finding): bool
return false; return false;
} }
$role = $user->tenantRole($tenant); return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
return match ($role) {
TenantRole::Owner,
TenantRole::Manager,
TenantRole::Operator => true,
default => false,
};
} }
} }

View File

@ -31,20 +31,7 @@ public function boot(): void
}); });
}; };
foreach ([ foreach (Capabilities::all() as $capability) {
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) {
$defineTenantCapability($capability); $defineTenantCapability($capability);
} }

View File

@ -5,7 +5,9 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole; use App\Support\TenantRole;
use Illuminate\Support\Facades\Log;
/** /**
* Capability Resolver * Capability Resolver
@ -17,6 +19,8 @@ class CapabilityResolver
{ {
private array $resolvedMemberships = []; private array $resolvedMemberships = [];
private array $loggedDenials = [];
/** /**
* Get the user's role for a tenant * 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 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); $role = $this->getRole($user, $tenant);
if ($role === null) { if ($role === null) {
$this->logDenial($user, $tenant, $capability);
return false; 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(),
]);
} }
/** /**

View File

@ -26,6 +26,9 @@ class RoleCapabilityMap
Capabilities::TENANT_ROLE_MAPPING_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_ROLE_MAPPING_MANAGE, Capabilities::TENANT_ROLE_MAPPING_MANAGE,
Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE,
Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
Capabilities::PROVIDER_VIEW, Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_MANAGE, Capabilities::PROVIDER_MANAGE,
Capabilities::PROVIDER_RUN, Capabilities::PROVIDER_RUN,
@ -39,10 +42,11 @@ class RoleCapabilityMap
Capabilities::TENANT_SYNC, Capabilities::TENANT_SYNC,
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
Capabilities::TENANT_ROLE_MAPPING_VIEW, 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_VIEW,
Capabilities::PROVIDER_MANAGE, Capabilities::PROVIDER_MANAGE,
@ -58,6 +62,8 @@ class RoleCapabilityMap
Capabilities::TENANT_MEMBERSHIP_VIEW, Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_ROLE_MAPPING_VIEW, Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
Capabilities::PROVIDER_VIEW, Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_RUN, Capabilities::PROVIDER_RUN,
@ -88,6 +94,24 @@ public static function getCapabilities(TenantRole|string $role): array
return self::$roleCapabilities[$roleValue] ?? []; return self::$roleCapabilities[$roleValue] ?? [];
} }
/**
* Get all role values that grant a given capability.
*
* @return array<string>
*/
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 * Check if a role has a specific capability
*/ */

View File

@ -6,7 +6,7 @@
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Models\User; use App\Models\User;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Support\TenantRole; use App\Support\Audit\AuditActionId;
use DomainException; use DomainException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -18,10 +18,12 @@ public function addMember(
Tenant $tenant, Tenant $tenant,
User $actor, User $actor,
User $member, User $member,
TenantRole $role, string $role,
string $source = 'manual', string $source = 'manual',
?string $sourceRef = null, ?string $sourceRef = null,
): TenantMembership { ): TenantMembership {
$this->assertValidRole($role);
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership { return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
$existing = TenantMembership::query() $existing = TenantMembership::query()
->where('tenant_id', $tenant->getKey()) ->where('tenant_id', $tenant->getKey())
@ -29,9 +31,9 @@ public function addMember(
->first(); ->first();
if ($existing) { if ($existing) {
if ($existing->role !== $role->value) { if ($existing->role !== $role) {
$existing->forceFill([ $existing->forceFill([
'role' => $role->value, 'role' => $role,
'source' => $source, 'source' => $source,
'source_ref' => $sourceRef, 'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(), 'created_by_user_id' => (int) $actor->getKey(),
@ -39,12 +41,12 @@ public function addMember(
$this->auditLogger->log( $this->auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'tenant_membership.role_change', action: AuditActionId::TenantMembershipRoleChange->value,
context: [ context: [
'metadata' => [ 'metadata' => [
'member_user_id' => (int) $member->getKey(), 'member_user_id' => (int) $member->getKey(),
'from_role' => $existing->getOriginal('role'), 'from_role' => $existing->getOriginal('role'),
'to_role' => $role->value, 'to_role' => $role,
'source' => $source, 'source' => $source,
], ],
], ],
@ -63,7 +65,7 @@ public function addMember(
$membership = TenantMembership::query()->create([ $membership = TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(), 'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $member->getKey(), 'user_id' => (int) $member->getKey(),
'role' => $role->value, 'role' => $role,
'source' => $source, 'source' => $source,
'source_ref' => $sourceRef, 'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(), 'created_by_user_id' => (int) $actor->getKey(),
@ -71,11 +73,11 @@ public function addMember(
$this->auditLogger->log( $this->auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'tenant_membership.add', action: AuditActionId::TenantMembershipAdd->value,
context: [ context: [
'metadata' => [ 'metadata' => [
'member_user_id' => (int) $member->getKey(), 'member_user_id' => (int) $member->getKey(),
'role' => $role->value, 'role' => $role,
'source' => $source, '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 { $this->assertValidRole($newRole);
$membership->refresh();
if ($membership->tenant_id !== (int) $tenant->getKey()) { try {
throw new DomainException('Membership belongs to a different tenant.'); 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) { $oldRole = $membership->role;
return $membership;
}
$this->guardLastOwnerDemotion($tenant, $membership, $newRole); if ($oldRole === $newRole) {
return $membership;
}
$membership->forceFill([ $this->guardLastOwnerDemotion($tenant, $membership, $newRole);
'role' => $newRole->value,
])->save();
$this->auditLogger->log( $membership->forceFill([
tenant: $tenant, 'role' => $newRole,
action: 'tenant_membership.role_change', ])->save();
context: [
'metadata' => [ $this->auditLogger->log(
'member_user_id' => (int) $membership->user_id, tenant: $tenant,
'from_role' => $oldRole, action: AuditActionId::TenantMembershipRoleChange->value,
'to_role' => $newRole->value, context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole,
],
], ],
], actorId: (int) $actor->getKey(),
actorId: (int) $actor->getKey(), actorEmail: $actor->email,
actorEmail: $actor->email, actorName: $actor->name,
actorName: $actor->name, status: 'success',
status: 'success', resourceType: 'tenant',
resourceType: 'tenant', resourceId: (string) $tenant->getKey(),
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 public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
{ {
DB::transaction(function () use ($tenant, $actor, $membership): void { try {
$membership->refresh(); DB::transaction(function () use ($tenant, $actor, $membership): void {
$membership->refresh();
if ($membership->tenant_id !== (int) $tenant->getKey()) { if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.'); 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); throw $exception;
}
$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(),
);
});
} }
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership 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, tenant: $tenant,
actor: $actor, actor: $actor,
member: $member, member: $member,
role: TenantRole::Owner, role: 'owner',
source: 'break_glass', source: 'break_glass',
); );
$this->auditLogger->log( $this->auditLogger->log(
tenant: $tenant, tenant: $tenant,
action: 'tenant_membership.bootstrap_recover', action: AuditActionId::TenantMembershipBootstrapRecover->value,
context: [ context: [
'metadata' => [ 'metadata' => [
'member_user_id' => (int) $member->getKey(), '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 private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
{ {
if ($membership->role !== TenantRole::Owner->value) { if ($membership->role !== 'owner') {
return; return;
} }
$owners = TenantMembership::query() $owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value) ->where('role', 'owner')
->count(); ->count();
if ($owners <= 1) { 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; return;
} }
if ($newRole === TenantRole::Owner) { if ($newRole === 'owner') {
return; return;
} }
$owners = TenantMembership::query() $owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value) ->where('role', 'owner')
->count(); ->count();
if ($owners <= 1) { if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.'); 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.');
}
}
} }

View File

@ -10,6 +10,9 @@
use App\Services\Graph\GroupResolver; use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver; use App\Services\Graph\ScopeTagResolver;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
class VersionService class VersionService
{ {
@ -30,23 +33,49 @@ public function captureVersion(
?array $assignments = null, ?array $assignments = null,
?array $scopeTags = null, ?array $scopeTags = null,
): PolicyVersion { ): PolicyVersion {
$versionNumber = $this->nextVersionNumber($policy); $version = null;
$versionNumber = null;
$version = PolicyVersion::create([ for ($attempt = 1; $attempt <= 3; $attempt++) {
'tenant_id' => $policy->tenant_id, try {
'policy_id' => $policy->id, [$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array {
'version_number' => $versionNumber, // Serialize version number allocation per policy.
'policy_type' => $policy->policy_type, Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first();
'platform' => $policy->platform,
'created_by' => $createdBy, $versionNumber = $this->nextVersionNumber($policy);
'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload, $version = PolicyVersion::create([
'metadata' => $metadata, 'tenant_id' => $policy->tenant_id,
'assignments' => $assignments, 'policy_id' => $policy->id,
'scope_tags' => $scopeTags, 'version_number' => $versionNumber,
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null, 'policy_type' => $policy->policy_type,
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null, '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( $this->auditLogger->log(
tenant: $policy->tenant, tenant: $policy->tenant,
@ -65,6 +94,23 @@ public function captureVersion(
return $version; 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( public function captureFromGraph(
Tenant $tenant, Tenant $tenant,
Policy $policy, Policy $policy,

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\Audit;
enum AuditActionId: string
{
case TenantMembershipAdd = 'tenant_membership.add';
case TenantMembershipRoleChange = 'tenant_membership.role_change';
case TenantMembershipRemove = 'tenant_membership.remove';
case TenantMembershipLastOwnerBlocked = 'tenant_membership.last_owner_blocked';
// Not part of the v1 contract, but used in codebase.
case TenantMembershipBootstrapRecover = 'tenant_membership.bootstrap_recover';
}

View File

@ -10,6 +10,11 @@
*/ */
class Capabilities class Capabilities
{ {
/**
* @var array<string>|null
*/
private static ?array $all = null;
// Tenants // Tenants
public const TENANT_VIEW = 'tenant.view'; public const TENANT_VIEW = 'tenant.view';
@ -29,6 +34,11 @@ class Capabilities
public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage'; 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) // Providers (existing gate names used throughout the app)
public const PROVIDER_VIEW = 'provider.view'; public const PROVIDER_VIEW = 'provider.view';
@ -46,8 +56,17 @@ class Capabilities
*/ */
public static function all(): array public static function all(): array
{ {
if (self::$all !== null) {
return self::$all;
}
$reflection = new \ReflectionClass(self::class); $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);
} }
} }

View File

@ -8,57 +8,4 @@ enum TenantRole: string
case Manager = 'manager'; case Manager = 'manager';
case Operator = 'operator'; case Operator = 'operator';
case Readonly = 'readonly'; 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,
};
}
} }

View File

@ -20,7 +20,7 @@
'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds '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. // 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'),
]; ];

View File

@ -184,7 +184,7 @@ ### Functional Requirements
### Canonical allowed summary keys (single source of truth) ### Canonical allowed summary keys (single source of truth)
The following keys are the ONLY allowed summary keys for Ops-UX rendering: 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). All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).

View File

@ -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.

View File

@ -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.<domain>.<verb>`
## 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)

View File

@ -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`.

View File

@ -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 T025T033.
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.

View File

@ -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] |

View File

@ -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')
<button>Add Member</button>
@endcan
@cannot('tenant.danger_zone')
<p>You are not authorized to access the danger zone.</p>
@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.

View File

@ -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.

View File

@ -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 repositorys canonical registry is `App\Support\Auth\Capabilities`.
Tenant-scoped capabilities are defined as `<namespace>.<action>`, 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)

View File

@ -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: 510 golden checks) in `tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`
- [X] T039 [P] [US2] Role matrix tests (Operator: 510 golden checks) in `tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`
- [X] T040 [P] [US2] Role matrix tests (Manager: 510 golden checks) in `tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`
- [X] T041 [P] [US2] Role matrix tests (Owner: 510 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 (T024T030) 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.

View File

@ -0,0 +1,130 @@
<?php
use App\Models\AuditLog;
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Auth\TenantMembershipManager;
use App\Support\Audit\AuditActionId;
it('writes canonical audit action IDs for membership mutations', function () {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$member = User::factory()->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');
}
});

View File

@ -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(); Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -66,7 +66,8 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListEntraGroups::class) Livewire::test(ListEntraGroups::class)
->assertActionHidden('sync_groups'); ->assertActionVisible('sync_groups')
->assertActionDisabled('sync_groups');
Queue::assertNothingPushed(); Queue::assertNothingPushed();
}); });

View File

@ -70,6 +70,5 @@
->assertSee('Coverage') ->assertSee('Coverage')
->assertSee('Policies') ->assertSee('Policies')
->assertSee('Foundations') ->assertSee('Foundations')
->assertSee('Dependencies') ->assertSee('Dependencies');
->assertSee('✅');
}); });

View File

@ -73,6 +73,49 @@
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']); 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 () { test('restore run wizard can be prefilled from query params for policy version backup set', function () {
$tenant = Tenant::create([ $tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-prefill', 'tenant_id' => 'tenant-policy-version-prefill',

View File

@ -0,0 +1,142 @@
<?php
use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Filament\Resources\TenantResource\Pages\ListTenants;
use App\Models\Tenant;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('readonly users may switch current tenant via ChooseTenant', function () {
[$user, $tenantA] = createUserWithTenant(role: 'readonly');
$tenantB = Tenant::factory()->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);
});

View File

@ -1,46 +1,44 @@
<?php <?php
use App\Filament\Resources\TenantResource\Pages\ListTenants; use App\Filament\Pages\ChooseTenant;
use App\Filament\Pages\TenantDashboard;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\UserTenantPreference;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire; use Livewire\Livewire;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
test('make current action marks exactly one tenant as current', function () { test('choosing a tenant persists last used tenant preference', function () {
$originalEnv = getenv('INTUNE_TENANT_ID'); $first = Tenant::factory()->create([
putenv('INTUNE_TENANT_ID='); 'status' => 'active',
$first = Tenant::create([
'tenant_id' => 'tenant-one',
'name' => 'Tenant One',
'is_current' => true,
]); ]);
$second = Tenant::create([ $second = Tenant::factory()->create([
'tenant_id' => 'tenant-two', 'status' => 'active',
'name' => 'Tenant Two',
'is_current' => false,
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$user->tenants()->syncWithoutDetaching([ $user->tenants()->syncWithoutDetaching([
$first->getKey() => ['role' => 'owner'], $first->getKey() => ['role' => 'owner'],
$second->getKey() => ['role' => 'owner'], $second->getKey() => ['role' => 'owner'],
]); ]);
Filament::setTenant($first, true); Filament::setTenant($first, true);
Livewire::test(ListTenants::class) Livewire::test(ChooseTenant::class)
->callTableAction('makeCurrent', $second); ->call('selectTenant', $second->getKey())
->assertRedirect(TenantDashboard::getUrl(tenant: $second));
expect(Tenant::find($second->id)->is_current)->toBeTrue(); $preference = UserTenantPreference::query()
expect(Tenant::find($first->id)->is_current)->toBeFalse(); ->where('user_id', $user->getKey())
expect(Tenant::query()->where('is_current', true)->count())->toBe(1); ->where('tenant_id', $second->getKey())
->first();
$originalEnv !== false expect($preference)->not->toBeNull();
? putenv("INTUNE_TENANT_ID={$originalEnv}") expect($preference?->last_used_at)->not->toBeNull();
: putenv('INTUNE_TENANT_ID');
}); });

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
it('renders the tenant members UI DB-only (no outbound HTTP, no background work)', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$tenant->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();
});

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\TenantResource\Pages\ViewTenant;
use App\Filament\Resources\TenantResource\RelationManagers\TenantMembershipsRelationManager;
use App\Models\TenantMembership;
use App\Models\User;
use Livewire\Livewire;
it('allows an owner to add, change role, and remove members', function (): void {
[$owner, $tenant] = createUserWithTenant(role: 'owner');
$tenant->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();
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\ProviderConnectionResource;
use App\Models\ProviderConnection;
use App\Models\Tenant;
use Filament\Facades\Filament;
it('returns 404 for non-members on tenant-scoped routes', 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(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);
});

View File

@ -194,7 +194,7 @@ public function request(string $method, string $path, array $options = []): Grap
$response->assertSee('Actions'); $response->assertSee('Actions');
$response->assertSee($firstKey); $response->assertSee($firstKey);
$response->assertSee('ok'); $response->assertSee('ok');
$response->assertSee('missing'); $response->assertSee('Missing');
}); });
test('tenant list shows Open in Entra action', function () { test('tenant list shows Open in Entra action', function () {

View File

@ -204,7 +204,7 @@
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1); 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(); Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -212,7 +212,8 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListInventoryItems::class) Livewire::test(ListInventoryItems::class)
->assertActionHidden('run_inventory_sync'); ->assertActionVisible('run_inventory_sync')
->assertActionDisabled('run_inventory_sync');
Queue::assertNothingPushed(); Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse(); expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();

View File

@ -166,5 +166,5 @@
$message = (string) (($fresh?->failure_summary[0]['message'] ?? '')); $message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz'); expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
expect($message)->toContain('[REDACTED]'); expect($message)->toContain('[REDACTED');
}); });

View File

@ -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(); Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -175,7 +175,8 @@
Filament::setTenant($tenant, true); Filament::setTenant($tenant, true);
Livewire::test(ListPolicies::class) Livewire::test(ListPolicies::class)
->assertActionHidden('sync'); ->assertActionVisible('sync')
->assertActionDisabled('sync');
Queue::assertNothingPushed(); Queue::assertNothingPushed();
}); });

View File

@ -91,3 +91,29 @@
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1); 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);
});

View File

@ -0,0 +1,28 @@
<?php
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
it('logs structured context on authorization denials without secrets', function () {
Log::spy();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$gate = Gate::forUser($user);
expect($gate->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();
});

View File

@ -0,0 +1,111 @@
<?php
use App\Filament\Resources\BackupSetResource;
use App\Filament\Resources\BackupSetResource\Pages\CreateBackupSet;
use App\Filament\Resources\BackupSetResource\Pages\ListBackupSets;
use App\Filament\Resources\PolicyResource\Pages\ListPolicies;
use App\Filament\Resources\RestoreRunResource\Pages\CreateRestoreRun;
use App\Filament\Resources\RestoreRunResource\Pages\ListRestoreRuns;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\RestoreRun;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('readonly users cannot archive backup sets', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
Filament::setTenant($tenant, true);
$set = BackupSet::create([
'tenant_id' => $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();
});

View File

@ -0,0 +1,60 @@
<?php
use App\Filament\Resources\PolicyVersionResource\Pages\ListPolicyVersions;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('readonly users cannot archive 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,
]);
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();
});

View File

@ -0,0 +1,26 @@
<?php
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
it('enforces manager must-allow and must-not capabilities', function () {
[$user, $tenant] = createUserWithTenant(role: 'manager');
$gate = Gate::forUser($user);
expect($gate->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();
});

View File

@ -0,0 +1,26 @@
<?php
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
it('enforces operator must-allow and must-not capabilities', function () {
[$user, $tenant] = createUserWithTenant(role: 'operator');
$gate = Gate::forUser($user);
expect($gate->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();
});

View File

@ -0,0 +1,25 @@
<?php
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
it('enforces owner must-allow capabilities', function () {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$gate = Gate::forUser($user);
expect($gate->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();
});

View File

@ -0,0 +1,25 @@
<?php
use App\Support\Auth\Capabilities;
use Illuminate\Support\Facades\Gate;
it('enforces readonly must-allow and must-not capabilities', function () {
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$gate = Gate::forUser($user);
expect($gate->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();
});

View File

@ -0,0 +1,30 @@
<?php
use App\Filament\Pages\Tenancy\RegisterTenant;
use App\Filament\Resources\TenantResource\Pages\CreateTenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
test('readonly users cannot access tenant registration', function () {
[$user] = createUserWithTenant(role: 'readonly');
$this->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);
});

View File

@ -2,7 +2,6 @@
use App\Models\TenantMembership; use App\Models\TenantMembership;
use App\Services\Auth\TenantMembershipManager; use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -17,7 +16,7 @@
$manager = app(TenantMembershipManager::class); $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.'); expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
}); });

View File

@ -3,7 +3,6 @@
use App\Models\AuditLog; use App\Models\AuditLog;
use App\Models\User; use App\Models\User;
use App\Services\Auth\TenantMembershipManager; use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -14,8 +13,8 @@
$manager = app(TenantMembershipManager::class); $manager = app(TenantMembershipManager::class);
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly); $membership = $manager->addMember($tenant, $actor, $member, 'readonly');
$manager->changeRole($tenant, $actor, $membership, TenantRole::Operator); $manager->changeRole($tenant, $actor, $membership, 'operator');
$manager->removeMember($tenant, $actor, $membership); $manager->removeMember($tenant, $actor, $membership);
$actions = AuditLog::query() $actions = AuditLog::query()

View File

@ -12,6 +12,11 @@
it('bootstraps tenant creator as owner and audits the assignment', function () { it('bootstraps tenant creator as owner and audits the assignment', function () {
$user = User::factory()->create(); $user = User::factory()->create();
$existingTenant = Tenant::factory()->create();
$user->tenants()->syncWithoutDetaching([
$existingTenant->getKey() => ['role' => 'owner'],
]);
$this->actingAs($user); $this->actingAs($user);
$tenantGuid = '11111111-1111-1111-1111-111111111111'; $tenantGuid = '11111111-1111-1111-1111-111111111111';

View File

@ -2,7 +2,6 @@
use App\Models\User; use App\Models\User;
use App\Services\Auth\TenantMembershipManager; use App\Services\Auth\TenantMembershipManager;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
@ -13,7 +12,7 @@
$manager = app(TenantMembershipManager::class); $manager = app(TenantMembershipManager::class);
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly); $membership = $manager->addMember($tenant, $actor, $member, 'readonly');
$this->assertDatabaseHas('tenant_memberships', [ $this->assertDatabaseHas('tenant_memberships', [
'id' => $membership->getKey(), 'id' => $membership->getKey(),
@ -23,7 +22,7 @@
'source' => 'manual', 'source' => 'manual',
]); ]);
$updated = $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator); $updated = $manager->changeRole($tenant, $actor, $membership, 'operator');
expect($updated->role)->toBe('operator'); expect($updated->role)->toBe('operator');

View File

@ -0,0 +1,37 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('does not execute additional tenant_memberships queries after first resolve within a request', function () {
$tenant = Tenant::factory()->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);
});

View File

@ -15,18 +15,38 @@
$owner = User::factory()->create(); $owner = User::factory()->create();
$owner->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']); $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 = User::factory()->create();
$readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']); $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); $resolver = app(CapabilityResolver::class);
expect($resolver->isMember($owner, $tenant))->toBeTrue(); expect($resolver->isMember($owner, $tenant))->toBeTrue();
expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->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_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->isMember($readonly, $tenant))->toBeTrue();
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->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::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(); expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
$outsider = User::factory()->create(); $outsider = User::factory()->create();

View File

@ -0,0 +1,62 @@
<?php
use Illuminate\Filesystem\Filesystem;
it('does not use role-string checks outside the RBAC core', function () {
/**
* This guard test is intentionally narrow:
* - It targets comparisons / branching on role strings (authorization-by-role patterns).
* - It does NOT forbid role literals used as data values (e.g., form options, seed data).
*/
$allowedFiles = collect([
app_path('Services/Auth/RoleCapabilityMap.php'),
app_path('Services/Auth/TenantMembershipManager.php'),
])->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();
});

View File

@ -0,0 +1,20 @@
<?php
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\TenantRole;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('fails fast on unknown capability strings', function () {
$tenant = Tenant::factory()->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);

View File

@ -0,0 +1,82 @@
<?php
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Services\Graph\AssignmentFetcher;
use App\Services\Graph\AssignmentFilterResolver;
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\PolicySnapshotService;
use App\Services\Intune\VersionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('retries and succeeds after a policy_versions unique collision during capture', function () {
$tenant = Tenant::factory()->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);
});