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
81 changed files with 3572 additions and 493 deletions
Showing only changes of commit 0d83ca7896 - Show all commits

View File

@ -141,7 +141,7 @@ ### Spec-First Workflow
## Quality Gates
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
- Run `./vendor/bin/pint --dirty` before finalizing.
- Run `./vendor/bin/sail bin pint --dirty` before finalizing.
## Governance

View File

@ -896,9 +896,9 @@ ### Replaced Utilities
</laravel-boost-guidelines>
## Recent Changes
- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
## Active Technologies
- PostgreSQL (with a new `platform_users` table) (064-auth-structure)
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)

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\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
@ -20,6 +21,7 @@
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class DriftLanding extends Page
@ -173,7 +175,7 @@ public function mount(): void
}
}
if (! $user->canSyncTenant($tenant)) {
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
$this->state = 'blocked';
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';

View File

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

View File

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

View File

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

View File

@ -13,7 +13,9 @@ class ListBackupSets extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->disabled(fn (): bool => ! BackupSetResource::canCreate())
->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'),
];
}
}

View File

@ -8,6 +8,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
@ -23,6 +24,7 @@
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
class BackupItemsRelationManager extends RelationManager
{
@ -132,6 +134,10 @@ public function table(Table $table): Table
Actions\Action::make('addPolicies')
->label('Add Policies')
->icon('heroicon-o-plus')
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)))
->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.')
->modalHeading('Add Policies')
->modalSubmitAction(false)
->modalCancelActionLabel('Close')
@ -173,7 +179,11 @@ public function table(Table $table): Table
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $user->canSyncTenant($tenant)) {
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}
@ -252,7 +262,11 @@ public function table(Table $table): Table
$tenant = $backupSet->tenant ?? Tenant::current();
if (! $user->canSyncTenant($tenant)) {
if (! $tenant instanceof Tenant) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}

View File

@ -10,10 +10,12 @@
use App\Models\User;
use App\Services\Directory\EntraGroupSelection;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListEntraGroups extends ListRecords
{
@ -45,9 +47,43 @@ protected function getHeaderActions(): array
return false;
}
$role = $user->tenantRole($tenant);
if (! $user->canAccessTenant($tenant)) {
return false;
}
return $role?->canSync() ?? false;
return true;
})
->disabled(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync groups.';
})
->action(function (): void {
$user = auth()->user();
@ -66,11 +102,7 @@ protected function getHeaderActions(): array
abort(403);
}
$role = $user->tenantRole($tenant);
if (! ($role?->canSync() ?? false)) {
abort(403);
}
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
$selectionKey = EntraGroupSelection::allGroupsV1();

View File

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

View File

@ -9,6 +9,7 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\Drift\DriftFindingDiffBuilder;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use BackedEnum;
@ -25,6 +26,7 @@
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Gate;
@ -40,6 +42,33 @@ class FindingResource extends Resource
protected static ?string $navigationLabel = 'Findings';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
return false;
}
if ($record instanceof Finding) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function form(Schema $schema): Schema
{
return $schema;
@ -389,7 +418,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->addSelect([

View File

@ -8,6 +8,7 @@
use App\Models\Tenant;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
@ -24,6 +25,8 @@
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class InventoryItemResource extends Resource
@ -38,6 +41,33 @@ class InventoryItemResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
return false;
}
if ($record instanceof InventoryItem) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function form(Schema $schema): Schema
{
return $schema;
@ -225,7 +255,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))

View File

@ -11,6 +11,7 @@
use App\Services\Intune\AuditLogger;
use App\Services\Inventory\InventorySyncService;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
@ -23,6 +24,7 @@
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Support\Enums\Size;
use Illuminate\Support\Facades\Gate;
class ListInventoryItems extends ListRecords
{
@ -109,17 +111,57 @@ protected function getHeaderActions(): array
return false;
}
return $user->canSyncTenant(Tenant::current());
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
return $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return true;
}
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
})
->tooltip(function (): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to start inventory sync.';
})
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
abort(404);
}
$user = auth()->user();
if (! $user instanceof User) {
abort(403, 'Not allowed');
}
if (! $user->canSyncTenant($tenant)) {
if (! $user->canAccessTenant($tenant)) {
abort(404);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403, 'Not allowed');
}

