065-tenant-rbac-v1 #79
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
44
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal file
44
app/Filament/Concerns/ScopesGlobalSearchToTenant.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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.';
|
||||
|
||||
|
||||
@ -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',
|
||||
],
|
||||
],
|
||||
|
||||
@ -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);
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
])
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
16
app/Support/Audit/AuditActionId.php
Normal file
16
app/Support/Audit/AuditActionId.php
Normal 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';
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
34
specs/065-tenant-rbac-v1/checklists/requirements.md
Normal file
34
specs/065-tenant-rbac-v1/checklists/requirements.md
Normal 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.
|
||||
212
specs/065-tenant-rbac-v1/enforcement-hitlist.md
Normal file
212
specs/065-tenant-rbac-v1/enforcement-hitlist.md
Normal 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 T025–T033.
|
||||
|
||||
Legend:
|
||||
- kind: mutate | dispatch | destructive | secret
|
||||
- capability (target):
|
||||
- Use existing App\Support\Auth\Capabilities constants where available.
|
||||
- Mark missing ones as NEW for addition/mapping in T025/T026.
|
||||
|
||||
Tenant (tenant-plane)
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M001 | app/Filament/Resources/TenantResource.php | syncTenant | dispatch | visible() checks Gate::allows(Capabilities::TENANT_SYNC, record) | Capabilities::TENANT_SYNC | Uses OperationRunService to dispatch SyncPoliciesJob. |
|
||||
| M002 | app/Filament/Resources/TenantResource.php | syncSelected (bulk) | dispatch | visible()+authorize() checks rolesWithCapability(Capabilities::TENANT_SYNC) | Capabilities::TENANT_SYNC | Dispatches BulkTenantSyncJob. |
|
||||
| M003 | app/Filament/Resources/TenantResource.php | makeCurrent | mutate | none obvious | NEW | Sets current tenant context; should be capability-gated. |
|
||||
| M004 | app/Filament/Resources/TenantResource.php | archive / deactivate | destructive | none obvious | Capabilities::TENANT_DELETE (or NEW) | Soft-deletes tenant; confirmation already present. |
|
||||
| M005 | app/Filament/Resources/TenantResource.php | forceDelete | destructive | none obvious | Capabilities::TENANT_DELETE | Permanent delete; confirmation already present. |
|
||||
| M006 | app/Filament/Resources/TenantResource.php | verify | mutate/dispatch | none obvious | Capabilities::TENANT_MANAGE | May update status fields; should be capability-gated. |
|
||||
| M007 | app/Filament/Resources/TenantResource.php | setup_rbac | mutate/dispatch | none obvious | Capabilities::TENANT_MANAGE | Intune RBAC setup; should be capability-gated + confirmed (confirmation present). |
|
||||
|
||||
Tenant membership
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M010 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | add member | mutate | relation manager auth + server-side manager guards | Capabilities::TENANT_MEMBERSHIP_MANAGE | Uses TenantMembershipManager (audited). |
|
||||
| M011 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | change role | mutate | relation manager auth + last-owner protection | Capabilities::TENANT_MEMBERSHIP_MANAGE | Privilege change; requires confirmation. |
|
||||
| M012 | app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php | remove member | destructive | relation manager auth + last-owner protection | Capabilities::TENANT_MEMBERSHIP_MANAGE | Requires confirmation; blocked attempts are audited. |
|
||||
|
||||
Providers (provider-plane)
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M020 | app/Filament/Resources/ProviderConnectionResource.php | check_connection | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderConnectionHealthCheckJob via OperationRunService. |
|
||||
| M021 | app/Filament/Resources/ProviderConnectionResource.php | inventory_sync | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderInventorySyncJob. |
|
||||
| M022 | app/Filament/Resources/ProviderConnectionResource.php | compliance_snapshot | dispatch | Gate::allows(Capabilities::PROVIDER_RUN_OPERATIONS, record) | Capabilities::PROVIDER_RUN_OPERATIONS | Dispatches ProviderComplianceSnapshotJob. |
|
||||
| M023 | app/Filament/Resources/ProviderConnectionResource.php | set_default | mutate | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Changes default provider; audited. |
|
||||
| M024 | app/Filament/Resources/ProviderConnectionResource.php | update_credentials | secret | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Credential/secret handling; audited. |
|
||||
| M025 | app/Filament/Resources/ProviderConnectionResource.php | enable / disable | mutate | Gate::allows(Capabilities::PROVIDER_MANAGE, record) | Capabilities::PROVIDER_MANAGE | Connection state change; audited. |
|
||||
|
||||
Backup schedules
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M030 | app/Filament/Resources/BackupScheduleResource.php | create/edit/delete | mutate/destructive | Resource can* + policy guard | Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE | Already mapped in Step-1. |
|
||||
| M031 | app/Filament/Resources/BackupScheduleResource.php | run now / retry (row + bulk) | dispatch | visible()+abort_unless guards | Capabilities::TENANT_BACKUP_SCHEDULES_RUN | Already mapped in Step-1. |
|
||||
|
||||
Backup sets
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M040 | app/Filament/Resources/BackupSetResource.php | restore | dispatch | none obvious | NEW | Starts restore workflow from a backup set; uses OperationRunService. |
|
||||
| M041 | app/Filament/Resources/BackupSetResource.php | archive / delete (bulk) | destructive | none obvious | NEW (or Capabilities::TENANT_DELETE) | Bulk job: BulkBackupSetDeleteJob. |
|
||||
| M042 | app/Filament/Resources/BackupSetResource.php | restore (bulk) | dispatch | none obvious | NEW | BulkBackupSetRestoreJob. |
|
||||
| M043 | app/Filament/Resources/BackupSetResource.php | force delete (row + bulk) | destructive | none obvious | NEW | Bulk job: BulkBackupSetForceDeleteJob. |
|
||||
|
||||
Restore runs
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M050 | app/Filament/Resources/RestoreRunResource/Pages/CreateRestoreRun.php | create/queue restore run | dispatch | tenant match + non-dry-run confirmation | NEW | Dispatches ExecuteRestoreRunJob; emits restore.queued audit. |
|
||||
| M051 | app/Filament/Resources/RestoreRunResource.php | rerun | dispatch | none obvious | NEW | Starts restore rerun. |
|
||||
| M052 | app/Filament/Resources/RestoreRunResource.php | archive / restore / forceDelete | destructive | none obvious | NEW (or Capabilities::TENANT_DELETE) | Row-level destructive actions; confirmations exist. |
|
||||
| M053 | app/Filament/Resources/RestoreRunResource.php | bulk delete / restore / force delete | destructive | none obvious | NEW | Bulk jobs: BulkRestoreRunDeleteJob, BulkRestoreRunRestoreJob, BulkRestoreRunForceDeleteJob. |
|
||||
|
||||
Drift
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M060 | app/Filament/Pages/DriftLanding.php | auto enqueue findings generation (mount) | dispatch | Gate::allows(Capabilities::TENANT_SYNC, tenant) | Capabilities::TENANT_SYNC | Dispatches GenerateDriftFindingsJob when no findings exist. |
|
||||
|
||||
Findings
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M070 | app/Filament/Resources/FindingResource.php | acknowledge (row + bulk) | mutate | policy + tenant scoping | NEW (or policy-only) | Local mutation; decide in T025 whether to require a dedicated capability. |
|
||||
|
||||
Policies
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M080 | app/Filament/Resources/PolicyResource.php | ignore / unignore (row + bulk) | mutate | mixed (some Gate checks) | NEW | Local policy lifecycle; bulk jobs include BulkPolicyDeleteJob / BulkPolicyUnignoreJob (verify naming). |
|
||||
| M081 | app/Filament/Resources/PolicyResource.php | sync (row + bulk) | dispatch | requires Capabilities::TENANT_SYNC in places | Capabilities::TENANT_SYNC (or NEW) | Dispatches SyncPoliciesJob; ensure all entry points have server-side authorization. |
|
||||
| M082 | app/Filament/Resources/PolicyResource.php | export (row + bulk) | dispatch | none obvious | NEW | BulkPolicyExportJob; capability needed to prevent data exfil. |
|
||||
| M083 | app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php | restore_to_intune | dispatch | none obvious | NEW | Calls RestoreService::executeFromPolicyVersion. |
|
||||
|
||||
Entra groups
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M090 | app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php | sync_groups (header) | dispatch | abort(403) when role cannot sync | Capabilities::TENANT_SYNC | Execution guard already present. |
|
||||
| M091 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php | sync_groups (header) | dispatch | abort(403) when role cannot sync | Capabilities::TENANT_SYNC | Execution guard already present. |
|
||||
|
||||
Inventory
|
||||
|
||||
| ID | Location | Action | Kind | Current guard | Capability (target) | Notes |
|
||||
|-----|----------|--------|------|---------------|---------------------|-------|
|
||||
| M100 | app/Filament/Resources/InventorySyncRunResource.php | view runs | read | relies on tenant scoping | Capabilities::TENANT_VIEW (or NEW) | Decide whether listing historical runs needs explicit capability in T025. |
|
||||
|
||||
Scope: discovery only (phase 1). This file enumerates every remaining occurrence matched by the stop-regex:
|
||||
|
||||
`TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync`
|
||||
|
||||
Notes:
|
||||
- `rg` (ripgrep) is not available in this environment, so discovery uses GNU/BSD `grep`.
|
||||
- The “allowed” exclusions for sweep progress reporting are:
|
||||
- `app/Services/Auth/RoleCapabilityMap.php`
|
||||
- `app/Services/Auth/CapabilityResolver.php`
|
||||
- `app/Support/Auth/Capabilities.php`
|
||||
- `app/Support/TenantRole.php`
|
||||
|
||||
## Discovery commands + counts
|
||||
|
||||
### Total matches (all of app/)
|
||||
|
||||
```bash
|
||||
grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app | wc -l
|
||||
```
|
||||
|
||||
Result: **31**
|
||||
|
||||
### Remaining matches (excluding mapping/registry/TenantRole enum definition)
|
||||
|
||||
```bash
|
||||
grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app \
|
||||
| grep -vE '^app/Services/Auth/RoleCapabilityMap\.php:|^app/Services/Auth/CapabilityResolver\.php:|^app/Support/Auth/Capabilities\.php:|^app/Support/TenantRole\.php:' \
|
||||
| wc -l
|
||||
```
|
||||
|
||||
Result: **21**
|
||||
|
||||
### Top files by remaining match count
|
||||
|
||||
```bash
|
||||
grep -RInE --include='*.php' 'TenantRole::|->tenantRole\(|currentTenantRole\(|canManage|canRun|canSync' app \
|
||||
| grep -vE '^app/Services/Auth/RoleCapabilityMap\.php:|^app/Services/Auth/CapabilityResolver\.php:|^app/Support/Auth/Capabilities\.php:|^app/Support/TenantRole\.php:' \
|
||||
| cut -d: -f1 | sort | uniq -c | sort -nr | head -n 20
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
10 app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php
|
||||
6 app/Services/Auth/TenantMembershipManager.php
|
||||
2 app/Models/User.php
|
||||
2 app/Filament/Pages/Tenancy/RegisterTenant.php
|
||||
1 app/Filament/Resources/TenantResource/Pages/CreateTenant.php
|
||||
```
|
||||
|
||||
## Full remaining match list (excluding mapping/registry/TenantRole enum definition)
|
||||
|
||||
```text
|
||||
app/Models/User.php:116: return TenantRole::tryFrom($role);
|
||||
app/Models/User.php:119: public function canSyncTenant(Tenant $tenant): bool
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:66: TenantRole::Owner->value => 'Owner',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:67: TenantRole::Manager->value => 'Manager',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:68: TenantRole::Operator->value => 'Operator',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:69: TenantRole::Readonly->value => 'Readonly',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:100: role: TenantRole::from((string) $data['role']),
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:135: TenantRole::Owner->value => 'Owner',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:136: TenantRole::Manager->value => 'Manager',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:137: TenantRole::Operator->value => 'Operator',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:138: TenantRole::Readonly->value => 'Readonly',
|
||||
app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php:162: newRole: TenantRole::from((string) $data['role']),
|
||||
app/Filament/Resources/TenantResource/Pages/CreateTenant.php:23: $this->record->getKey() => ['role' => TenantRole::Owner->value],
|
||||
app/Filament/Pages/Tenancy/RegisterTenant.php:79: 'role' => TenantRole::Owner->value,
|
||||
app/Filament/Pages/Tenancy/RegisterTenant.php:91: 'role' => TenantRole::Owner->value,
|
||||
app/Services/Auth/TenantMembershipManager.php:178: role: TenantRole::Owner,
|
||||
app/Services/Auth/TenantMembershipManager.php:203: if ($membership->role !== TenantRole::Owner->value) {
|
||||
app/Services/Auth/TenantMembershipManager.php:209: ->where('role', TenantRole::Owner->value)
|
||||
app/Services/Auth/TenantMembershipManager.php:219: if ($membership->role !== TenantRole::Owner->value) {
|
||||
app/Services/Auth/TenantMembershipManager.php:223: if ($newRole === TenantRole::Owner) {
|
||||
app/Services/Auth/TenantMembershipManager.php:229: ->where('role', TenantRole::Owner->value)
|
||||
```
|
||||
| H003 | app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php:62 | `if (! ($role?->canSync() ?? false)) abort(403);` | Header action execution: `sync_groups` | `Capabilities::TENANT_SYNC` | Execution guard. |
|
||||
| H004 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php:50 | `$role?->canSync() ?? false` | Header action visibility: `sync_groups` | `Capabilities::TENANT_SYNC` | Visible guard only. |
|
||||
| H005 | app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php:71 | `if (! ($role?->canSync() ?? false)) abort(403);` | Header action execution: `sync_groups` | `Capabilities::TENANT_SYNC` | Execution guard. |
|
||||
| H006 | app/Filament/Resources/BackupScheduleResource.php:86 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canCreate()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H007 | app/Filament/Resources/BackupScheduleResource.php:91 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canEdit()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H008 | app/Filament/Resources/BackupScheduleResource.php:96 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canDelete()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H009 | app/Filament/Resources/BackupScheduleResource.php:101 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Resource ability: `canDeleteAny()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H010 | app/Filament/Resources/BackupScheduleResource.php:303 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Table row action visibility: `runNow` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H011 | app/Filament/Resources/BackupScheduleResource.php:305 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Table row action execution: `runNow` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H012 | app/Filament/Resources/BackupScheduleResource.php:427 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Table row action visibility: `retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H013 | app/Filament/Resources/BackupScheduleResource.php:429 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Table row action execution: `retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H014 | app/Filament/Resources/BackupScheduleResource.php:548 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Table row action visibility: `EditAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H015 | app/Filament/Resources/BackupScheduleResource.php:550 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Table row action visibility: `DeleteAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H016 | app/Filament/Resources/BackupScheduleResource.php:559 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Bulk action visibility: `bulk_run_now` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H017 | app/Filament/Resources/BackupScheduleResource.php:561 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Bulk action execution: `bulk_run_now` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H018 | app/Filament/Resources/BackupScheduleResource.php:688 | `static::currentTenantRole()?->canRunBackupSchedules() ?? false` | Bulk action visibility: `bulk_retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H019 | app/Filament/Resources/BackupScheduleResource.php:690 | `abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);` | Bulk action execution: `bulk_retry` | `Capabilities::TENANT_BACKUP_SCHEDULES_RUN` | New capability added in Option-1 patch. |
|
||||
| H020 | app/Filament/Resources/BackupScheduleResource.php:814 | `static::currentTenantRole()?->canManageBackupSchedules() ?? false` | Bulk action visibility: `DeleteBulkAction` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H021 | app/Policies/BackupSchedulePolicy.php:34 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `create()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H022 | app/Policies/BackupSchedulePolicy.php:39 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `update()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
| H023 | app/Policies/BackupSchedulePolicy.php:44 | `$this->resolveRole($user)?->canManageBackupSchedules() ?? false` | Policy: `delete()` | `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE` | New capability added in Option-1 patch. |
|
||||
|
||||
## Step-1 conclusion (guardrails)
|
||||
|
||||
- `canSync()` has a clear mapping: `Capabilities::TENANT_SYNC`.
|
||||
- `canManageBackupSchedules()` now maps to `Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE`.
|
||||
- `canRunBackupSchedules()` now maps to `Capabilities::TENANT_BACKUP_SCHEDULES_RUN`.
|
||||
- No unmapped `TenantRole::can*()` usages remain in this hitlist.
|
||||
@ -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`.
|
||||
|
||||
400
specs/065-tenant-rbac-v1/spec.md
Normal file
400
specs/065-tenant-rbac-v1/spec.md
Normal 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 repository’s 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)
|
||||
@ -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: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`
|
||||
- [X] T039 [P] [US2] Role matrix tests (Operator: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`
|
||||
- [X] T040 [P] [US2] Role matrix tests (Manager: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`
|
||||
- [X] T041 [P] [US2] Role matrix tests (Owner: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`
|
||||
|
||||
### Late enforcement follow-ups (post-sweep)
|
||||
|
||||
- [X] T046 Gate tenant registration + tenant create/edit/delete for non-managers (prevent cross-tenant privilege escalation via Register Tenant) in `app/Filament/Pages/Tenancy/RegisterTenant.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Resources/TenantResource/Pages/*.php`
|
||||
- [X] T047 Gate policy version maintenance actions (archive/restore/prune/force delete) to `Capabilities::TENANT_MANAGE` with UI disable + server-side abort in `app/Filament/Resources/PolicyVersionResource.php`
|
||||
- [X] T048 Add regression tests for readonly tenant + policy version maintenance restrictions in `tests/Feature/Rbac/*.php` and update existing bootstrap test in `tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php`
|
||||
|
||||
## Phase 5: Polish & Finalization
|
||||
|
||||
- [ ] 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 (T024–T030) can be split across agents once T022 (hitlist) is stable.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
|
||||
130
tests/Feature/Audit/TenantMembershipAuditLogTest.php
Normal file
130
tests/Feature/Audit/TenantMembershipAuditLogTest.php
Normal 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');
|
||||
}
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -70,6 +70,5 @@
|
||||
->assertSee('Coverage')
|
||||
->assertSee('Policies')
|
||||
->assertSee('Foundations')
|
||||
->assertSee('Dependencies')
|
||||
->assertSee('✅');
|
||||
->assertSee('Dependencies');
|
||||
});
|
||||
|
||||
@ -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',
|
||||
|
||||
142
tests/Feature/Filament/TenantActionsAuthorizationTest.php
Normal file
142
tests/Feature/Filament/TenantActionsAuthorizationTest.php
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
35
tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php
Normal file
35
tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php
Normal 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();
|
||||
});
|
||||
111
tests/Feature/Filament/TenantMembersTest.php
Normal file
111
tests/Feature/Filament/TenantMembersTest.php
Normal 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();
|
||||
});
|
||||
70
tests/Feature/Filament/TenantScopingTest.php
Normal file
70
tests/Feature/Filament/TenantScopingTest.php
Normal 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);
|
||||
});
|
||||
@ -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 () {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -166,5 +166,5 @@
|
||||
|
||||
$message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
|
||||
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
|
||||
expect($message)->toContain('[REDACTED]');
|
||||
expect($message)->toContain('[REDACTED');
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
28
tests/Feature/Rbac/DenialDiagnosticsTest.php
Normal file
28
tests/Feature/Rbac/DenialDiagnosticsTest.php
Normal 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();
|
||||
});
|
||||
111
tests/Feature/Rbac/FilamentManageEnforcementTest.php
Normal file
111
tests/Feature/Rbac/FilamentManageEnforcementTest.php
Normal 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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
26
tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php
Normal file
26
tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php
Normal 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();
|
||||
});
|
||||
26
tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php
Normal file
26
tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php
Normal 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();
|
||||
});
|
||||
25
tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php
Normal file
25
tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php
Normal 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();
|
||||
});
|
||||
25
tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php
Normal file
25
tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php
Normal 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();
|
||||
});
|
||||
30
tests/Feature/Rbac/TenantAdminAuthorizationTest.php
Normal file
30
tests/Feature/Rbac/TenantAdminAuthorizationTest.php
Normal 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);
|
||||
});
|
||||
@ -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.');
|
||||
});
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
37
tests/Unit/Auth/CapabilityResolverQueryCountTest.php
Normal file
37
tests/Unit/Auth/CapabilityResolverQueryCountTest.php
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
62
tests/Unit/Auth/NoRoleStringChecksTest.php
Normal file
62
tests/Unit/Auth/NoRoleStringChecksTest.php
Normal 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();
|
||||
});
|
||||
20
tests/Unit/Auth/UnknownCapabilityGuardTest.php
Normal file
20
tests/Unit/Auth/UnknownCapabilityGuardTest.php
Normal 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);
|
||||
82
tests/Unit/Intune/VersionServiceConcurrencyTest.php
Normal file
82
tests/Unit/Intune/VersionServiceConcurrencyTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user