View File

@ -6,6 +6,7 @@
use App\Filament\Resources\InventorySyncRunResource\Pages;
use App\Models\InventorySyncRun;
use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\OperationRunLinks;
@ -19,6 +20,8 @@
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use UnitEnum;
class InventorySyncRunResource extends Resource
@ -35,6 +38,33 @@ class InventorySyncRunResource extends Resource
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
public static function canViewAny(): bool
{
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
}
public static function canView(Model $record): bool
{
$tenant = Tenant::current();
if (! $tenant instanceof Tenant) {
return false;
}
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
return false;
}
if ($record instanceof InventorySyncRun) {
return (int) $record->tenant_id === (int) $tenant->getKey();
}
return true;
}
public static function getNavigationLabel(): string
{
return 'Sync History';
@ -155,7 +185,7 @@ public static function table(Table $table): Table
public static function getEloquentQuery(): Builder
{
$tenantId = Tenant::current()->getKey();
$tenantId = Tenant::current()?->getKey();
return parent::getEloquentQuery()
->with('user')

View File

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

View File

@ -7,12 +7,14 @@
use App\Models\Tenant;
use App\Models\User;
use App\Services\OperationRunService;
use App\Support\Auth\Capabilities;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use Filament\Actions;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Gate;
class ListPolicies extends ListRecords
{
@ -35,7 +37,28 @@ protected function getHeaderActions(): array
$tenant = Tenant::current();
return $user->canSyncTenant($tenant);
return $tenant instanceof Tenant
&& $user->canAccessTenant($tenant);
})
->disabled(function (): bool {
$user = auth()->user();
$tenant = Tenant::current();
return ! ($user instanceof User
&& $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
})
->tooltip(function (): ?string {
$user = auth()->user();
$tenant = Tenant::current();
if (! ($user instanceof User && $tenant instanceof Tenant)) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
? null
: 'You do not have permission to sync policies.';
})
->action(function (self $livewire): void {
$tenant = Tenant::current();
@ -45,7 +68,15 @@ protected function getHeaderActions(): array
abort(403);
}
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
if (! $tenant instanceof Tenant) {
abort(403);
}
if (! $user->canAccessTenant($tenant)) {
abort(403);
}
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
abort(403);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,11 @@ class ListProviderConnections extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())
? null
: 'You do not have permission to create provider connections.'),
];
}
}

View File

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

View File

@ -5,10 +5,12 @@
use App\Filament\Resources\RestoreRunResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Support\Auth\Capabilities;
use Filament\Actions\Action;
use Filament\Resources\Pages\Concerns\HasWizard;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Gate;
use Livewire\Attributes\On;
class CreateRestoreRun extends CreateRecord
@ -17,6 +19,13 @@ class CreateRestoreRun extends CreateRecord
protected static string $resource = RestoreRunResource::class;
protected function authorizeAccess(): void
{
$tenant = Tenant::current();
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
}
public function getSteps(): array
{
return RestoreRunResource::getWizardSteps();

View File

@ -13,7 +13,9 @@ class ListRestoreRuns extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->disabled(fn (): bool => ! RestoreRunResource::canCreate())
->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'),
];
}
}

View File

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

View File

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

View File

@ -3,8 +3,12 @@
namespace App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource;
use App\Models\Tenant;
use App\Models\User;
use App\Support\Auth\Capabilities;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Gate;
class EditTenant extends EditRecord
{
@ -14,7 +18,42 @@ protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
Actions\Action::make('archive')
->label('Archive')
->color('danger')
->requiresConfirmation()
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
->disabled(function (): bool {
$tenant = $this->record;
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return true;
}
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
})
->tooltip(function (): ?string {
$tenant = $this->record;
$user = auth()->user();
if (! $tenant instanceof Tenant || ! $user instanceof User) {
return null;
}
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)
? null
: 'You do not have permission to archive tenants.';
})
->action(function (): void {
$tenant = $this->record;
$user = auth()->user();
abort_unless($tenant instanceof Tenant && $user instanceof User, 403);
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant), 403);
$tenant->delete();
}),
];
}
}

View File

@ -13,7 +13,9 @@ class ListTenants extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->disabled(fn (): bool => ! TenantResource::canCreate())
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -5,42 +5,59 @@
use App\Models\BackupSchedule;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantRole;
use App\Support\Auth\Capabilities;
use Illuminate\Auth\Access\HandlesAuthorization;
use Illuminate\Support\Facades\Gate;
class BackupSchedulePolicy
{
use HandlesAuthorization;
protected function resolveRole(User $user): ?TenantRole
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
{
$tenant = Tenant::current();
$tenant ??= Tenant::current();
return $user->tenantRole($tenant);
return $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
}
public function viewAny(User $user): bool
{
return $this->resolveRole($user) !== null;
return $this->isTenantMember($user);
}
public function view(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user) !== null;
$tenant = Tenant::current();
if (! $this->isTenantMember($user, $tenant)) {
return false;
}
return (int) $schedule->tenant_id === (int) $tenant->getKey();
}
public function create(User $user): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}
public function update(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}
public function delete(User $user, BackupSchedule $schedule): bool
{
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
$tenant = Tenant::current();
return $tenant instanceof Tenant
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
}
}

View File

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

View File

@ -31,20 +31,7 @@ public function boot(): void
});
};
foreach ([
Capabilities::PROVIDER_VIEW,
Capabilities::PROVIDER_MANAGE,
Capabilities::PROVIDER_RUN,
Capabilities::TENANT_MEMBERSHIP_VIEW,
Capabilities::TENANT_MEMBERSHIP_MANAGE,
Capabilities::TENANT_ROLE_MAPPING_VIEW,
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
Capabilities::AUDIT_VIEW,
Capabilities::TENANT_VIEW,
Capabilities::TENANT_MANAGE,
Capabilities::TENANT_DELETE,
Capabilities::TENANT_SYNC,
] as $capability) {
foreach (Capabilities::all() as $capability) {
$defineTenantCapability($capability);
}

View File

@ -5,7 +5,9 @@
use App\Models\Tenant;
use App\Models\TenantMembership;
use App\Models\User;
use App\Support\Auth\Capabilities;
use App\Support\TenantRole;
use Illuminate\Support\Facades\Log;
/**
* Capability Resolver
@ -17,6 +19,8 @@ class CapabilityResolver
{
private array $resolvedMemberships = [];
private array $loggedDenials = [];
/**
* Get the user's role for a tenant
*/
@ -36,13 +40,42 @@ public function getRole(User $user, Tenant $tenant): ?TenantRole
*/
public function can(User $user, Tenant $tenant, string $capability): bool
{
if (! Capabilities::isKnown($capability)) {
throw new \InvalidArgumentException("Unknown capability: {$capability}");
}
$role = $this->getRole($user, $tenant);
if ($role === null) {
$this->logDenial($user, $tenant, $capability);
return false;
}
return RoleCapabilityMap::hasCapability($role, $capability);
$allowed = RoleCapabilityMap::hasCapability($role, $capability);
if (! $allowed) {
$this->logDenial($user, $tenant, $capability);
}
return $allowed;
}
private function logDenial(User $user, Tenant $tenant, string $capability): void
{
$key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]);
if (isset($this->loggedDenials[$key])) {
return;
}
$this->loggedDenials[$key] = true;
Log::warning('rbac.denied', [
'capability' => $capability,
'tenant_id' => (int) $tenant->getKey(),
'actor_user_id' => (int) $user->getKey(),
]);
}
/**

View File

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

View File

@ -6,7 +6,7 @@
use App\Models\TenantMembership;
use App\Models\User;
use App\Services\Intune\AuditLogger;
use App\Support\TenantRole;
use App\Support\Audit\AuditActionId;
use DomainException;
use Illuminate\Support\Facades\DB;
@ -18,10 +18,12 @@ public function addMember(
Tenant $tenant,
User $actor,
User $member,
TenantRole $role,
string $role,
string $source = 'manual',
?string $sourceRef = null,
): TenantMembership {
$this->assertValidRole($role);
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
$existing = TenantMembership::query()
->where('tenant_id', $tenant->getKey())
@ -29,9 +31,9 @@ public function addMember(
->first();
if ($existing) {
if ($existing->role !== $role->value) {
if ($existing->role !== $role) {
$existing->forceFill([
'role' => $role->value,
'role' => $role,
'source' => $source,
'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(),
@ -39,12 +41,12 @@ public function addMember(
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.role_change',
action: AuditActionId::TenantMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'from_role' => $existing->getOriginal('role'),
'to_role' => $role->value,
'to_role' => $role,
'source' => $source,
],
],
@ -63,7 +65,7 @@ public function addMember(
$membership = TenantMembership::query()->create([
'tenant_id' => (int) $tenant->getKey(),
'user_id' => (int) $member->getKey(),
'role' => $role->value,
'role' => $role,
'source' => $source,
'source_ref' => $sourceRef,
'created_by_user_id' => (int) $actor->getKey(),
@ -71,11 +73,11 @@ public function addMember(
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.add',
action: AuditActionId::TenantMembershipAdd->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
'role' => $role->value,
'role' => $role,
'source' => $source,
],
],
@ -91,82 +93,132 @@ public function addMember(
});
}
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, string $newRole): TenantMembership
{
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
$membership->refresh();
$this->assertValidRole($newRole);
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
}
try {
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
$membership->refresh();
$oldRole = $membership->role;
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
}
if ($oldRole === $newRole->value) {
return $membership;
}
$oldRole = $membership->role;
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
if ($oldRole === $newRole) {
return $membership;
}
$membership->forceFill([
'role' => $newRole->value,
])->save();
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.role_change',
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole->value,
$membership->forceFill([
'role' => $newRole,
])->save();
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::TenantMembershipRoleChange->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => $oldRole,
'to_role' => $newRole,
],
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
return $membership->refresh();
});
return $membership->refresh();
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'from_role' => (string) $membership->role,
'attempted_to_role' => $newRole,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'blocked',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
throw $exception;
}
}
public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
{
DB::transaction(function () use ($tenant, $actor, $membership): void {
$membership->refresh();
try {
DB::transaction(function () use ($tenant, $actor, $membership): void {
$membership->refresh();
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
if ($membership->tenant_id !== (int) $tenant->getKey()) {
throw new DomainException('Membership belongs to a different tenant.');
}
$this->guardLastOwnerRemoval($tenant, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::TenantMembershipRemove->value,
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
});
} catch (DomainException $exception) {
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
$this->auditLogger->log(
tenant: $tenant,
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
context: [
'metadata' => [
'member_user_id' => (int) $membership->user_id,
'role' => (string) $membership->role,
'attempted_action' => 'remove',
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'blocked',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
}
$this->guardLastOwnerRemoval($tenant, $membership);
$memberUserId = (int) $membership->user_id;
$oldRole = (string) $membership->role;
$membership->delete();
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.remove',
context: [
'metadata' => [
'member_user_id' => $memberUserId,
'role' => $oldRole,
],
],
actorId: (int) $actor->getKey(),
actorEmail: $actor->email,
actorName: $actor->name,
status: 'success',
resourceType: 'tenant',
resourceId: (string) $tenant->getKey(),
);
});
throw $exception;
}
}
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership
@ -175,13 +227,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten
tenant: $tenant,
actor: $actor,
member: $member,
role: TenantRole::Owner,
role: 'owner',
source: 'break_glass',
);
$this->auditLogger->log(
tenant: $tenant,
action: 'tenant_membership.bootstrap_recover',
action: AuditActionId::TenantMembershipBootstrapRecover->value,
context: [
'metadata' => [
'member_user_id' => (int) $member->getKey(),
@ -200,13 +252,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten
private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
{
if ($membership->role !== TenantRole::Owner->value) {
if ($membership->role !== 'owner') {
return;
}
$owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value)
->where('role', 'owner')
->count();
if ($owners <= 1) {
@ -214,23 +266,30 @@ private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $members
}
}
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, string $newRole): void
{
if ($membership->role !== TenantRole::Owner->value) {
if ($membership->role !== 'owner') {
return;
}
if ($newRole === TenantRole::Owner) {
if ($newRole === 'owner') {
return;
}
$owners = TenantMembership::query()
->where('tenant_id', (int) $tenant->getKey())
->where('role', TenantRole::Owner->value)
->where('role', 'owner')
->count();
if ($owners <= 1) {
throw new DomainException('You cannot demote the last remaining owner.');
}
}
private function assertValidRole(string $role): void
{
if (! in_array($role, ['owner', 'manager', 'operator', 'readonly'], true)) {
throw new DomainException('Invalid role value.');
}
}
}

View File

@ -10,6 +10,9 @@
use App\Services\Graph\GroupResolver;
use App\Services\Graph\ScopeTagResolver;
use Carbon\CarbonImmutable;
use Illuminate\Database\QueryException;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
class VersionService
{
@ -30,23 +33,49 @@ public function captureVersion(
?array $assignments = null,
?array $scopeTags = null,
): PolicyVersion {
$versionNumber = $this->nextVersionNumber($policy);
$version = null;
$versionNumber = null;
$version = PolicyVersion::create([
'tenant_id' => $policy->tenant_id,
'policy_id' => $policy->id,
'version_number' => $versionNumber,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => $createdBy,
'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload,
'metadata' => $metadata,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
]);
for ($attempt = 1; $attempt <= 3; $attempt++) {
try {
[$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array {
// Serialize version number allocation per policy.
Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first();
$versionNumber = $this->nextVersionNumber($policy);
$version = PolicyVersion::create([
'tenant_id' => $policy->tenant_id,
'policy_id' => $policy->id,
'version_number' => $versionNumber,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'created_by' => $createdBy,
'captured_at' => CarbonImmutable::now(),
'snapshot' => $payload,
'metadata' => $metadata,
'assignments' => $assignments,
'scope_tags' => $scopeTags,
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
]);
return [$version, $versionNumber];
}, 3);
break;
} catch (QueryException $e) {
if (! $this->isUniqueViolation($e) || $attempt === 3) {
throw $e;
}
usleep(50_000 * $attempt);
}
}
if (! $version instanceof PolicyVersion || ! is_int($versionNumber)) {
throw new \RuntimeException('Failed to capture policy version.');
}
$this->auditLogger->log(
tenant: $policy->tenant,
@ -65,6 +94,23 @@ public function captureVersion(
return $version;
}
private function isUniqueViolation(QueryException $exception): bool
{
if ($exception instanceof UniqueConstraintViolationException) {
return true;
}
$sqlState = $exception->getCode();
if (is_string($sqlState) && in_array($sqlState, ['23505', '23000'], true)) {
return true;
}
$errorInfoState = $exception->errorInfo[0] ?? null;
return is_string($errorInfoState) && in_array($errorInfoState, ['23505', '23000'], true);
}
public function captureFromGraph(
Tenant $tenant,
Policy $policy,

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

View File

@ -8,57 +8,4 @@ enum TenantRole: string
case Manager = 'manager';
case Operator = 'operator';
case Readonly = 'readonly';
public function canSync(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
public function canManageBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager => true,
default => false,
};
}
public function canRunBackupSchedules(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
public function canViewProviders(): bool
{
return true;
}
public function canManageProviders(): bool
{
return match ($this) {
self::Owner,
self::Manager => true,
default => false,
};
}
public function canRunProviderOperations(): bool
{
return match ($this) {
self::Owner,
self::Manager,
self::Operator => true,
self::Readonly => false,
};
}
}

View File

@ -20,7 +20,7 @@
'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds
],
// When true (default in local/debug), BackupService will fall back to stub payloads
// When true (default in local), BackupService will fall back to stub payloads
// instead of failing the backup entirely if Graph returns an error.
'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local' || env('APP_DEBUG')),
'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local'),
];

View File

@ -184,7 +184,7 @@ ### Functional Requirements
### Canonical allowed summary keys (single source of truth)
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
`total, processed, succeeded, failed, skipped, created, updated, deleted, items, tenants`
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants`
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).

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,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

@ -10,11 +10,31 @@ ## 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. Creating a `tenant_memberships` table to store user roles within each tenant.
2. Defining a central capability registry and mapping roles (`Owner`, `Manager`, `Operator`, `Readonly`) to specific capabilities.
3. Using Laravel Gates and Policies to enforce these capabilities throughout the application.
4. Building a Filament Relation Manager (`MembersRelationManager`) to provide a UI for Owners to manage tenant memberships.
5. Adding comprehensive feature and unit tests with Pest to ensure the RBAC system is secure and correct.
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
@ -40,7 +60,7 @@ ## Constitution Check
- [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 and role).
- [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
@ -65,31 +85,29 @@ ### Source Code (repository root)
```text
app/
├── Models/
│ ├── TenantMembership.php # New Eloquent model
│ └── User.php # Add relationship to TenantMembership
│ └── Tenant.php # Add relationship to TenantMembership
│ ├── TenantMembership.php # Existing pivot model
│ └── User.php # Existing relationship to tenants/memberships
│ └── Tenant.php # Existing relationship to users/memberships
├── Policies/
│ └── TenantMembershipPolicy.php # New policy for managing memberships
│ └── (optional) TenantMembershipPolicy.php # Optional policy for membership mutations (currently Gate-driven)
├── Providers/
│ └── AuthServiceProvider.php # Register gates and policies
│ └── AuthServiceProvider.php # Register per-capability Gates
└── Filament/
└── Resources/
└── TenantResource/
└── RelationManagers/
└── MembersRelationManager.php # New Filament relation manager
└── TenantMembershipsRelationManager.php # New Filament relation manager
database/
├── factories/
│ └── TenantMembershipFactory.php # New model factory
└── migrations/
└── [timestamp]_create_tenant_memberships_table.php # New migration
└── 2026_01_25_022729_create_tenant_memberships_table.php # Existing migration
tests/
├── Feature/
│ └── Filament/
│ └── TenantMembersTest.php # New feature test for RBAC UI
└── Unit/
└── TenantRBACTest.php # New unit test for role-capability mapping
└── 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`.

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

@ -4,49 +4,74 @@ # Tasks for Feature: Tenant RBAC v1
## Phase 1: Setup & Database
- [ ] T001 Create migration for `tenant_memberships` table in `database/migrations/`
- [ ] T002 Create `TenantMembership` model in `app/Models/TenantMembership.php`
- [ ] T003 Add relationships to `User` and `Tenant` models for `TenantMembership` in `app/Models/User.php` and `app/Models/Tenant.php`
- [ ] T004 Create `TenantMembershipFactory` in `database/factories/TenantMembershipFactory.php`
- [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
- [ ] T005 [P] Create a `TenantCapabilities` enum or class to register all capabilities in `app/Enums/TenantCapabilities.php`
- [ ] T006 [P] Create a service or helper to map roles to capabilities in `app/Services/RBAC/RoleMapper.php`
- [ ] T007 Register the core `tenant.can` Gate in `app/Providers/AuthServiceProvider.php`
- [ ] T008 [P] Create `TenantMembershipPolicy` in `app/Policies/TenantMembershipPolicy.php`
- [ ] T009 Register `TenantMembershipPolicy` in `app/Providers/AuthServiceProvider.php`
- [ ] T010 Create unit test for role-capability mapping in `tests/Unit/TenantRBACTest.php`
- [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.
- [ ] T011 [US1] Create `MembersRelationManager` for `TenantResource` in `app/Filament/Resources/TenantResource/RelationManagers/MembersRelationManager.php`
- [ ] T012 [US1] Implement table columns (user name, email, role) in `MembersRelationManager`
- [ ] T013 [US1] Implement "Add Member" action, accessible only to owners, in `MembersRelationManager`
- [ ] T014 [US1] Implement "Change Role" action, accessible only to owners, in `MembersRelationManager`
- [ ] T015 [US1] Implement "Remove Member" action, accessible only to owners, in `MembersRelationManager`
- [ ] T016 [US1] Add "last owner" protection logic to the "Change Role" and "Remove Member" actions.
- [ ] T017 [US1] Create feature test for membership management UI and authorization in `tests/Feature/Filament/TenantMembersTest.php`
- [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.
- [ ] T018 [US2] Implement tenant scoping middleware to enforce FR-011 (non-member returns 404).
- [ ] T019 [US2] Apply `tenant.can` gate checks to all relevant Filament pages, actions, and relation managers.
- [ ] T020 [US2] Add audit logging for all membership changes (add, edit, remove) as per FR-010.
- [ ] T021 [US2] Add feature tests to verify that different roles have the correct access levels across the application.
- [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
- [ ] T022 [P] Review all code for adherence to project conventions.
- [ ] T023 [P] Ensure all new UI text is translatable.
- [ ] T024 Run `./vendor/bin/pint --dirty` to format all changed files.
- [ ] T025 Run the full test suite (`./vendor/bin/sail test`) to ensure no regressions.
- [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
@ -55,8 +80,8 @@ ## Dependencies
## Parallel Execution
- Within **Phase 2**, task T005, T006 and T008 can be worked on in parallel.
- Within **Phase 3**, after T011 is complete, the actions (T013, T014, T015) can be developed in parallel.
- 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

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

View File

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

View File

@ -73,6 +73,49 @@
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
});
test('readonly users cannot open restore wizard via policy version row action', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-wizard-readonly',
'name' => 'Tenant',
'metadata' => [],
]);
$tenant->makeCurrent();
$policy = Policy::create([
'tenant_id' => $tenant->id,
'external_id' => 'policy-ro-1',
'policy_type' => 'settingsCatalogPolicy',
'display_name' => 'Settings Catalog',
'platform' => 'windows',
]);
$version = PolicyVersion::create([
'tenant_id' => $tenant->id,
'policy_id' => $policy->id,
'version_number' => 1,
'policy_type' => $policy->policy_type,
'platform' => $policy->platform,
'captured_at' => now(),
'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
]);
$user = User::factory()->create(['email' => 'readonly@example.com']);
$user->tenants()->syncWithoutDetaching([
$tenant->getKey() => ['role' => 'readonly'],
]);
$this->actingAs($user);
Filament::setTenant($tenant, true);
Livewire::test(ListPolicyVersions::class)
->assertTableActionDisabled('restore_via_wizard', $version)
->callTableAction('restore_via_wizard', $version);
expect(BackupSet::query()->where('metadata->source', 'policy_version')->exists())->toBeFalse();
expect(BackupItem::query()->exists())->toBeFalse();
});
test('restore run wizard can be prefilled from query params for policy version backup set', function () {
$tenant = Tenant::create([
'tenant_id' => 'tenant-policy-version-prefill',

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

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($firstKey);
$response->assertSee('ok');
$response->assertSee('missing');
$response->assertSee('Missing');
});
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);
});
it('forbids unauthorized users from starting inventory sync', function () {
it('disables inventory sync start action for readonly users', function () {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -212,7 +212,8 @@
Filament::setTenant($tenant, true);
Livewire::test(ListInventoryItems::class)
->assertActionHidden('run_inventory_sync');
->assertActionVisible('run_inventory_sync')
->assertActionDisabled('run_inventory_sync');
Queue::assertNothingPushed();
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();

View File

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

View File

@ -91,3 +91,29 @@
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
});
it('disables connection check action for readonly users', function (): void {
Queue::fake();
[$user, $tenant] = createUserWithTenant(role: 'readonly');
$this->actingAs($user);
$tenant->makeCurrent();
Filament::setTenant($tenant, true);
$connection = ProviderConnection::factory()->create([
'tenant_id' => $tenant->getKey(),
'provider' => 'microsoft',
'entra_tenant_id' => fake()->uuid(),
'status' => 'connected',
]);
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
->assertActionVisible('check_connection')
->assertActionDisabled('check_connection')
->assertActionVisible('compliance_snapshot')
->assertActionDisabled('compliance_snapshot');
Queue::assertNothingPushed();
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
});

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

View File

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

View File

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

View File

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

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->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']);
$manager = User::factory()->create();
$manager->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Manager->value, 'source' => 'manual']);
$readonly = User::factory()->create();
$readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']);
$operator = User::factory()->create();
$operator->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Operator->value, 'source' => 'manual']);
$resolver = app(CapabilityResolver::class);
expect($resolver->isMember($owner, $tenant))->toBeTrue();
expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
expect($resolver->can($owner, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeTrue();
expect($resolver->can($owner, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeTrue();
expect($resolver->isMember($manager, $tenant))->toBeTrue();
expect($resolver->can($manager, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeTrue();
expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue();
expect($resolver->can($manager, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
expect($resolver->can($manager, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeFalse();
expect($resolver->isMember($operator, $tenant))->toBeTrue();
expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue();
expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse();
expect($resolver->isMember($readonly, $tenant))->toBeTrue();
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue();
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_MANAGE))->toBeFalse();
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeFalse();
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse();
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
$outsider = User::factory()->create();

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);
});