065-tenant-rbac-v1 (#79)
PR Body Implements Spec 065 “Tenant RBAC v1” with capabilities-first RBAC, tenant membership scoping (Option 3), and consistent Filament action semantics. Key decisions / rules Tenancy Option 3: tenant switching is tenantless (ChooseTenant), tenant-scoped routes stay scoped, non-members get 404 (not 403). RBAC model: canonical capability registry + role→capability map + Gates for each capability (no role-string checks in UI logic). UX policy: for tenant members lacking permission → actions are visible but disabled + tooltip (avoid click→403). Security still enforced server-side. What’s included Capabilities foundation: Central capability registry (Capabilities::*) Role→capability mapping (RoleCapabilityMap) Gate registration + resolver/manager updates to support tenant-scoped authorization Filament enforcement hardening across the app: Tenant registration & tenant CRUD properly gated Backup/restore/policy flows aligned to “visible-but-disabled” where applicable Provider operations (health check / inventory sync / compliance snapshot) guarded and normalized Directory groups + inventory sync start surfaces normalized Policy version maintenance actions (archive/restore/prune/force delete) gated SpecKit artifacts for 065: spec.md, plan/tasks updates, checklists, enforcement hitlist Security guarantees Non-member → 404 via tenant scoping/membership guards. Member without capability → 403 on execution, even if UI is disabled. No destructive actions execute without proper authorization checks. Tests Adds/updates Pest coverage for: Tenant scoping & membership denial behavior Role matrix expectations (owner/manager/operator/readonly) Filament surface checks (visible/disabled actions, no side effects) Provider/Inventory/Groups run-start authorization Verified locally with targeted vendor/bin/sail artisan test --compact … Deployment / ops notes No new services required. Safe change: behavior is authorization + UI semantics; no breaking route changes intended. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #79
This commit is contained in:
parent
3a3de045ba
commit
d90fb0f963
@ -141,7 +141,7 @@ ### Spec-First Workflow
|
|||||||
|
|
||||||
## Quality Gates
|
## Quality Gates
|
||||||
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
- Changes MUST be programmatically tested (Pest) and run via targeted `php artisan test ...`.
|
||||||
- Run `./vendor/bin/pint --dirty` before finalizing.
|
- Run `./vendor/bin/sail bin pint --dirty` before finalizing.
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
|
|||||||
@ -896,9 +896,9 @@ ### Replaced Utilities
|
|||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 065-tenant-rbac-v1: Added PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4
|
||||||
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
- 064-auth-structure: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
||||||
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
- 063-entra-signin: Added PHP 8.4 + `laravel/framework:^12`, `livewire/livewire:^4`, `filament/filament:^5`, `laravel/socialite:^5.0`
|
||||||
- 062-tenant-rbac-v1: Added PHP 8.4 + Laravel 12, Filament v5, Livewire v4
|
|
||||||
|
|
||||||
## Active Technologies
|
## Active Technologies
|
||||||
- PostgreSQL (with a new `platform_users` table) (064-auth-structure)
|
- PHP 8.4+ + Laravel 12, Filament 5, Livewire 4, Pest 4 (065-tenant-rbac-v1)
|
||||||
|
|||||||
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\Drift\DriftRunSelector;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -20,6 +21,7 @@
|
|||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Pages\Page;
|
use Filament\Pages\Page;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class DriftLanding extends Page
|
class DriftLanding extends Page
|
||||||
@ -173,7 +175,7 @@ public function mount(): void
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
$this->state = 'blocked';
|
$this->state = 'blocked';
|
||||||
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
$this->message = 'You can view existing drift findings and run history, but you do not have permission to generate drift.';
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,12 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\TenantRole;
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
use Filament\Pages\Tenancy\RegisterTenant as BaseRegisterTenant;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class RegisterTenant extends BaseRegisterTenant
|
class RegisterTenant extends BaseRegisterTenant
|
||||||
{
|
{
|
||||||
@ -20,7 +21,25 @@ public static function getLabel(): string
|
|||||||
|
|
||||||
public static function canView(): bool
|
public static function canView(): bool
|
||||||
{
|
{
|
||||||
return true;
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
|
if ($tenantIds->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||||
|
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function form(Schema $schema): Schema
|
public function form(Schema $schema): Schema
|
||||||
@ -69,6 +88,8 @@ public function form(Schema $schema): Schema
|
|||||||
*/
|
*/
|
||||||
protected function handleRegistration(array $data): Model
|
protected function handleRegistration(array $data): Model
|
||||||
{
|
{
|
||||||
|
abort_unless(static::canView(), 403);
|
||||||
|
|
||||||
$tenant = Tenant::create($data);
|
$tenant = Tenant::create($data);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -76,7 +97,7 @@ protected function handleRegistration(array $data): Model
|
|||||||
if ($user instanceof User) {
|
if ($user instanceof User) {
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$tenant->getKey() => [
|
$tenant->getKey() => [
|
||||||
'role' => TenantRole::Owner->value,
|
'role' => 'owner',
|
||||||
'source' => 'manual',
|
'source' => 'manual',
|
||||||
'created_by_user_id' => $user->getKey(),
|
'created_by_user_id' => $user->getKey(),
|
||||||
],
|
],
|
||||||
@ -88,7 +109,7 @@ protected function handleRegistration(array $data): Model
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'user_id' => (int) $user->getKey(),
|
'user_id' => (int) $user->getKey(),
|
||||||
'role' => TenantRole::Owner->value,
|
'role' => 'owner',
|
||||||
'source' => 'manual',
|
'source' => 'manual',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -22,7 +23,6 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
@ -50,6 +50,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\UniqueConstraintViolationException;
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
use Illuminate\Support\Facades\Bus;
|
use Illuminate\Support\Facades\Bus;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -60,45 +61,63 @@ class BackupScheduleResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
protected static function currentTenantRole(): ?TenantRole
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->tenantRole(Tenant::current());
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function canViewAny(): bool
|
public static function canViewAny(): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole() !== null;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canView(Model $record): bool
|
public static function canView(Model $record): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole() !== null;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof BackupSchedule) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canCreate(): bool
|
public static function canCreate(): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canEdit(Model $record): bool
|
public static function canEdit(Model $record): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDelete(Model $record): bool
|
public static function canDelete(Model $record): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function canDeleteAny(): bool
|
public static function canDeleteAny(): bool
|
||||||
{
|
{
|
||||||
return static::currentTenantRole()?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
@ -300,11 +319,18 @@ public static function table(Table $table): Table
|
|||||||
->label('Run now')
|
->label('Run now')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
->visible(function (): bool {
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
|
||||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
@ -424,11 +450,18 @@ public static function table(Table $table): Table
|
|||||||
->label('Retry')
|
->label('Retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
->visible(function (): bool {
|
||||||
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
|
||||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->action(function (BackupSchedule $record, HasTable $livewire): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
$userModel = $user instanceof User ? $user : ($userId ? User::query()->find($userId) : null);
|
||||||
@ -545,9 +578,19 @@ public static function table(Table $table): Table
|
|||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
EditAction::make()
|
EditAction::make()
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
|
}),
|
||||||
DeleteAction::make()
|
DeleteAction::make()
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
@ -556,9 +599,17 @@ public static function table(Table $table): Table
|
|||||||
->label('Run now')
|
->label('Run now')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||||
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
if ($records->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -685,9 +736,17 @@ public static function table(Table $table): Table
|
|||||||
->label('Retry')
|
->label('Retry')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('warning')
|
->color('warning')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canRunBackupSchedules() ?? false)
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant);
|
||||||
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire): void {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
abort_unless(static::currentTenantRole()?->canRunBackupSchedules() ?? false, 403);
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_RUN, $tenant), 403);
|
||||||
|
|
||||||
if ($records->isEmpty()) {
|
if ($records->isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -811,7 +870,12 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
DeleteBulkAction::make('bulk_delete')
|
DeleteBulkAction::make('bulk_delete')
|
||||||
->visible(fn (): bool => static::currentTenantRole()?->canManageBackupSchedules() ?? false),
|
->visible(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -33,6 +34,7 @@
|
|||||||
use Filament\Tables\Filters\TrashedFilter;
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class BackupSetResource extends Resource
|
class BackupSetResource extends Resource
|
||||||
@ -43,6 +45,12 @@ class BackupSetResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return ($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -87,8 +95,14 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record) => $record->trashed())
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
$record->items()->withTrashed()->restore();
|
$record->items()->withTrashed()->restore();
|
||||||
|
|
||||||
@ -113,8 +127,14 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record) => ! $record->trashed())
|
->visible(fn (BackupSet $record): bool => ! $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -138,8 +158,14 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (BackupSet $record) => $record->trashed())
|
->visible(fn (BackupSet $record): bool => $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||||
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
->action(function (BackupSet $record, AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||||
|
|
||||||
if ($record->restoreRuns()->withTrashed()->exists()) {
|
if ($record->restoreRuns()->withTrashed()->exists()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot force delete backup set')
|
->title('Cannot force delete backup set')
|
||||||
@ -178,6 +204,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -212,6 +240,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -258,6 +288,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -278,6 +310,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -324,6 +358,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -359,6 +395,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
|||||||
@ -13,7 +13,9 @@ class ListBackupSets extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->disabled(fn (): bool => ! BackupSetResource::canCreate())
|
||||||
|
->tooltip(fn (): ?string => BackupSetResource::canCreate() ? null : 'You do not have permission to create backup sets.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -23,6 +24,7 @@
|
|||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class BackupItemsRelationManager extends RelationManager
|
class BackupItemsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -132,6 +134,10 @@ public function table(Table $table): Table
|
|||||||
Actions\Action::make('addPolicies')
|
Actions\Action::make('addPolicies')
|
||||||
->label('Add Policies')
|
->label('Add Policies')
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)))
|
||||||
|
->tooltip(fn (): ?string => (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_SYNC, $tenant)) ? null : 'You do not have permission to add policies.')
|
||||||
->modalHeading('Add Policies')
|
->modalHeading('Add Policies')
|
||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelActionLabel('Close')
|
->modalCancelActionLabel('Close')
|
||||||
@ -173,7 +179,11 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,7 +262,11 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
$tenant = $backupSet->tenant ?? Tenant::current();
|
$tenant = $backupSet->tenant ?? Tenant::current();
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,10 +10,12 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ListEntraGroups extends ListRecords
|
class ListEntraGroups extends ListRecords
|
||||||
{
|
{
|
||||||
@ -45,9 +47,43 @@ protected function getHeaderActions(): array
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $user->tenantRole($tenant);
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return $role?->canSync() ?? false;
|
return true;
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to sync groups.';
|
||||||
})
|
})
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -66,11 +102,7 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $user->tenantRole($tenant);
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
||||||
|
|
||||||
if (! ($role?->canSync() ?? false)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,10 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
use App\Services\Directory\EntraGroupSelection;
|
use App\Services\Directory\EntraGroupSelection;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ListEntraGroupSyncRuns extends ListRecords
|
class ListEntraGroupSyncRuns extends ListRecords
|
||||||
{
|
{
|
||||||
@ -36,9 +38,11 @@ protected function getHeaderActions(): array
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $user->tenantRole($tenant);
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return $role?->canSync() ?? false;
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
})
|
})
|
||||||
->action(function (): void {
|
->action(function (): void {
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -57,11 +61,7 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $user->tenantRole($tenant);
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant), 403);
|
||||||
|
|
||||||
if (! ($role?->canSync() ?? false)) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
@ -25,6 +26,7 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Gate;
|
use Illuminate\Support\Facades\Gate;
|
||||||
@ -40,6 +42,33 @@ class FindingResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = 'Findings';
|
protected static ?string $navigationLabel = 'Findings';
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof Finding) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -389,7 +418,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->addSelect([
|
->addSelect([
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -24,6 +25,8 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventoryItemResource extends Resource
|
class InventoryItemResource extends Resource
|
||||||
@ -38,6 +41,33 @@ class InventoryItemResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof InventoryItem) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -225,7 +255,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId))
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -23,6 +24,7 @@
|
|||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
use Filament\Support\Enums\Size;
|
use Filament\Support\Enums\Size;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ListInventoryItems extends ListRecords
|
class ListInventoryItems extends ListRecords
|
||||||
{
|
{
|
||||||
@ -109,17 +111,57 @@ protected function getHeaderActions(): array
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->canSyncTenant(Tenant::current());
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant);
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to start inventory sync.';
|
||||||
})
|
})
|
||||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403, 'Not allowed');
|
abort(403, 'Not allowed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -19,6 +20,8 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class InventorySyncRunResource extends Resource
|
class InventorySyncRunResource extends Resource
|
||||||
@ -35,6 +38,33 @@ class InventorySyncRunResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||||
|
|
||||||
|
public static function canViewAny(): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canView(Model $record): bool
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::allows(Capabilities::TENANT_VIEW, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($record instanceof InventorySyncRun) {
|
||||||
|
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static function getNavigationLabel(): string
|
public static function getNavigationLabel(): string
|
||||||
{
|
{
|
||||||
return 'Sync History';
|
return 'Sync History';
|
||||||
@ -155,7 +185,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
public static function getEloquentQuery(): Builder
|
||||||
{
|
{
|
||||||
$tenantId = Tenant::current()->getKey();
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
return parent::getEloquentQuery()
|
return parent::getEloquentQuery()
|
||||||
->with('user')
|
->with('user')
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -40,6 +41,7 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyResource extends Resource
|
class PolicyResource extends Resource
|
||||||
@ -366,8 +368,14 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (Policy $record, HasTable $livewire) {
|
->action(function (Policy $record, HasTable $livewire) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->ignore();
|
$record->ignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -380,8 +388,14 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Policy $record) => $record->ignored_at !== null)
|
->visible(fn (Policy $record): bool => $record->ignored_at !== null)
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (Policy $record) {
|
->action(function (Policy $record) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->unignore();
|
$record->unignore();
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -406,18 +420,25 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return $user->canSyncTenant($tenant);
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
})
|
})
|
||||||
->action(function (Policy $record, HasTable $livewire): void {
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -461,7 +482,9 @@ public static function table(Table $table): Table
|
|||||||
Actions\Action::make('export')
|
Actions\Action::make('export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
->visible(fn (Policy $record) => $record->ignored_at === null)
|
->visible(fn (Policy $record): bool => $record->ignored_at === null)
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label('Backup Name')
|
||||||
@ -476,6 +499,8 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$ids = [(int) $record->getKey()];
|
$ids = [(int) $record->getKey()];
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -533,6 +558,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $value === 'ignored';
|
return $value === 'ignored';
|
||||||
})
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
if ($records->count() >= 20) {
|
if ($records->count() >= 20) {
|
||||||
return [
|
return [
|
||||||
@ -558,6 +593,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -616,6 +653,16 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! in_array($value, [null, 'ignored'], true);
|
return ! in_array($value, [null, 'ignored'], true);
|
||||||
})
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
->action(function (Collection $records, HasTable $livewire) {
|
->action(function (Collection $records, HasTable $livewire) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -626,6 +673,8 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
@ -704,8 +753,11 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -719,11 +771,15 @@ public static function table(Table $table): Table
|
|||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
if (! $user instanceof User) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
if (! $user->canAccessTenant($tenant) || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -777,6 +833,16 @@ public static function table(Table $table): Table
|
|||||||
BulkAction::make('bulk_export')
|
BulkAction::make('bulk_export')
|
||||||
->label('Export to Backup')
|
->label('Export to Backup')
|
||||||
->icon('heroicon-o-archive-box-arrow-down')
|
->icon('heroicon-o-archive-box-arrow-down')
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\TextInput::make('backup_name')
|
Forms\Components\TextInput::make('backup_name')
|
||||||
->label('Backup Name')
|
->label('Backup Name')
|
||||||
@ -793,6 +859,8 @@ public static function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$selection = app(BulkSelectionIdentity::class);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
|
|||||||
@ -7,12 +7,14 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class ListPolicies extends ListRecords
|
class ListPolicies extends ListRecords
|
||||||
{
|
{
|
||||||
@ -35,7 +37,28 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
return $user->canSyncTenant($tenant);
|
return $tenant instanceof Tenant
|
||||||
|
&& $user->canAccessTenant($tenant);
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return ! ($user instanceof User
|
||||||
|
&& $tenant instanceof Tenant
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! ($user instanceof User && $tenant instanceof Tenant)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to sync policies.';
|
||||||
})
|
})
|
||||||
->action(function (self $livewire): void {
|
->action(function (self $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
@ -45,7 +68,15 @@ protected function getHeaderActions(): array
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant) || ! $user->canSyncTenant($tenant)) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->canAccessTenant($tenant)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -14,6 +15,7 @@
|
|||||||
use Filament\Resources\RelationManagers\RelationManager;
|
use Filament\Resources\RelationManagers\RelationManager;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class VersionsRelationManager extends RelationManager
|
class VersionsRelationManager extends RelationManager
|
||||||
{
|
{
|
||||||
@ -42,6 +44,8 @@ public function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
||||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
||||||
|
->visible(fn (): bool => ($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant))
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} to Intune?")
|
||||||
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
->modalSubheading('Creates a restore run using this policy version snapshot.')
|
||||||
@ -53,6 +57,8 @@ public function table(Table $table): Table
|
|||||||
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
->action(function (PolicyVersion $record, array $data, RestoreService $restoreService) {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
if ($record->tenant_id !== $tenant->id) {
|
if ($record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version belongs to a different tenant')
|
->title('Policy version belongs to a different tenant')
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
use App\Support\Badges\TagBadgeRenderer;
|
use App\Support\Badges\TagBadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -38,6 +39,7 @@
|
|||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
|
|
||||||
class PolicyVersionResource extends Resource
|
class PolicyVersionResource extends Resource
|
||||||
@ -210,8 +212,19 @@ public static function table(Table $table): Table
|
|||||||
->label('Restore via Wizard')
|
->label('Restore via Wizard')
|
||||||
->icon('heroicon-o-arrow-path-rounded-square')
|
->icon('heroicon-o-arrow-path-rounded-square')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only')
|
->disabled(fn (PolicyVersion $record): bool => ($record->metadata['source'] ?? null) === 'metadata_only'
|
||||||
->tooltip('Disabled for metadata-only snapshots (Graph did not provide policy settings).')
|
|| ! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
if (! (($tenant = Tenant::current()) instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant))) {
|
||||||
|
return 'You do not have permission to create restore runs.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($record->metadata['source'] ?? null) === 'metadata_only') {
|
||||||
|
return 'Disabled for metadata-only snapshots (Graph did not provide policy settings).';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
->modalHeading(fn (PolicyVersion $record): string => "Restore version {$record->version_number} via wizard?")
|
||||||
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
->modalSubheading('Creates a 1-item backup set from this snapshot and opens the restore run wizard prefilled.')
|
||||||
@ -219,6 +232,8 @@ public static function table(Table $table): Table
|
|||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
if (! $tenant || $record->tenant_id !== $tenant->id) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Policy version belongs to a different tenant')
|
->title('Policy version belongs to a different tenant')
|
||||||
@ -304,7 +319,35 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
->visible(fn (PolicyVersion $record) => ! $record->trashed())
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -329,7 +372,35 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
@ -355,7 +426,35 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (PolicyVersion $record) => $record->trashed())
|
->visible(fn (PolicyVersion $record) => $record->trashed())
|
||||||
|
->disabled(function (PolicyVersion $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (PolicyVersion $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
if (! $user instanceof User || ! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
->action(function (PolicyVersion $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
$tenant = $record->tenant;
|
||||||
|
|
||||||
|
abort_unless($user instanceof User && $tenant instanceof Tenant, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -392,6 +491,28 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $isOnlyTrashed;
|
return $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->form(function (Collection $records) {
|
->form(function (Collection $records) {
|
||||||
$fields = [
|
$fields = [
|
||||||
Forms\Components\TextInput::make('retention_days')
|
Forms\Components\TextInput::make('retention_days')
|
||||||
@ -427,6 +548,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -499,6 +623,28 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
->modalHeading(fn (Collection $records) => "Restore {$records->count()} policy versions?")
|
||||||
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
->modalDescription('Archived versions will be restored back to the active list. Active versions will be skipped.')
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records) {
|
||||||
@ -511,6 +657,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -565,6 +714,28 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return ! $isOnlyTrashed;
|
return ! $isOnlyTrashed;
|
||||||
})
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage policy versions.';
|
||||||
|
})
|
||||||
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
->modalHeading(fn (Collection $records) => "Force delete {$records->count()} policy versions?")
|
||||||
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
->modalDescription('This is permanent. Only archived versions will be permanently deleted; active versions will be skipped.')
|
||||||
->form([
|
->form([
|
||||||
@ -586,6 +757,9 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Concerns\ScopesGlobalSearchToTenant;
|
||||||
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
use App\Filament\Resources\ProviderConnectionResource\Pages;
|
||||||
use App\Jobs\ProviderComplianceSnapshotJob;
|
use App\Jobs\ProviderComplianceSnapshotJob;
|
||||||
use App\Jobs\ProviderConnectionHealthCheckJob;
|
use App\Jobs\ProviderConnectionHealthCheckJob;
|
||||||
@ -13,6 +14,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -32,6 +34,8 @@
|
|||||||
|
|
||||||
class ProviderConnectionResource extends Resource
|
class ProviderConnectionResource extends Resource
|
||||||
{
|
{
|
||||||
|
use ScopesGlobalSearchToTenant;
|
||||||
|
|
||||||
protected static bool $isScopedToTenant = false;
|
protected static bool $isScopedToTenant = false;
|
||||||
|
|
||||||
protected static ?string $model = ProviderConnection::class;
|
protected static ?string $model = ProviderConnection::class;
|
||||||
@ -51,17 +55,17 @@ public static function form(Schema $schema): Schema
|
|||||||
TextInput::make('display_name')
|
TextInput::make('display_name')
|
||||||
->label('Display name')
|
->label('Display name')
|
||||||
->required()
|
->required()
|
||||||
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
|
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||||
->maxLength(255),
|
->maxLength(255),
|
||||||
TextInput::make('entra_tenant_id')
|
TextInput::make('entra_tenant_id')
|
||||||
->label('Entra tenant ID')
|
->label('Entra tenant ID')
|
||||||
->required()
|
->required()
|
||||||
->maxLength(255)
|
->maxLength(255)
|
||||||
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
|
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||||
->rules(['uuid']),
|
->rules(['uuid']),
|
||||||
Toggle::make('is_default')
|
Toggle::make('is_default')
|
||||||
->label('Default connection')
|
->label('Default connection')
|
||||||
->disabled(fn (): bool => ! Gate::allows('provider.manage', Tenant::current()))
|
->disabled(fn (): bool => ! Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||||
->helperText('Exactly one default connection is required per tenant/provider.'),
|
->helperText('Exactly one default connection is required per tenant/provider.'),
|
||||||
TextInput::make('status')
|
TextInput::make('status')
|
||||||
->label('Status')
|
->label('Status')
|
||||||
@ -148,15 +152,49 @@ public static function table(Table $table): Table
|
|||||||
->label('Check connection')
|
->label('Check connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -220,15 +258,49 @@ public static function table(Table $table): Table
|
|||||||
->label('Inventory sync')
|
->label('Inventory sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -292,15 +364,49 @@ public static function table(Table $table): Table
|
|||||||
->label('Compliance snapshot')
|
->label('Compliance snapshot')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.run', Tenant::current())
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->canAccessTenant($tenant) && $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -364,13 +470,13 @@ public static function table(Table $table): Table
|
|||||||
->label('Set as default')
|
->label('Set as default')
|
||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||||
&& $record->status !== 'disabled'
|
&& $record->status !== 'disabled'
|
||||||
&& ! $record->is_default)
|
&& ! $record->is_default)
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->makeDefault();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -407,7 +513,7 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => Gate::allows('provider.manage', Tenant::current()))
|
->visible(fn (): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current()))
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
@ -422,7 +528,7 @@ public static function table(Table $table): Table
|
|||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -462,12 +568,12 @@ public static function table(Table $table): Table
|
|||||||
->label('Enable connection')
|
->label('Enable connection')
|
||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||||
&& $record->status === 'disabled')
|
&& $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -527,12 +633,12 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => Gate::allows('provider.manage', Tenant::current())
|
->visible(fn (ProviderConnection $record): bool => Gate::allows(Capabilities::PROVIDER_MANAGE, Tenant::current())
|
||||||
&& $record->status !== 'disabled')
|
&& $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Providers\CredentialManager;
|
use App\Services\Providers\CredentialManager;
|
||||||
use App\Services\Providers\ProviderOperationStartGate;
|
use App\Services\Providers\ProviderOperationStartGate;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
@ -119,7 +120,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-eye')
|
->icon('heroicon-o-eye')
|
||||||
->color('gray')
|
->color('gray')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows('provider.view', $tenant)
|
&& Gate::allows(Capabilities::PROVIDER_VIEW, $tenant)
|
||||||
&& OperationRun::query()
|
&& OperationRun::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('type', 'provider.connection.check')
|
->where('type', 'provider.connection.check')
|
||||||
@ -150,16 +151,45 @@ protected function getHeaderActions(): array
|
|||||||
->label('Check connection')
|
->label('Check connection')
|
||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& Gate::allows('provider.run', $tenant)
|
$tenant = Tenant::current();
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -224,7 +254,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-key')
|
->icon('heroicon-o-key')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
->modalDescription('Client secret is stored encrypted and will never be shown again.')
|
||||||
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows('provider.manage', $tenant))
|
->visible(fn (): bool => $tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant))
|
||||||
->form([
|
->form([
|
||||||
TextInput::make('client_id')
|
TextInput::make('client_id')
|
||||||
->label('Client ID')
|
->label('Client ID')
|
||||||
@ -239,7 +269,7 @@ protected function getHeaderActions(): array
|
|||||||
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
->action(function (array $data, ProviderConnection $record, CredentialManager $credentials, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$credentials->upsertClientSecretCredential(
|
$credentials->upsertClientSecretCredential(
|
||||||
connection: $record,
|
connection: $record,
|
||||||
@ -280,7 +310,7 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-star')
|
->icon('heroicon-o-star')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows('provider.manage', $tenant)
|
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||||
&& $record->status !== 'disabled'
|
&& $record->status !== 'disabled'
|
||||||
&& ! $record->is_default
|
&& ! $record->is_default
|
||||||
&& ProviderConnection::query()
|
&& ProviderConnection::query()
|
||||||
@ -290,7 +320,7 @@ protected function getHeaderActions(): array
|
|||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->makeDefault();
|
$record->makeDefault();
|
||||||
|
|
||||||
@ -326,16 +356,45 @@ protected function getHeaderActions(): array
|
|||||||
->label('Inventory sync')
|
->label('Inventory sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& Gate::allows('provider.run', $tenant)
|
$tenant = Tenant::current();
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -399,16 +458,45 @@ protected function getHeaderActions(): array
|
|||||||
->label('Compliance snapshot')
|
->label('Compliance snapshot')
|
||||||
->icon('heroicon-o-shield-check')
|
->icon('heroicon-o-shield-check')
|
||||||
->color('info')
|
->color('info')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(function (ProviderConnection $record): bool {
|
||||||
&& Gate::allows('provider.run', $tenant)
|
$tenant = Tenant::current();
|
||||||
&& $record->status !== 'disabled')
|
$user = auth()->user();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& $user instanceof User
|
||||||
|
&& $user->canAccessTenant($tenant)
|
||||||
|
&& $record->status !== 'disabled';
|
||||||
|
})
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to run provider operations.';
|
||||||
|
})
|
||||||
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
->action(function (ProviderConnection $record, ProviderOperationStartGate $gate): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.run', $tenant), 403);
|
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant, 404);
|
||||||
abort_unless($user instanceof User, 403);
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($tenant), 404);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::PROVIDER_RUN, $tenant), 403);
|
||||||
$initiator = $user;
|
$initiator = $user;
|
||||||
|
|
||||||
$result = $gate->start(
|
$result = $gate->start(
|
||||||
@ -473,12 +561,12 @@ protected function getHeaderActions(): array
|
|||||||
->icon('heroicon-o-play')
|
->icon('heroicon-o-play')
|
||||||
->color('success')
|
->color('success')
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows('provider.manage', $tenant)
|
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||||
&& $record->status === 'disabled')
|
&& $record->status === 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$hadCredentials = $record->credential()->exists();
|
$hadCredentials = $record->credential()->exists();
|
||||||
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
$status = $hadCredentials ? 'connected' : 'needs_consent';
|
||||||
@ -539,12 +627,12 @@ protected function getHeaderActions(): array
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
->visible(fn (ProviderConnection $record): bool => $tenant instanceof Tenant
|
||||||
&& Gate::allows('provider.manage', $tenant)
|
&& Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)
|
||||||
&& $record->status !== 'disabled')
|
&& $record->status !== 'disabled')
|
||||||
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
->action(function (ProviderConnection $record, AuditLogger $auditLogger): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$previousStatus = (string) $record->status;
|
$previousStatus = (string) $record->status;
|
||||||
|
|
||||||
@ -591,7 +679,7 @@ protected function getFormActions(): array
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if ($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant)) {
|
if ($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant)) {
|
||||||
return parent::getFormActions();
|
return parent::getFormActions();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -604,7 +692,7 @@ protected function handleRecordUpdate(Model $record, array $data): Model
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
abort_unless($tenant instanceof Tenant && Gate::allows('provider.manage', $tenant), 403);
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::PROVIDER_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
return parent::handleRecordUpdate($record, $data);
|
return parent::handleRecordUpdate($record, $data);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,11 @@ class ListProviderConnections extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->disabled(fn (): bool => ! \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current()))
|
||||||
|
->tooltip(fn (): ?string => \Illuminate\Support\Facades\Gate::allows(\App\Support\Auth\Capabilities::PROVIDER_MANAGE, \App\Models\Tenant::current())
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to create provider connections.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -49,6 +50,7 @@
|
|||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use UnitEnum;
|
use UnitEnum;
|
||||||
@ -61,6 +63,12 @@ class RestoreRunResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
protected static string|UnitEnum|null $navigationGroup = 'Backups & Restore';
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return ($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
@ -753,12 +761,18 @@ public static function table(Table $table): Table
|
|||||||
&& $backupSet !== null
|
&& $backupSet !== null
|
||||||
&& ! $backupSet->trashed();
|
&& ! $backupSet->trashed();
|
||||||
})
|
})
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (
|
->action(function (
|
||||||
RestoreRun $record,
|
RestoreRun $record,
|
||||||
RestoreService $restoreService,
|
RestoreService $restoreService,
|
||||||
\App\Services\Intune\AuditLogger $auditLogger,
|
\App\Services\Intune\AuditLogger $auditLogger,
|
||||||
HasTable $livewire
|
HasTable $livewire
|
||||||
) {
|
) {
|
||||||
|
$currentTenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($currentTenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $currentTenant), 403);
|
||||||
|
|
||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
@ -924,8 +938,14 @@ public static function table(Table $table): Table
|
|||||||
->color('success')
|
->color('success')
|
||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record) => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$record->restore();
|
$record->restore();
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
@ -949,8 +969,14 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record) => ! $record->trashed())
|
->visible(fn (RestoreRun $record): bool => ! $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
if (! $record->isDeletable()) {
|
if (! $record->isDeletable()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run cannot be archived')
|
->title('Restore run cannot be archived')
|
||||||
@ -984,8 +1010,14 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (RestoreRun $record) => $record->trashed())
|
->visible(fn (RestoreRun $record): bool => $record->trashed())
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||||
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
->action(function (RestoreRun $record, \App\Services\Intune\AuditLogger $auditLogger) {
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||||
|
|
||||||
if ($record->tenant) {
|
if ($record->tenant) {
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record->tenant,
|
tenant: $record->tenant,
|
||||||
@ -1013,6 +1045,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1046,6 +1080,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1092,6 +1128,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-arrow-uturn-left')
|
->icon('heroicon-o-arrow-uturn-left')
|
||||||
->color('success')
|
->color('success')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_MANAGE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1112,6 +1150,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1169,6 +1209,8 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->disabled(fn (): bool => ! (($tenant = Tenant::current()) instanceof Tenant
|
||||||
|
&& Gate::allows(Capabilities::TENANT_DELETE, $tenant)))
|
||||||
->hidden(function (HasTable $livewire): bool {
|
->hidden(function (HasTable $livewire): bool {
|
||||||
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
$trashedFilterState = $livewire->getTableFilterState(TrashedFilter::class) ?? [];
|
||||||
$value = $trashedFilterState['value'] ?? null;
|
$value = $trashedFilterState['value'] ?? null;
|
||||||
@ -1198,6 +1240,8 @@ public static function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abort_unless(Gate::allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||||
|
|
||||||
$initiator = $user instanceof User ? $user : null;
|
$initiator = $user instanceof User ? $user : null;
|
||||||
|
|
||||||
/** @var BulkSelectionIdentity $selection */
|
/** @var BulkSelectionIdentity $selection */
|
||||||
@ -1450,6 +1494,8 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
/** @var Tenant $tenant */
|
/** @var Tenant $tenant */
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
|
||||||
/** @var BackupSet $backupSet */
|
/** @var BackupSet $backupSet */
|
||||||
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
$backupSet = BackupSet::findOrFail($data['backup_set_id']);
|
||||||
|
|
||||||
|
|||||||
@ -5,10 +5,12 @@
|
|||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Livewire\Attributes\On;
|
use Livewire\Attributes\On;
|
||||||
|
|
||||||
class CreateRestoreRun extends CreateRecord
|
class CreateRestoreRun extends CreateRecord
|
||||||
@ -17,6 +19,13 @@ class CreateRestoreRun extends CreateRecord
|
|||||||
|
|
||||||
protected static string $resource = RestoreRunResource::class;
|
protected static string $resource = RestoreRunResource::class;
|
||||||
|
|
||||||
|
protected function authorizeAccess(): void
|
||||||
|
{
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && Gate::allows(Capabilities::TENANT_MANAGE, $tenant), 403);
|
||||||
|
}
|
||||||
|
|
||||||
public function getSteps(): array
|
public function getSteps(): array
|
||||||
{
|
{
|
||||||
return RestoreRunResource::getWizardSteps();
|
return RestoreRunResource::getWizardSteps();
|
||||||
|
|||||||
@ -13,7 +13,9 @@ class ListRestoreRuns extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->disabled(fn (): bool => ! RestoreRunResource::canCreate())
|
||||||
|
->tooltip(fn (): ?string => RestoreRunResource::canCreate() ? null : 'You do not have permission to create restore runs.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\RoleCapabilityMap;
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -18,6 +19,7 @@
|
|||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -25,7 +27,6 @@
|
|||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -39,8 +40,10 @@
|
|||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
@ -57,6 +60,84 @@ class TenantResource extends Resource
|
|||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
protected static string|UnitEnum|null $navigationGroup = 'Settings';
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::userCanManageAnyTenant($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDeleteAny(): bool
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return static::userCanDeleteAnyTenant($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function userCanManageAnyTenant(User $user): bool
|
||||||
|
{
|
||||||
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
|
if ($tenantIds->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||||
|
if (Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function userCanDeleteAnyTenant(User $user): bool
|
||||||
|
{
|
||||||
|
$tenantIds = $user->tenants()->withTrashed()->pluck('tenants.id');
|
||||||
|
|
||||||
|
if ($tenantIds->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Tenant::query()->whereIn('id', $tenantIds)->cursor() as $tenant) {
|
||||||
|
if (Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
// ... [Schema Omitted - No Change] ...
|
// ... [Schema Omitted - No Change] ...
|
||||||
@ -188,8 +269,11 @@ public static function table(Table $table): Table
|
|||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\ViewAction::make(),
|
|
||||||
ActionGroup::make([
|
ActionGroup::make([
|
||||||
|
Actions\Action::make('view')
|
||||||
|
->label('View')
|
||||||
|
->icon('heroicon-o-eye')
|
||||||
|
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
|
||||||
Actions\Action::make('syncTenant')
|
Actions\Action::make('syncTenant')
|
||||||
->label('Sync')
|
->label('Sync')
|
||||||
->icon('heroicon-o-arrow-path')
|
->icon('heroicon-o-arrow-path')
|
||||||
@ -206,10 +290,34 @@ public static function table(Table $table): Table
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $user->canSyncTenant($record);
|
return $user->canAccessTenant($record);
|
||||||
|
})
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record);
|
||||||
|
})
|
||||||
|
->tooltip(function (Tenant $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to sync this tenant.';
|
||||||
})
|
})
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
|
||||||
// Phase 3: Canonical Operation Run Start
|
$user = auth()->user();
|
||||||
|
abort_unless($user instanceof User, 403);
|
||||||
|
abort_unless($user->canAccessTenant($record), 404);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $record), 403);
|
||||||
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
|
|
||||||
@ -224,6 +332,7 @@ public static function table(Table $table): Table
|
|||||||
'scope' => 'full',
|
'scope' => 'full',
|
||||||
'types' => $typeNames,
|
'types' => $typeNames,
|
||||||
];
|
];
|
||||||
|
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->ensureRun(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
type: 'policy.sync',
|
type: 'policy.sync',
|
||||||
@ -288,12 +397,40 @@ public static function table(Table $table): Table
|
|||||||
->color('primary')
|
->color('primary')
|
||||||
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
|
||||||
->visible(fn (Tenant $record) => $record->isActive()),
|
->visible(fn (Tenant $record) => $record->isActive()),
|
||||||
Actions\EditAction::make(),
|
Actions\Action::make('edit')
|
||||||
Actions\RestoreAction::make()
|
->label('Edit')
|
||||||
|
->icon('heroicon-o-pencil-square')
|
||||||
|
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
|
||||||
|
->disabled(fn (Tenant $record): bool => ! static::canEdit($record))
|
||||||
|
->tooltip(fn (Tenant $record): ?string => static::canEdit($record) ? null : 'You do not have permission to edit this tenant.'),
|
||||||
|
Actions\Action::make('restore')
|
||||||
->label('Restore')
|
->label('Restore')
|
||||||
->color('success')
|
->color('success')
|
||||||
->successNotificationTitle('Tenant reactivated')
|
->successNotificationTitle('Tenant reactivated')
|
||||||
->after(function (Tenant $record, AuditLogger $auditLogger) {
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->trashed())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||||
|
})
|
||||||
|
->action(function (Tenant $record, AuditLogger $auditLogger): void {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$record->restore();
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $record,
|
tenant: $record,
|
||||||
action: 'tenant.restored',
|
action: 'tenant.restored',
|
||||||
@ -303,34 +440,31 @@ public static function table(Table $table): Table
|
|||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('makeCurrent')
|
|
||||||
->label('Make current')
|
|
||||||
->color('success')
|
|
||||||
->icon('heroicon-o-check-circle')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->visible(fn (Tenant $record) => $record->isActive() && ! $record->is_current)
|
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
|
||||||
$record->makeCurrent();
|
|
||||||
|
|
||||||
$auditLogger->log(
|
|
||||||
tenant: $record,
|
|
||||||
action: 'tenant.current_set',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $record->id,
|
|
||||||
status: 'success',
|
|
||||||
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
|
|
||||||
);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->title('Current tenant updated')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
}),
|
|
||||||
Actions\Action::make('admin_consent')
|
Actions\Action::make('admin_consent')
|
||||||
->label('Admin consent')
|
->label('Admin consent')
|
||||||
->icon('heroicon-o-clipboard-document')
|
->icon('heroicon-o-clipboard-document')
|
||||||
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
->url(fn (Tenant $record) => static::adminConsentUrl($record))
|
||||||
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||||
|
})
|
||||||
|
->tooltip(function (Tenant $record): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to manage tenant consent.';
|
||||||
|
})
|
||||||
->openUrlInNewTab(),
|
->openUrlInNewTab(),
|
||||||
Actions\Action::make('open_in_entra')
|
Actions\Action::make('open_in_entra')
|
||||||
->label('Open in Entra')
|
->label('Open in Entra')
|
||||||
@ -343,6 +477,16 @@ public static function table(Table $table): Table
|
|||||||
->icon('heroicon-o-check-badge')
|
->icon('heroicon-o-check-badge')
|
||||||
->color('primary')
|
->color('primary')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||||
|
})
|
||||||
->action(function (
|
->action(function (
|
||||||
Tenant $record,
|
Tenant $record,
|
||||||
TenantConfigService $configService,
|
TenantConfigService $configService,
|
||||||
@ -350,6 +494,16 @@ public static function table(Table $table): Table
|
|||||||
RbacHealthService $rbacHealthService,
|
RbacHealthService $rbacHealthService,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
|
||||||
}),
|
}),
|
||||||
static::rbacAction(),
|
static::rbacAction(),
|
||||||
@ -358,8 +512,27 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-archive-box-x-mark')
|
->icon('heroicon-o-archive-box-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (Tenant $record) => ! $record->trashed())
|
->visible(fn (Tenant $record): bool => ! $record->trashed())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||||
|
})
|
||||||
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
->action(function (Tenant $record, AuditLogger $auditLogger) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$record->delete();
|
$record->delete();
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -382,12 +555,35 @@ public static function table(Table $table): Table
|
|||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-trash')
|
->icon('heroicon-o-trash')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->visible(fn (?Tenant $record) => $record?->trashed())
|
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
|
||||||
|
->disabled(function (?Tenant $record): bool {
|
||||||
|
if (! $record instanceof Tenant) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record);
|
||||||
|
})
|
||||||
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
->action(function (?Tenant $record, AuditLogger $auditLogger) {
|
||||||
if ($record === null) {
|
if ($record === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$tenant = Tenant::withTrashed()->find($record->id);
|
$tenant = Tenant::withTrashed()->find($record->id);
|
||||||
|
|
||||||
if (! $tenant?->trashed()) {
|
if (! $tenant?->trashed()) {
|
||||||
@ -415,7 +611,7 @@ public static function table(Table $table): Table
|
|||||||
->success()
|
->success()
|
||||||
->send();
|
->send();
|
||||||
}),
|
}),
|
||||||
])->icon('heroicon-o-ellipsis-vertical'),
|
]),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Actions\BulkAction::make('syncSelected')
|
Actions\BulkAction::make('syncSelected')
|
||||||
@ -431,11 +627,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenants()
|
return $user->tenants()
|
||||||
->whereIn('role', [
|
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||||
TenantRole::Owner->value,
|
|
||||||
TenantRole::Manager->value,
|
|
||||||
TenantRole::Operator->value,
|
|
||||||
])
|
|
||||||
->exists();
|
->exists();
|
||||||
})
|
})
|
||||||
->authorize(function (): bool {
|
->authorize(function (): bool {
|
||||||
@ -446,11 +638,7 @@ public static function table(Table $table): Table
|
|||||||
}
|
}
|
||||||
|
|
||||||
return $user->tenants()
|
return $user->tenants()
|
||||||
->whereIn('role', [
|
->whereIn('role', RoleCapabilityMap::rolesWithCapability(Capabilities::TENANT_SYNC))
|
||||||
TenantRole::Owner->value,
|
|
||||||
TenantRole::Manager->value,
|
|
||||||
TenantRole::Operator->value,
|
|
||||||
])
|
|
||||||
->exists();
|
->exists();
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
->action(function (Collection $records, AuditLogger $auditLogger): void {
|
||||||
@ -462,7 +650,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$eligible = $records
|
$eligible = $records
|
||||||
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
|
||||||
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant));
|
->filter(fn (Tenant $tenant) => Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant));
|
||||||
|
|
||||||
if ($eligible->isEmpty()) {
|
if ($eligible->isEmpty()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -697,7 +885,16 @@ public static function rbacAction(): Actions\Action
|
|||||||
->noSearchResultsMessage('No security groups found')
|
->noSearchResultsMessage('No security groups found')
|
||||||
->loadingMessage('Searching groups...'),
|
->loadingMessage('Searching groups...'),
|
||||||
])
|
])
|
||||||
->visible(fn (Tenant $record) => $record->isActive())
|
->visible(fn (Tenant $record): bool => $record->isActive())
|
||||||
|
->disabled(function (Tenant $record): bool {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record);
|
||||||
|
})
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->action(function (
|
->action(function (
|
||||||
array $data,
|
array $data,
|
||||||
@ -705,6 +902,16 @@ public static function rbacAction(): Actions\Action
|
|||||||
RbacOnboardingService $service,
|
RbacOnboardingService $service,
|
||||||
AuditLogger $auditLogger
|
AuditLogger $auditLogger
|
||||||
) {
|
) {
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_MANAGE, $record)) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
|
||||||
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use Filament\Resources\Pages\CreateRecord;
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
class CreateTenant extends CreateRecord
|
class CreateTenant extends CreateRecord
|
||||||
@ -20,7 +19,7 @@ protected function afterCreate(): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$this->record->getKey() => ['role' => TenantRole::Owner->value],
|
$this->record->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,8 +3,12 @@
|
|||||||
namespace App\Filament\Resources\TenantResource\Pages;
|
namespace App\Filament\Resources\TenantResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource;
|
use App\Filament\Resources\TenantResource;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class EditTenant extends EditRecord
|
class EditTenant extends EditRecord
|
||||||
{
|
{
|
||||||
@ -14,7 +18,42 @@ protected function getHeaderActions(): array
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\ViewAction::make(),
|
Actions\ViewAction::make(),
|
||||||
Actions\DeleteAction::make(),
|
Actions\Action::make('archive')
|
||||||
|
->label('Archive')
|
||||||
|
->color('danger')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->visible(fn (): bool => $this->record instanceof Tenant && ! $this->record->trashed())
|
||||||
|
->disabled(function (): bool {
|
||||||
|
$tenant = $this->record;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->denies(Capabilities::TENANT_DELETE, $tenant);
|
||||||
|
})
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$tenant = $this->record;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant)
|
||||||
|
? null
|
||||||
|
: 'You do not have permission to archive tenants.';
|
||||||
|
})
|
||||||
|
->action(function (): void {
|
||||||
|
$tenant = $this->record;
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
abort_unless($tenant instanceof Tenant && $user instanceof User, 403);
|
||||||
|
abort_unless(Gate::forUser($user)->allows(Capabilities::TENANT_DELETE, $tenant), 403);
|
||||||
|
|
||||||
|
$tenant->delete();
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,9 @@ class ListTenants extends ListRecords
|
|||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
Actions\CreateAction::make(),
|
Actions\CreateAction::make()
|
||||||
|
->disabled(fn (): bool => ! TenantResource::canCreate())
|
||||||
|
->tooltip(fn (): ?string => TenantResource::canCreate() ? null : 'You do not have permission to register tenants.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\TenantRole;
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -26,10 +26,10 @@ public function table(Table $table): Table
|
|||||||
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
->modifyQueryUsing(fn (Builder $query) => $query->with('user'))
|
||||||
->columns([
|
->columns([
|
||||||
Tables\Columns\TextColumn::make('user.name')
|
Tables\Columns\TextColumn::make('user.name')
|
||||||
->label('User')
|
->label(__('User'))
|
||||||
->searchable(),
|
->searchable(),
|
||||||
Tables\Columns\TextColumn::make('user.email')
|
Tables\Columns\TextColumn::make('user.email')
|
||||||
->label('Email')
|
->label(__('Email'))
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('role')
|
Tables\Columns\TextColumn::make('role')
|
||||||
->badge()
|
->badge()
|
||||||
@ -41,7 +41,7 @@ public function table(Table $table): Table
|
|||||||
])
|
])
|
||||||
->headerActions([
|
->headerActions([
|
||||||
Actions\Action::make('add_member')
|
Actions\Action::make('add_member')
|
||||||
->label('Add member')
|
->label(__('Add member'))
|
||||||
->icon('heroicon-o-plus')
|
->icon('heroicon-o-plus')
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
@ -50,22 +50,22 @@ public function table(Table $table): Table
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::allows('tenant_membership.manage', $tenant);
|
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||||
})
|
})
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Select::make('user_id')
|
Forms\Components\Select::make('user_id')
|
||||||
->label('User')
|
->label(__('User'))
|
||||||
->required()
|
->required()
|
||||||
->searchable()
|
->searchable()
|
||||||
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
->options(fn () => User::query()->orderBy('name')->pluck('name', 'id')->all()),
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label('Role')
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
->options([
|
->options([
|
||||||
TenantRole::Owner->value => 'Owner',
|
'owner' => __('Owner'),
|
||||||
TenantRole::Manager->value => 'Manager',
|
'manager' => __('Manager'),
|
||||||
TenantRole::Operator->value => 'Operator',
|
'operator' => __('Operator'),
|
||||||
TenantRole::Readonly->value => 'Readonly',
|
'readonly' => __('Readonly'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (array $data, TenantMembershipManager $manager): void {
|
->action(function (array $data, TenantMembershipManager $manager): void {
|
||||||
@ -80,13 +80,13 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$member = User::query()->find((int) $data['user_id']);
|
$member = User::query()->find((int) $data['user_id']);
|
||||||
if (! $member) {
|
if (! $member) {
|
||||||
Notification::make()->title('User not found')->danger()->send();
|
Notification::make()->title(__('User not found'))->danger()->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -96,12 +96,12 @@ public function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
member: $member,
|
member: $member,
|
||||||
role: TenantRole::from((string) $data['role']),
|
role: (string) $data['role'],
|
||||||
source: 'manual',
|
source: 'manual',
|
||||||
);
|
);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Failed to add member')
|
->title(__('Failed to add member'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -109,14 +109,15 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()->title('Member added')->success()->send();
|
Notification::make()->title(__('Member added'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
->actions([
|
->actions([
|
||||||
Actions\Action::make('change_role')
|
Actions\Action::make('change_role')
|
||||||
->label('Change role')
|
->label(__('Change role'))
|
||||||
->icon('heroicon-o-pencil')
|
->icon('heroicon-o-pencil')
|
||||||
|
->requiresConfirmation()
|
||||||
->visible(function (): bool {
|
->visible(function (): bool {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
|
|
||||||
@ -124,17 +125,17 @@ public function table(Table $table): Table
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::allows('tenant_membership.manage', $tenant);
|
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||||
})
|
})
|
||||||
->form([
|
->form([
|
||||||
Forms\Components\Select::make('role')
|
Forms\Components\Select::make('role')
|
||||||
->label('Role')
|
->label(__('Role'))
|
||||||
->required()
|
->required()
|
||||||
->options([
|
->options([
|
||||||
TenantRole::Owner->value => 'Owner',
|
'owner' => __('Owner'),
|
||||||
TenantRole::Manager->value => 'Manager',
|
'manager' => __('Manager'),
|
||||||
TenantRole::Operator->value => 'Operator',
|
'operator' => __('Operator'),
|
||||||
TenantRole::Readonly->value => 'Readonly',
|
'readonly' => __('Readonly'),
|
||||||
]),
|
]),
|
||||||
])
|
])
|
||||||
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
->action(function (TenantMembership $record, array $data, TenantMembershipManager $manager): void {
|
||||||
@ -149,7 +150,7 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,11 +159,11 @@ public function table(Table $table): Table
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
membership: $record,
|
membership: $record,
|
||||||
newRole: TenantRole::from((string) $data['role']),
|
newRole: (string) $data['role'],
|
||||||
);
|
);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Failed to change role')
|
->title(__('Failed to change role'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -170,11 +171,11 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()->title('Role updated')->success()->send();
|
Notification::make()->title(__('Role updated'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
Actions\Action::make('remove')
|
Actions\Action::make('remove')
|
||||||
->label('Remove')
|
->label(__('Remove'))
|
||||||
->color('danger')
|
->color('danger')
|
||||||
->icon('heroicon-o-x-mark')
|
->icon('heroicon-o-x-mark')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
@ -185,7 +186,7 @@ public function table(Table $table): Table
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Gate::allows('tenant_membership.manage', $tenant);
|
return Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant);
|
||||||
})
|
})
|
||||||
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
->action(function (TenantMembership $record, TenantMembershipManager $manager): void {
|
||||||
$tenant = $this->getOwnerRecord();
|
$tenant = $this->getOwnerRecord();
|
||||||
@ -199,7 +200,7 @@ public function table(Table $table): Table
|
|||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! Gate::allows('tenant_membership.manage', $tenant)) {
|
if (! Gate::allows(Capabilities::TENANT_MEMBERSHIP_MANAGE, $tenant)) {
|
||||||
abort(403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -207,7 +208,7 @@ public function table(Table $table): Table
|
|||||||
$manager->removeMember($tenant, $actor, $record);
|
$manager->removeMember($tenant, $actor, $record);
|
||||||
} catch (\Throwable $throwable) {
|
} catch (\Throwable $throwable) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Failed to remove member')
|
->title(__('Failed to remove member'))
|
||||||
->body($throwable->getMessage())
|
->body($throwable->getMessage())
|
||||||
->danger()
|
->danger()
|
||||||
->send();
|
->send();
|
||||||
@ -215,7 +216,7 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Notification::make()->title('Member removed')->success()->send();
|
Notification::make()->title(__('Member removed'))->success()->send();
|
||||||
$this->resetTable();
|
$this->resetTable();
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -8,11 +8,13 @@
|
|||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@ -94,7 +96,7 @@ public function handle(
|
|||||||
|
|
||||||
$user = User::query()->whereKey($this->userId)->first();
|
$user = User::query()->whereKey($this->userId)->first();
|
||||||
|
|
||||||
if (! $user instanceof User || ! $user->canSyncTenant($tenant)) {
|
if (! $user instanceof User || ! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
$runs->incrementSummaryCounts($this->operationRun, [
|
$runs->incrementSummaryCounts($this->operationRun, [
|
||||||
'processed' => 1,
|
'processed' => 1,
|
||||||
'skipped' => 1,
|
'skipped' => 1,
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Operations\BulkSelectionIdentity;
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Badges\BadgeDomain;
|
use App\Support\Badges\BadgeDomain;
|
||||||
use App\Support\Badges\BadgeRenderer;
|
use App\Support\Badges\BadgeRenderer;
|
||||||
use App\Support\Badges\TagBadgeDomain;
|
use App\Support\Badges\TagBadgeDomain;
|
||||||
@ -26,6 +27,7 @@
|
|||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class BackupSetPolicyPickerTable extends TableComponent
|
class BackupSetPolicyPickerTable extends TableComponent
|
||||||
@ -201,7 +203,11 @@ public function table(Table $table): Table
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,7 +254,7 @@ public function table(Table $table): Table
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Not allowed')
|
->title('Not allowed')
|
||||||
->danger()
|
->danger()
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use App\Support\TenantRole;
|
use App\Support\Auth\Capabilities;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Models\Contracts\HasDefaultTenant;
|
use Filament\Models\Contracts\HasDefaultTenant;
|
||||||
use Filament\Models\Contracts\HasTenants;
|
use Filament\Models\Contracts\HasTenants;
|
||||||
@ -15,6 +15,7 @@
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants
|
class User extends Authenticatable implements FilamentUser, HasDefaultTenant, HasTenants
|
||||||
@ -97,7 +98,7 @@ private function tenantPreferencesTableExists(): bool
|
|||||||
return $exists ??= Schema::hasTable('user_tenant_preferences');
|
return $exists ??= Schema::hasTable('user_tenant_preferences');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tenantRole(Tenant $tenant): ?TenantRole
|
public function tenantRoleValue(Tenant $tenant): ?string
|
||||||
{
|
{
|
||||||
if (! $this->tenantPivotTableExists()) {
|
if (! $this->tenantPivotTableExists()) {
|
||||||
return null;
|
return null;
|
||||||
@ -111,14 +112,12 @@ public function tenantRole(Tenant $tenant): ?TenantRole
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TenantRole::tryFrom($role);
|
return $role;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canSyncTenant(Tenant $tenant): bool
|
public function allowsTenantSync(Tenant $tenant): bool
|
||||||
{
|
{
|
||||||
$role = $this->tenantRole($tenant);
|
return Gate::forUser($this)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
|
|
||||||
return $role?->canSync() ?? false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function canAccessTenant(Model $tenant): bool
|
public function canAccessTenant(Model $tenant): bool
|
||||||
|
|||||||
@ -5,42 +5,59 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\TenantRole;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class BackupSchedulePolicy
|
class BackupSchedulePolicy
|
||||||
{
|
{
|
||||||
use HandlesAuthorization;
|
use HandlesAuthorization;
|
||||||
|
|
||||||
protected function resolveRole(User $user): ?TenantRole
|
protected function isTenantMember(User $user, ?Tenant $tenant = null): bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant ??= Tenant::current();
|
||||||
|
|
||||||
return $user->tenantRole($tenant);
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_VIEW, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function viewAny(User $user): bool
|
public function viewAny(User $user): bool
|
||||||
{
|
{
|
||||||
return $this->resolveRole($user) !== null;
|
return $this->isTenantMember($user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, BackupSchedule $schedule): bool
|
public function view(User $user, BackupSchedule $schedule): bool
|
||||||
{
|
{
|
||||||
return $this->resolveRole($user) !== null;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
if (! $this->isTenantMember($user, $tenant)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $schedule->tenant_id === (int) $tenant->getKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function create(User $user): bool
|
public function create(User $user): bool
|
||||||
{
|
{
|
||||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(User $user, BackupSchedule $schedule): bool
|
public function update(User $user, BackupSchedule $schedule): bool
|
||||||
{
|
{
|
||||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(User $user, BackupSchedule $schedule): bool
|
public function delete(User $user, BackupSchedule $schedule): bool
|
||||||
{
|
{
|
||||||
return $this->resolveRole($user)?->canManageBackupSchedules() ?? false;
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
|
return $tenant instanceof Tenant
|
||||||
|
&& Gate::forUser($user)->allows(Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE, $tenant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,9 @@
|
|||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Support\TenantRole;
|
use App\Support\Auth\Capabilities;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
class FindingPolicy
|
class FindingPolicy
|
||||||
{
|
{
|
||||||
@ -42,7 +43,7 @@ public function update(User $user, Finding $finding): bool
|
|||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
if (! $tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,13 +55,6 @@ public function update(User $user, Finding $finding): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$role = $user->tenantRole($tenant);
|
return Gate::forUser($user)->allows(Capabilities::TENANT_SYNC, $tenant);
|
||||||
|
|
||||||
return match ($role) {
|
|
||||||
TenantRole::Owner,
|
|
||||||
TenantRole::Manager,
|
|
||||||
TenantRole::Operator => true,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,20 +31,7 @@ public function boot(): void
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach ([
|
foreach (Capabilities::all() as $capability) {
|
||||||
Capabilities::PROVIDER_VIEW,
|
|
||||||
Capabilities::PROVIDER_MANAGE,
|
|
||||||
Capabilities::PROVIDER_RUN,
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
|
||||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
|
||||||
Capabilities::AUDIT_VIEW,
|
|
||||||
Capabilities::TENANT_VIEW,
|
|
||||||
Capabilities::TENANT_MANAGE,
|
|
||||||
Capabilities::TENANT_DELETE,
|
|
||||||
Capabilities::TENANT_SYNC,
|
|
||||||
] as $capability) {
|
|
||||||
$defineTenantCapability($capability);
|
$defineTenantCapability($capability);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,9 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\TenantRole;
|
use App\Support\TenantRole;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Capability Resolver
|
* Capability Resolver
|
||||||
@ -17,6 +19,8 @@ class CapabilityResolver
|
|||||||
{
|
{
|
||||||
private array $resolvedMemberships = [];
|
private array $resolvedMemberships = [];
|
||||||
|
|
||||||
|
private array $loggedDenials = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the user's role for a tenant
|
* Get the user's role for a tenant
|
||||||
*/
|
*/
|
||||||
@ -36,13 +40,42 @@ public function getRole(User $user, Tenant $tenant): ?TenantRole
|
|||||||
*/
|
*/
|
||||||
public function can(User $user, Tenant $tenant, string $capability): bool
|
public function can(User $user, Tenant $tenant, string $capability): bool
|
||||||
{
|
{
|
||||||
|
if (! Capabilities::isKnown($capability)) {
|
||||||
|
throw new \InvalidArgumentException("Unknown capability: {$capability}");
|
||||||
|
}
|
||||||
|
|
||||||
$role = $this->getRole($user, $tenant);
|
$role = $this->getRole($user, $tenant);
|
||||||
|
|
||||||
if ($role === null) {
|
if ($role === null) {
|
||||||
|
$this->logDenial($user, $tenant, $capability);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return RoleCapabilityMap::hasCapability($role, $capability);
|
$allowed = RoleCapabilityMap::hasCapability($role, $capability);
|
||||||
|
|
||||||
|
if (! $allowed) {
|
||||||
|
$this->logDenial($user, $tenant, $capability);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logDenial(User $user, Tenant $tenant, string $capability): void
|
||||||
|
{
|
||||||
|
$key = implode(':', [(string) $user->getKey(), (string) $tenant->getKey(), $capability]);
|
||||||
|
|
||||||
|
if (isset($this->loggedDenials[$key])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->loggedDenials[$key] = true;
|
||||||
|
|
||||||
|
Log::warning('rbac.denied', [
|
||||||
|
'capability' => $capability,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'actor_user_id' => (int) $user->getKey(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -26,6 +26,9 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
||||||
|
|
||||||
|
Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE,
|
||||||
|
Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||||
|
|
||||||
Capabilities::PROVIDER_VIEW,
|
Capabilities::PROVIDER_VIEW,
|
||||||
Capabilities::PROVIDER_MANAGE,
|
Capabilities::PROVIDER_MANAGE,
|
||||||
Capabilities::PROVIDER_RUN,
|
Capabilities::PROVIDER_RUN,
|
||||||
@ -39,10 +42,11 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_SYNC,
|
Capabilities::TENANT_SYNC,
|
||||||
|
|
||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_MEMBERSHIP_MANAGE,
|
|
||||||
|
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_MANAGE,
|
|
||||||
|
Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE,
|
||||||
|
Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||||
|
|
||||||
Capabilities::PROVIDER_VIEW,
|
Capabilities::PROVIDER_VIEW,
|
||||||
Capabilities::PROVIDER_MANAGE,
|
Capabilities::PROVIDER_MANAGE,
|
||||||
@ -58,6 +62,8 @@ class RoleCapabilityMap
|
|||||||
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
Capabilities::TENANT_MEMBERSHIP_VIEW,
|
||||||
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
Capabilities::TENANT_ROLE_MAPPING_VIEW,
|
||||||
|
|
||||||
|
Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||||
|
|
||||||
Capabilities::PROVIDER_VIEW,
|
Capabilities::PROVIDER_VIEW,
|
||||||
Capabilities::PROVIDER_RUN,
|
Capabilities::PROVIDER_RUN,
|
||||||
|
|
||||||
@ -88,6 +94,24 @@ public static function getCapabilities(TenantRole|string $role): array
|
|||||||
return self::$roleCapabilities[$roleValue] ?? [];
|
return self::$roleCapabilities[$roleValue] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all role values that grant a given capability.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function rolesWithCapability(string $capability): array
|
||||||
|
{
|
||||||
|
$roles = [];
|
||||||
|
|
||||||
|
foreach (self::$roleCapabilities as $role => $capabilities) {
|
||||||
|
if (in_array($capability, $capabilities, true)) {
|
||||||
|
$roles[] = $role;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $roles;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a role has a specific capability
|
* Check if a role has a specific capability
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\TenantRole;
|
use App\Support\Audit\AuditActionId;
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@ -18,10 +18,12 @@ public function addMember(
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
User $actor,
|
User $actor,
|
||||||
User $member,
|
User $member,
|
||||||
TenantRole $role,
|
string $role,
|
||||||
string $source = 'manual',
|
string $source = 'manual',
|
||||||
?string $sourceRef = null,
|
?string $sourceRef = null,
|
||||||
): TenantMembership {
|
): TenantMembership {
|
||||||
|
$this->assertValidRole($role);
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
|
return DB::transaction(function () use ($tenant, $actor, $member, $role, $source, $sourceRef): TenantMembership {
|
||||||
$existing = TenantMembership::query()
|
$existing = TenantMembership::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
@ -29,9 +31,9 @@ public function addMember(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
if ($existing->role !== $role->value) {
|
if ($existing->role !== $role) {
|
||||||
$existing->forceFill([
|
$existing->forceFill([
|
||||||
'role' => $role->value,
|
'role' => $role,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'source_ref' => $sourceRef,
|
'source_ref' => $sourceRef,
|
||||||
'created_by_user_id' => (int) $actor->getKey(),
|
'created_by_user_id' => (int) $actor->getKey(),
|
||||||
@ -39,12 +41,12 @@ public function addMember(
|
|||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'tenant_membership.role_change',
|
action: AuditActionId::TenantMembershipRoleChange->value,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'member_user_id' => (int) $member->getKey(),
|
'member_user_id' => (int) $member->getKey(),
|
||||||
'from_role' => $existing->getOriginal('role'),
|
'from_role' => $existing->getOriginal('role'),
|
||||||
'to_role' => $role->value,
|
'to_role' => $role,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -63,7 +65,7 @@ public function addMember(
|
|||||||
$membership = TenantMembership::query()->create([
|
$membership = TenantMembership::query()->create([
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
'user_id' => (int) $member->getKey(),
|
'user_id' => (int) $member->getKey(),
|
||||||
'role' => $role->value,
|
'role' => $role,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
'source_ref' => $sourceRef,
|
'source_ref' => $sourceRef,
|
||||||
'created_by_user_id' => (int) $actor->getKey(),
|
'created_by_user_id' => (int) $actor->getKey(),
|
||||||
@ -71,11 +73,11 @@ public function addMember(
|
|||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'tenant_membership.add',
|
action: AuditActionId::TenantMembershipAdd->value,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'member_user_id' => (int) $member->getKey(),
|
'member_user_id' => (int) $member->getKey(),
|
||||||
'role' => $role->value,
|
'role' => $role,
|
||||||
'source' => $source,
|
'source' => $source,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -91,82 +93,132 @@ public function addMember(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, TenantRole $newRole): TenantMembership
|
public function changeRole(Tenant $tenant, User $actor, TenantMembership $membership, string $newRole): TenantMembership
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
|
$this->assertValidRole($newRole);
|
||||||
$membership->refresh();
|
|
||||||
|
|
||||||
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
try {
|
||||||
throw new DomainException('Membership belongs to a different tenant.');
|
return DB::transaction(function () use ($tenant, $actor, $membership, $newRole): TenantMembership {
|
||||||
}
|
$membership->refresh();
|
||||||
|
|
||||||
$oldRole = $membership->role;
|
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
throw new DomainException('Membership belongs to a different tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
if ($oldRole === $newRole->value) {
|
$oldRole = $membership->role;
|
||||||
return $membership;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
|
if ($oldRole === $newRole) {
|
||||||
|
return $membership;
|
||||||
|
}
|
||||||
|
|
||||||
$membership->forceFill([
|
$this->guardLastOwnerDemotion($tenant, $membership, $newRole);
|
||||||
'role' => $newRole->value,
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$membership->forceFill([
|
||||||
tenant: $tenant,
|
'role' => $newRole,
|
||||||
action: 'tenant_membership.role_change',
|
])->save();
|
||||||
context: [
|
|
||||||
'metadata' => [
|
$this->auditLogger->log(
|
||||||
'member_user_id' => (int) $membership->user_id,
|
tenant: $tenant,
|
||||||
'from_role' => $oldRole,
|
action: AuditActionId::TenantMembershipRoleChange->value,
|
||||||
'to_role' => $newRole->value,
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'member_user_id' => (int) $membership->user_id,
|
||||||
|
'from_role' => $oldRole,
|
||||||
|
'to_role' => $newRole,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
actorId: (int) $actor->getKey(),
|
||||||
actorId: (int) $actor->getKey(),
|
actorEmail: $actor->email,
|
||||||
actorEmail: $actor->email,
|
actorName: $actor->name,
|
||||||
actorName: $actor->name,
|
status: 'success',
|
||||||
status: 'success',
|
resourceType: 'tenant',
|
||||||
resourceType: 'tenant',
|
resourceId: (string) $tenant->getKey(),
|
||||||
resourceId: (string) $tenant->getKey(),
|
);
|
||||||
);
|
|
||||||
|
|
||||||
return $membership->refresh();
|
return $membership->refresh();
|
||||||
});
|
});
|
||||||
|
} catch (DomainException $exception) {
|
||||||
|
if ($exception->getMessage() === 'You cannot demote the last remaining owner.') {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'member_user_id' => (int) $membership->user_id,
|
||||||
|
'from_role' => (string) $membership->role,
|
||||||
|
'attempted_to_role' => $newRole,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $actor->getKey(),
|
||||||
|
actorEmail: $actor->email,
|
||||||
|
actorName: $actor->name,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
|
public function removeMember(Tenant $tenant, User $actor, TenantMembership $membership): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($tenant, $actor, $membership): void {
|
try {
|
||||||
$membership->refresh();
|
DB::transaction(function () use ($tenant, $actor, $membership): void {
|
||||||
|
$membership->refresh();
|
||||||
|
|
||||||
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
if ($membership->tenant_id !== (int) $tenant->getKey()) {
|
||||||
throw new DomainException('Membership belongs to a different tenant.');
|
throw new DomainException('Membership belongs to a different tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->guardLastOwnerRemoval($tenant, $membership);
|
||||||
|
|
||||||
|
$memberUserId = (int) $membership->user_id;
|
||||||
|
$oldRole = (string) $membership->role;
|
||||||
|
|
||||||
|
$membership->delete();
|
||||||
|
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActionId::TenantMembershipRemove->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'member_user_id' => $memberUserId,
|
||||||
|
'role' => $oldRole,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $actor->getKey(),
|
||||||
|
actorEmail: $actor->email,
|
||||||
|
actorName: $actor->name,
|
||||||
|
status: 'success',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (DomainException $exception) {
|
||||||
|
if ($exception->getMessage() === 'You cannot remove the last remaining owner.') {
|
||||||
|
$this->auditLogger->log(
|
||||||
|
tenant: $tenant,
|
||||||
|
action: AuditActionId::TenantMembershipLastOwnerBlocked->value,
|
||||||
|
context: [
|
||||||
|
'metadata' => [
|
||||||
|
'member_user_id' => (int) $membership->user_id,
|
||||||
|
'role' => (string) $membership->role,
|
||||||
|
'attempted_action' => 'remove',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
actorId: (int) $actor->getKey(),
|
||||||
|
actorEmail: $actor->email,
|
||||||
|
actorName: $actor->name,
|
||||||
|
status: 'blocked',
|
||||||
|
resourceType: 'tenant',
|
||||||
|
resourceId: (string) $tenant->getKey(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->guardLastOwnerRemoval($tenant, $membership);
|
throw $exception;
|
||||||
|
}
|
||||||
$memberUserId = (int) $membership->user_id;
|
|
||||||
$oldRole = (string) $membership->role;
|
|
||||||
|
|
||||||
$membership->delete();
|
|
||||||
|
|
||||||
$this->auditLogger->log(
|
|
||||||
tenant: $tenant,
|
|
||||||
action: 'tenant_membership.remove',
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'member_user_id' => $memberUserId,
|
|
||||||
'role' => $oldRole,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
actorId: (int) $actor->getKey(),
|
|
||||||
actorEmail: $actor->email,
|
|
||||||
actorName: $actor->name,
|
|
||||||
status: 'success',
|
|
||||||
resourceType: 'tenant',
|
|
||||||
resourceId: (string) $tenant->getKey(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership
|
public function bootstrapRecover(Tenant $tenant, User $actor, User $member): TenantMembership
|
||||||
@ -175,13 +227,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten
|
|||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
member: $member,
|
member: $member,
|
||||||
role: TenantRole::Owner,
|
role: 'owner',
|
||||||
source: 'break_glass',
|
source: 'break_glass',
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'tenant_membership.bootstrap_recover',
|
action: AuditActionId::TenantMembershipBootstrapRecover->value,
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'member_user_id' => (int) $member->getKey(),
|
'member_user_id' => (int) $member->getKey(),
|
||||||
@ -200,13 +252,13 @@ public function bootstrapRecover(Tenant $tenant, User $actor, User $member): Ten
|
|||||||
|
|
||||||
private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
|
private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $membership): void
|
||||||
{
|
{
|
||||||
if ($membership->role !== TenantRole::Owner->value) {
|
if ($membership->role !== 'owner') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$owners = TenantMembership::query()
|
$owners = TenantMembership::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('role', TenantRole::Owner->value)
|
->where('role', 'owner')
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($owners <= 1) {
|
if ($owners <= 1) {
|
||||||
@ -214,23 +266,30 @@ private function guardLastOwnerRemoval(Tenant $tenant, TenantMembership $members
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, TenantRole $newRole): void
|
private function guardLastOwnerDemotion(Tenant $tenant, TenantMembership $membership, string $newRole): void
|
||||||
{
|
{
|
||||||
if ($membership->role !== TenantRole::Owner->value) {
|
if ($membership->role !== 'owner') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($newRole === TenantRole::Owner) {
|
if ($newRole === 'owner') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$owners = TenantMembership::query()
|
$owners = TenantMembership::query()
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
->where('tenant_id', (int) $tenant->getKey())
|
||||||
->where('role', TenantRole::Owner->value)
|
->where('role', 'owner')
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
if ($owners <= 1) {
|
if ($owners <= 1) {
|
||||||
throw new DomainException('You cannot demote the last remaining owner.');
|
throw new DomainException('You cannot demote the last remaining owner.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertValidRole(string $role): void
|
||||||
|
{
|
||||||
|
if (! in_array($role, ['owner', 'manager', 'operator', 'readonly'], true)) {
|
||||||
|
throw new DomainException('Invalid role value.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
use App\Services\Graph\GroupResolver;
|
use App\Services\Graph\GroupResolver;
|
||||||
use App\Services\Graph\ScopeTagResolver;
|
use App\Services\Graph\ScopeTagResolver;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Database\UniqueConstraintViolationException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class VersionService
|
class VersionService
|
||||||
{
|
{
|
||||||
@ -30,23 +33,49 @@ public function captureVersion(
|
|||||||
?array $assignments = null,
|
?array $assignments = null,
|
||||||
?array $scopeTags = null,
|
?array $scopeTags = null,
|
||||||
): PolicyVersion {
|
): PolicyVersion {
|
||||||
$versionNumber = $this->nextVersionNumber($policy);
|
$version = null;
|
||||||
|
$versionNumber = null;
|
||||||
|
|
||||||
$version = PolicyVersion::create([
|
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||||
'tenant_id' => $policy->tenant_id,
|
try {
|
||||||
'policy_id' => $policy->id,
|
[$version, $versionNumber] = DB::transaction(function () use ($policy, $payload, $createdBy, $metadata, $assignments, $scopeTags): array {
|
||||||
'version_number' => $versionNumber,
|
// Serialize version number allocation per policy.
|
||||||
'policy_type' => $policy->policy_type,
|
Policy::query()->whereKey($policy->getKey())->lockForUpdate()->first();
|
||||||
'platform' => $policy->platform,
|
|
||||||
'created_by' => $createdBy,
|
$versionNumber = $this->nextVersionNumber($policy);
|
||||||
'captured_at' => CarbonImmutable::now(),
|
|
||||||
'snapshot' => $payload,
|
$version = PolicyVersion::create([
|
||||||
'metadata' => $metadata,
|
'tenant_id' => $policy->tenant_id,
|
||||||
'assignments' => $assignments,
|
'policy_id' => $policy->id,
|
||||||
'scope_tags' => $scopeTags,
|
'version_number' => $versionNumber,
|
||||||
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
'policy_type' => $policy->policy_type,
|
||||||
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
'platform' => $policy->platform,
|
||||||
]);
|
'created_by' => $createdBy,
|
||||||
|
'captured_at' => CarbonImmutable::now(),
|
||||||
|
'snapshot' => $payload,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'assignments' => $assignments,
|
||||||
|
'scope_tags' => $scopeTags,
|
||||||
|
'assignments_hash' => $assignments ? hash('sha256', json_encode($assignments)) : null,
|
||||||
|
'scope_tags_hash' => $scopeTags ? hash('sha256', json_encode($scopeTags)) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [$version, $versionNumber];
|
||||||
|
}, 3);
|
||||||
|
|
||||||
|
break;
|
||||||
|
} catch (QueryException $e) {
|
||||||
|
if (! $this->isUniqueViolation($e) || $attempt === 3) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50_000 * $attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $version instanceof PolicyVersion || ! is_int($versionNumber)) {
|
||||||
|
throw new \RuntimeException('Failed to capture policy version.');
|
||||||
|
}
|
||||||
|
|
||||||
$this->auditLogger->log(
|
$this->auditLogger->log(
|
||||||
tenant: $policy->tenant,
|
tenant: $policy->tenant,
|
||||||
@ -65,6 +94,23 @@ public function captureVersion(
|
|||||||
return $version;
|
return $version;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isUniqueViolation(QueryException $exception): bool
|
||||||
|
{
|
||||||
|
if ($exception instanceof UniqueConstraintViolationException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sqlState = $exception->getCode();
|
||||||
|
|
||||||
|
if (is_string($sqlState) && in_array($sqlState, ['23505', '23000'], true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$errorInfoState = $exception->errorInfo[0] ?? null;
|
||||||
|
|
||||||
|
return is_string($errorInfoState) && in_array($errorInfoState, ['23505', '23000'], true);
|
||||||
|
}
|
||||||
|
|
||||||
public function captureFromGraph(
|
public function captureFromGraph(
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Policy $policy,
|
Policy $policy,
|
||||||
|
|||||||
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
|
class Capabilities
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array<string>|null
|
||||||
|
*/
|
||||||
|
private static ?array $all = null;
|
||||||
|
|
||||||
// Tenants
|
// Tenants
|
||||||
public const TENANT_VIEW = 'tenant.view';
|
public const TENANT_VIEW = 'tenant.view';
|
||||||
|
|
||||||
@ -29,6 +34,11 @@ class Capabilities
|
|||||||
|
|
||||||
public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage';
|
public const TENANT_ROLE_MAPPING_MANAGE = 'tenant_role_mapping.manage';
|
||||||
|
|
||||||
|
// Backup schedules
|
||||||
|
public const TENANT_BACKUP_SCHEDULES_MANAGE = 'tenant_backup_schedules.manage';
|
||||||
|
|
||||||
|
public const TENANT_BACKUP_SCHEDULES_RUN = 'tenant_backup_schedules.run';
|
||||||
|
|
||||||
// Providers (existing gate names used throughout the app)
|
// Providers (existing gate names used throughout the app)
|
||||||
public const PROVIDER_VIEW = 'provider.view';
|
public const PROVIDER_VIEW = 'provider.view';
|
||||||
|
|
||||||
@ -46,8 +56,17 @@ class Capabilities
|
|||||||
*/
|
*/
|
||||||
public static function all(): array
|
public static function all(): array
|
||||||
{
|
{
|
||||||
|
if (self::$all !== null) {
|
||||||
|
return self::$all;
|
||||||
|
}
|
||||||
|
|
||||||
$reflection = new \ReflectionClass(self::class);
|
$reflection = new \ReflectionClass(self::class);
|
||||||
|
|
||||||
return array_values($reflection->getConstants());
|
return self::$all = array_values($reflection->getConstants());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isKnown(string $capability): bool
|
||||||
|
{
|
||||||
|
return in_array($capability, self::all(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,57 +8,4 @@ enum TenantRole: string
|
|||||||
case Manager = 'manager';
|
case Manager = 'manager';
|
||||||
case Operator = 'operator';
|
case Operator = 'operator';
|
||||||
case Readonly = 'readonly';
|
case Readonly = 'readonly';
|
||||||
|
|
||||||
public function canSync(): bool
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Owner,
|
|
||||||
self::Manager,
|
|
||||||
self::Operator => true,
|
|
||||||
self::Readonly => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canManageBackupSchedules(): bool
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Owner,
|
|
||||||
self::Manager => true,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canRunBackupSchedules(): bool
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Owner,
|
|
||||||
self::Manager,
|
|
||||||
self::Operator => true,
|
|
||||||
self::Readonly => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canViewProviders(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canManageProviders(): bool
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Owner,
|
|
||||||
self::Manager => true,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public function canRunProviderOperations(): bool
|
|
||||||
{
|
|
||||||
return match ($this) {
|
|
||||||
self::Owner,
|
|
||||||
self::Manager,
|
|
||||||
self::Operator => true,
|
|
||||||
self::Readonly => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds
|
'sleep' => (int) env('GRAPH_RETRY_SLEEP', 200), // milliseconds
|
||||||
],
|
],
|
||||||
|
|
||||||
// When true (default in local/debug), BackupService will fall back to stub payloads
|
// When true (default in local), BackupService will fall back to stub payloads
|
||||||
// instead of failing the backup entirely if Graph returns an error.
|
// instead of failing the backup entirely if Graph returns an error.
|
||||||
'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local' || env('APP_DEBUG')),
|
'stub_on_failure' => (bool) env('GRAPH_STUB_ON_FAILURE', env('APP_ENV') === 'local'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -184,7 +184,7 @@ ### Functional Requirements
|
|||||||
### Canonical allowed summary keys (single source of truth)
|
### Canonical allowed summary keys (single source of truth)
|
||||||
|
|
||||||
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
|
The following keys are the ONLY allowed summary keys for Ops-UX rendering:
|
||||||
`total, processed, succeeded, failed, skipped, created, updated, deleted, items, tenants`
|
`total, processed, succeeded, failed, skipped, compliant, noncompliant, unknown, created, updated, deleted, items, tenants`
|
||||||
|
|
||||||
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).
|
All normalizers/renderers MUST reference this canonical list (no duplicated lists in multiple places).
|
||||||
|
|
||||||
|
|||||||
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.
|
||||||
44
specs/065-tenant-rbac-v1/contracts/capabilities.md
Normal file
44
specs/065-tenant-rbac-v1/contracts/capabilities.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Capability Contracts: Tenant RBAC v1
|
||||||
|
|
||||||
|
This document defines the canonical set of capabilities for the Tenant RBAC system. Feature code MUST use these capability strings when checking permissions with Laravel Gates (e.g., `Gate::allows('tenant.members.manage')`).
|
||||||
|
|
||||||
|
## Naming Convention
|
||||||
|
|
||||||
|
Capabilities follow the format: `tenant.<domain>.<verb>`
|
||||||
|
|
||||||
|
## Capability List (v1)
|
||||||
|
|
||||||
|
### Core
|
||||||
|
- `tenant.core.view`: View the tenant dashboard and basic information.
|
||||||
|
|
||||||
|
### Membership
|
||||||
|
- `tenant.members.view`: View the list of members in a tenant.
|
||||||
|
- `tenant.members.manage`: Add, remove, or change the roles of members in a tenant. (Owner-only)
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
- `tenant.settings.view`: View tenant settings.
|
||||||
|
- `tenant.settings.manage`: Modify tenant settings.
|
||||||
|
|
||||||
|
### Providers
|
||||||
|
- `tenant.providers.view`: View provider connections.
|
||||||
|
- `tenant.providers.manage`: Add, edit, or remove provider connections.
|
||||||
|
- `tenant.providers.credentials.rotate`: Rotate credentials for a provider connection.
|
||||||
|
- `tenant.providers.run_ops`: Execute operations using a provider.
|
||||||
|
|
||||||
|
### Operations & Monitoring
|
||||||
|
- `tenant.operations.view`: View tenant operations and monitoring data.
|
||||||
|
- `tenant.operations.start`: Start new tenant operations.
|
||||||
|
|
||||||
|
### Inventory & Drift
|
||||||
|
- `tenant.inventory.view`: View tenant inventory.
|
||||||
|
- `tenant.inventory.sync`: Trigger a synchronization of the tenant inventory.
|
||||||
|
- `tenant.drift.view`: View drift detection reports.
|
||||||
|
- `tenant.drift.ack`: Acknowledge drift alerts.
|
||||||
|
|
||||||
|
### Policies, Backups, & Restore
|
||||||
|
- `tenant.policies.view`: View policies.
|
||||||
|
- `tenant.policies.sync`: Synchronize policies.
|
||||||
|
- `tenant.policies.delete`: Delete policies.
|
||||||
|
- `tenant.backups.manage`: Manage backups.
|
||||||
|
- `tenant.restore.execute`: Execute a restore from a backup.
|
||||||
|
- `tenant.danger_zone`: Access to destructive "danger zone" actions. (Owner-only)
|
||||||
32
specs/065-tenant-rbac-v1/data-model.md
Normal file
32
specs/065-tenant-rbac-v1/data-model.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Data Model: Tenant RBAC v1
|
||||||
|
|
||||||
|
This document outlines the data model for the Tenant RBAC feature, as defined in the feature specification.
|
||||||
|
|
||||||
|
## Tables
|
||||||
|
|
||||||
|
### `tenant_memberships` (New Table)
|
||||||
|
|
||||||
|
This table is the source of truth for user membership and roles within a tenant.
|
||||||
|
|
||||||
|
**Columns**:
|
||||||
|
|
||||||
|
| Name | Type | Description | Constraints |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | `bigint` or `uuid` | Primary key. Follows repository convention. | Primary Key |
|
||||||
|
| `tenant_id` | `bigint` | Foreign key to the `tenants` table. | Not Null, FK to `tenants.id` |
|
||||||
|
| `user_id` | `bigint` | Foreign key to the `users` table. | Not Null, FK to `users.id` |
|
||||||
|
| `role` | `string` | The user's role within the tenant. | Not Null, Enum: `owner`, `manager`, `operator`, `readonly` |
|
||||||
|
| `created_at` | `timestamp` | Timestamp of creation. | Not Null |
|
||||||
|
| `updated_at` | `timestamp` | Timestamp of last update. | Not Null |
|
||||||
|
|
||||||
|
**Indexes**:
|
||||||
|
|
||||||
|
- `tenant_memberships_tenant_id_user_id_unique`: Unique constraint on `(tenant_id, user_id)` to ensure a user has only one role per tenant.
|
||||||
|
- `tenant_memberships_tenant_id_role_index`: Index on `(tenant_id, role)` for efficient role-based queries within a tenant.
|
||||||
|
- `tenant_memberships_user_id_index`: Index on `(user_id)` for efficiently finding all tenant memberships for a user.
|
||||||
|
|
||||||
|
## Relationships
|
||||||
|
|
||||||
|
- A `Tenant` has many `TenantMembership` records.
|
||||||
|
- A `User` has many `TenantMembership` records.
|
||||||
|
- A `TenantMembership` belongs to one `Tenant` and one `User`.
|
||||||
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.
|
||||||
122
specs/065-tenant-rbac-v1/plan.md
Normal file
122
specs/065-tenant-rbac-v1/plan.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# Implementation Plan: Tenant RBAC v1
|
||||||
|
|
||||||
|
**Branch**: `065-tenant-rbac-v1` | **Date**: 2026-01-27 | **Spec**: [spec.md](spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This plan outlines the implementation of a capabilities-first Tenant RBAC system within the existing Laravel and Filament application. The primary requirement is to introduce granular, server-side enforced permissions for tenant users, managed through a new "Members" UI.
|
||||||
|
|
||||||
|
The technical approach involves:
|
||||||
|
1. Verifying the existing `tenant_memberships` table matches the spec (role + minimal provenance fields).
|
||||||
|
2. Using the existing central capability registry (`App\Support\Auth\Capabilities`) and role → capability mapping to enforce least privilege.
|
||||||
|
3. Ensuring Laravel Gates are defined per capability for all registry entries (no hand-maintained capability lists).
|
||||||
|
4. Auditing / completing the Filament Relation Manager (`TenantMembershipsRelationManager`) so only Owners can manage memberships.
|
||||||
|
5. Adding/expanding unit + feature tests with Pest to ensure the RBAC system is secure and correct.
|
||||||
|
|
||||||
|
## Phases & Checkpoints
|
||||||
|
|
||||||
|
Phase 1 — Setup & Database
|
||||||
|
- Done when: `tenant_memberships` schema + relationships are verified and documented, and all related tests pass.
|
||||||
|
|
||||||
|
Phase 2 — Foundational RBAC Core
|
||||||
|
- Done when: capability registry + role mapping are aligned with least-privilege semantics and Gates are registered from `Capabilities::all()`.
|
||||||
|
- Done when: request-scope caching prevents repeated membership queries (query-count test).
|
||||||
|
|
||||||
|
Phase 3 — Membership Management UI
|
||||||
|
- Done when: Owners can add/change/remove members via Filament, last-owner rules are enforced, and all membership mutations require confirmation.
|
||||||
|
- Done when: members UI render/hydration is DB-only (no outbound HTTP and no jobs dispatched).
|
||||||
|
|
||||||
|
Phase 4 — Authorization Enforcement
|
||||||
|
- Done when: tenant route scoping is deny-as-not-found for non-members and global search is tenant-scoped.
|
||||||
|
- Done when: enforcement sweep removes role-ish authorization checks from tenant-plane feature code and replaces them with capability Gates/Policies.
|
||||||
|
|
||||||
|
Phase 5 — Polish & Finalization
|
||||||
|
- Done when: Pint passes for all changed files and the full test suite passes.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4+
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament 5, Livewire 4, Pest 4
|
||||||
|
**Storage**: PostgreSQL
|
||||||
|
**Testing**: Pest
|
||||||
|
**Target Platform**: Web (Laravel application)
|
||||||
|
**Project Type**: Web application
|
||||||
|
**Performance Goals**: Membership/capability evaluation MUST be O(1) per request after initial load (NFR-001).
|
||||||
|
**Constraints**: RBAC UI surfaces MUST be DB-only at render time (NFR-003).
|
||||||
|
**Scale/Scope**: The system should be designed to handle a moderate number of tenants and users, with the potential to scale. Initial design should not be a bottleneck for future growth.
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- [X] **Inventory-first**: N/A for this feature.
|
||||||
|
- [X] **Read/write separation**: All membership changes (write operations) are specified to require Owner-level privileges and will be implemented with confirmations and audit logs.
|
||||||
|
- [X] **Graph contract path**: N/A. The spec explicitly states no Graph calls for RBAC UI.
|
||||||
|
- [X] **Deterministic capabilities**: The role-to-capability mapping is deterministic and defined in a central place.
|
||||||
|
- [X] **RBAC Standard**: This feature implements the RBAC standard. It establishes the two-plane separation, capabilities-first authorization, and least-privilege roles.
|
||||||
|
- [X] **Tenant isolation**: All queries and actions are tenant-scoped through the `tenant_memberships` table.
|
||||||
|
- [X] **Run observability**: N/A for the core RBAC logic, as it's synchronous. Any long-running operations triggered by authorized users will follow this principle.
|
||||||
|
- [X] **Automation**: N/A for this feature.
|
||||||
|
- [X] **Data minimization**: The `tenant_memberships` table stores only essential information (IDs, role, and minimal provenance fields like `source` and `created_by_user_id`).
|
||||||
|
- [X] **Badge semantics (BADGE-001)**: The role badge in the members list will use the `BadgeCatalog`.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/065-tenant-rbac-v1/
|
||||||
|
├── plan.md # This file
|
||||||
|
├── research.md # Phase 0 output
|
||||||
|
├── data-model.md # Phase 1 output
|
||||||
|
├── quickstart.md # Phase 1 output
|
||||||
|
├── contracts/
|
||||||
|
│ └── capabilities.md # Phase 1 output
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
The project follows a standard Laravel structure. Key files for this feature will be located in:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Models/
|
||||||
|
│ ├── TenantMembership.php # Existing pivot model
|
||||||
|
│ └── User.php # Existing relationship to tenants/memberships
|
||||||
|
│ └── Tenant.php # Existing relationship to users/memberships
|
||||||
|
├── Policies/
|
||||||
|
│ └── (optional) TenantMembershipPolicy.php # Optional policy for membership mutations (currently Gate-driven)
|
||||||
|
├── Providers/
|
||||||
|
│ └── AuthServiceProvider.php # Register per-capability Gates
|
||||||
|
└── Filament/
|
||||||
|
└── Resources/
|
||||||
|
└── TenantResource/
|
||||||
|
└── RelationManagers/
|
||||||
|
└── TenantMembershipsRelationManager.php # New Filament relation manager
|
||||||
|
|
||||||
|
database/
|
||||||
|
└── migrations/
|
||||||
|
└── 2026_01_25_022729_create_tenant_memberships_table.php # Existing migration
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
│ └── Filament/
|
||||||
|
│ └── TenantMembersTest.php # New feature test for RBAC UI
|
||||||
|
└── Unit/
|
||||||
|
└── Auth/ # Existing unit tests for capability registry + resolver
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: The implementation will use the existing Laravel project structure. New classes and files will be created in their conventional locations. The primary UI for membership management will be a Filament Relation Manager on the `TenantResource`.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
|
||||||
|
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
|
||||||
74
specs/065-tenant-rbac-v1/quickstart.md
Normal file
74
specs/065-tenant-rbac-v1/quickstart.md
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
# Quickstart: Tenant RBAC v1
|
||||||
|
|
||||||
|
This guide provides a quick overview for developers on how to use the Tenant RBAC v1 system.
|
||||||
|
|
||||||
|
## Checking Permissions
|
||||||
|
|
||||||
|
The core of the RBAC system is a set of defined capabilities. To check if the currently authenticated user has a specific capability, use Laravel's `Gate` facade.
|
||||||
|
|
||||||
|
**NEVER check for roles directly.** Always check for capabilities.
|
||||||
|
|
||||||
|
### In PHP (Controllers, Policies, Livewire Components)
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
|
||||||
|
// Check for a specific capability
|
||||||
|
if (Gate::allows('tenant.members.manage')) {
|
||||||
|
// User can manage members
|
||||||
|
}
|
||||||
|
|
||||||
|
// You can also deny access
|
||||||
|
if (Gate::denies('tenant.settings.manage')) {
|
||||||
|
abort(403);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Blade Views
|
||||||
|
|
||||||
|
You can use the `@can` and `@cannot` directives in your Blade templates to conditionally show UI elements.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
@can('tenant.members.manage')
|
||||||
|
<button>Add Member</button>
|
||||||
|
@endcan
|
||||||
|
|
||||||
|
@cannot('tenant.danger_zone')
|
||||||
|
<p>You are not authorized to access the danger zone.</p>
|
||||||
|
@endcannot
|
||||||
|
```
|
||||||
|
|
||||||
|
### In Filament Resources
|
||||||
|
|
||||||
|
Filament actions and pages can be protected using the `can` method.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
// Protecting an action
|
||||||
|
Action::make('delete')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(fn ($record) => $record->delete())
|
||||||
|
->visible(fn ($record) => Gate::allows('tenant.policies.delete', $record));
|
||||||
|
|
||||||
|
// Protecting a page
|
||||||
|
class ListMembers extends ListRecords
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
public static function canView(): bool
|
||||||
|
{
|
||||||
|
return Gate::allows('tenant.members.view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capability Reference
|
||||||
|
|
||||||
|
A full list of available capabilities is defined in `specs/065-tenant-rbac-v1/contracts/capabilities.md`.
|
||||||
|
|
||||||
|
## Key Principles
|
||||||
|
|
||||||
|
1. **Capabilities, Not Roles**: All authorization checks MUST be against capabilities, not roles (`owner`, `manager`, etc.). This decouples the application's logic from the role definitions.
|
||||||
|
2. **Server-Side Enforcement**: UI hiding is not security. Always enforce permissions on the server-side (in controllers, actions, or policies) in addition to hiding UI elements.
|
||||||
|
3. **Use Policies for Model-Specific Logic**: For authorization logic that depends on a specific model instance, use a Laravel Policy class.
|
||||||
7
specs/065-tenant-rbac-v1/research.md
Normal file
7
specs/065-tenant-rbac-v1/research.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Research: Tenant RBAC v1
|
||||||
|
|
||||||
|
**Date**: 2026-01-27
|
||||||
|
|
||||||
|
No significant research was required for this feature. The specification is comprehensive and relies on established technologies and patterns within the project (Laravel, Filament, Pest).
|
||||||
|
|
||||||
|
The implementation will follow standard Laravel practices for Gates, Policies, and database migrations.
|
||||||
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)
|
||||||
88
specs/065-tenant-rbac-v1/tasks.md
Normal file
88
specs/065-tenant-rbac-v1/tasks.md
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# Tasks for Feature: Tenant RBAC v1
|
||||||
|
|
||||||
|
This document outlines the implementation tasks for the Tenant RBAC v1 feature, ordered by dependency.
|
||||||
|
|
||||||
|
## Phase 1: Setup & Database
|
||||||
|
|
||||||
|
- [X] T001 Verify `tenant_memberships` schema matches spec (uuid PK, role enum, minimal provenance fields) in `database/migrations/2026_01_25_022729_create_tenant_memberships_table.php`
|
||||||
|
- [X] T002 Verify `TenantMembership` pivot model + relationships exist and are correct in `app/Models/TenantMembership.php`, `app/Models/User.php`, and `app/Models/Tenant.php`
|
||||||
|
|
||||||
|
## Phase 2: Foundational RBAC Core
|
||||||
|
|
||||||
|
- [X] T005 [P] Ensure canonical capability registry exists and matches v1 contract in `app/Support/Auth/Capabilities.php`
|
||||||
|
- [X] T006 [P] Ensure role → capability mapping matches least-privilege semantics (Owner-only membership manage) and references only registry constants in `app/Services/Auth/RoleCapabilityMap.php`
|
||||||
|
- [X] T007 Register tenant capability gates for the full registry using `Capabilities::all()` (no hand-maintained lists) in `app/Providers/AuthServiceProvider.php`
|
||||||
|
- [X] T008 Add/confirm request-scope cache for membership + capabilities and add a test asserting repeated capability checks do not execute additional membership queries (query count assertion) in `tests/Unit/Auth/CapabilityResolverQueryCountTest.php`
|
||||||
|
- [X] T009 Create/update unit tests asserting least-privilege invariants (Owner can manage memberships, Manager cannot) in `tests/Unit/Auth/CapabilityResolverTest.php`
|
||||||
|
- [X] T012 [P] Add contract test that fails fast on unknown/typo capability strings passed to the capability system in `tests/Unit/Auth/UnknownCapabilityGuardTest.php`
|
||||||
|
- [X] T013 [P] (Optional, recommended) Add guard test forbidding role-string checks outside the mapper (e.g. `'owner'`, `'manager'`) in `tests/Unit/Auth/NoRoleStringChecksTest.php`
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Membership Management UI
|
||||||
|
|
||||||
|
**Goal**: As a Tenant Owner, I want to manage members and their roles.
|
||||||
|
**Independent Test Criteria**: An owner can add, edit, and remove members. Non-owners cannot. The last owner cannot be removed or demoted.
|
||||||
|
|
||||||
|
- [X] T014 [US1] Create/update `TenantMembershipsRelationManager` for `TenantResource` in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T015 [US1] Implement table columns (user name, email, role) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T016 [US1] Ensure "Add Member" action is Owner-only and requires existing user (no placeholder user creation) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T017 [US1] Implement "Change Role" action (Owner-only, server-authorized, requires confirmation + clear validation/feedback) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T018 [US1] Implement "Remove Member" action (Owner-only, server-authorized, requires confirmation + clear validation/feedback) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T019 [US1] Add "last owner" protection logic to change/remove actions (and cover policy edge-cases) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T020 [US1] Add DB-only render guard test for Members UI: use `Http::preventStrayRequests()` (and prevent queued work) while rendering/hydrating members relation manager; assert no outbound HTTP and no jobs dispatched in `tests/Feature/Filament/TenantMembersDbOnlyRenderTest.php`
|
||||||
|
- [X] T021 [US1] Create feature test for membership management UI and authorization in `tests/Feature/Filament/TenantMembersTest.php`
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Authorization Enforcement
|
||||||
|
|
||||||
|
**Goal**: As a user, my actions are authorized based on my role, and I cannot access unauthorized resources.
|
||||||
|
**Independent Test Criteria**: Routes and actions are protected based on the defined capability matrix.
|
||||||
|
|
||||||
|
- [X] T022 [US2] Harden tenant scoping to enforce FR-011 in the Filament tenant middleware chain (registered in `app/Providers/Filament/AdminPanelProvider.php`) with 404 (not 403) semantics for all `/admin/t/{tenant}/**` routes; enforce global search scoping by overriding global search queries on each Resource (or a shared trait) to restrict results to the current tenant and deny non-members (tests assert non-member sees zero results and result URLs still 404) in `app/Filament/Resources/*Resource.php`
|
||||||
|
- [X] T022 [US2] Harden tenant scoping to enforce FR-011 in the Filament tenant middleware chain (registered in `app/Providers/Filament/AdminPanelProvider.php`) with 404 (not 403) semantics for all `/admin/t/{tenant}/**` routes; enforce global search scoping by overriding global search queries on each Resource (or a shared trait) to restrict results to the current tenant and deny non-members (tests assert non-member sees zero results and result URLs still 404) in `app/Filament/Resources/*Resource.php`
|
||||||
|
- [X] T023 [US2] Add non-member 404 tests (direct URL, tenant switcher list, global search scoping) in `tests/Feature/Filament/TenantScopingTest.php`
|
||||||
|
- [X] T024 [US2] Discovery sweep (required): produce a mutation/start-action hitlist across Filament (Resources/RelationManagers/Actions/BulkActions) in `specs/065-tenant-rbac-v1/enforcement-hitlist.md`
|
||||||
|
- [ ] T025 [US2] Enforcement pass (required): for each hitlist entry add server-side authorization + UI visibility/disabled rules + confirmations for destructive actions in `app/Filament/**`
|
||||||
|
- [ ] T026 [US2] Replace role-based authorization helpers (e.g. `TenantRole::can*()` calls) with capability Gates across tenant-plane UI/actions (deny non-member as 404 per middleware; otherwise prefer authorization failures as 403) in `app/Filament/**` and `app/Policies/**`
|
||||||
|
- [X] T025 [US2] Enforcement pass (required): for each hitlist entry add server-side authorization + UI visibility/disabled rules + confirmations for destructive actions in `app/Filament/**`
|
||||||
|
- [X] T026 [US2] Replace role-based authorization helpers (e.g. `TenantRole::can*()` calls) with capability Gates across tenant-plane UI/actions (deny non-member as 404 per middleware; otherwise prefer authorization failures as 403) in `app/Filament/**` and `app/Policies/**`
|
||||||
|
- [X] T027 [P] [US2] Providers enforcement: gate + UI rules for provider CRUD / run ops / credential rotate in `app/Filament/Resources/ProviderConnectionResource.php` and `app/Filament/Resources/ProviderConnectionResource/Pages/*.php`
|
||||||
|
- [X] T028 [P] [US2] Tenants + tenant settings enforcement: gate + UI rules for tenant pages/actions in `app/Filament/Resources/TenantResource.php` and `app/Filament/Resources/TenantResource/Pages/*.php`
|
||||||
|
- [X] T029 [P] [US2] Policies enforcement: gate + UI rules (sync/delete/version views) in `app/Filament/Resources/PolicyResource.php` and `app/Filament/Resources/PolicyResource/Pages/*.php`
|
||||||
|
- [X] T030 [P] [US2] Backups enforcement: gate + UI rules for backup sets/schedules in `app/Filament/Resources/BackupSetResource.php` and `app/Filament/Resources/BackupScheduleResource.php`
|
||||||
|
- [X] T031 [P] [US2] Restore enforcement: gate + UI rules for restore creation/execution in `app/Filament/Resources/RestoreRunResource.php` and `app/Jobs/ExecuteRestoreRunJob.php`
|
||||||
|
- [X] T032 [P] [US2] Drift/Findings enforcement: gate + UI rules for drift findings browsing/ack (if present) in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/*.php`
|
||||||
|
- [X] T033 [P] [US2] Inventory enforcement: gate + UI rules for inventory browsing/sync runs in `app/Filament/Resources/InventoryItemResource.php` and `app/Filament/Resources/InventorySyncRunResource.php`
|
||||||
|
- [X] T034 [US2] Add canonical audit action_ids for membership changes (`tenant_membership.add|role_change|remove|last_owner_blocked`) in `app/Support/Audit/AuditActionId.php`
|
||||||
|
- [X] T035 [US2] Implement audit logging for membership changes (writes `audit_logs.action` + redacted metadata) in `app/Filament/Resources/TenantResource/RelationManagers/TenantMembershipsRelationManager.php`
|
||||||
|
- [X] T036 [US2] Add audit logging tests (entry written, action_id stable, metadata minimal + redacted; includes explicit data-minimization assertions: no secrets/tokens, minimal identity fields) in `tests/Feature/Audit/TenantMembershipAuditLogTest.php`
|
||||||
|
- [X] T037 [US2] Add denial diagnostics: log structured context for authorization denials (capability, tenant_id, actor_user_id) without secrets in `app/Services/Auth/CapabilityResolver.php` (or a dedicated listener) and cover one representative denial path in `tests/Feature/Rbac/DenialDiagnosticsTest.php`
|
||||||
|
- [X] T038 [P] [US2] Role matrix tests (Readonly: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ReadonlyAccessTest.php`
|
||||||
|
- [X] T039 [P] [US2] Role matrix tests (Operator: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OperatorAccessTest.php`
|
||||||
|
- [X] T040 [P] [US2] Role matrix tests (Manager: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/ManagerAccessTest.php`
|
||||||
|
- [X] T041 [P] [US2] Role matrix tests (Owner: 5–10 golden checks) in `tests/Feature/Rbac/RoleMatrix/OwnerAccessTest.php`
|
||||||
|
|
||||||
|
### Late enforcement follow-ups (post-sweep)
|
||||||
|
|
||||||
|
- [X] T046 Gate tenant registration + tenant create/edit/delete for non-managers (prevent cross-tenant privilege escalation via Register Tenant) in `app/Filament/Pages/Tenancy/RegisterTenant.php`, `app/Filament/Resources/TenantResource.php`, and `app/Filament/Resources/TenantResource/Pages/*.php`
|
||||||
|
- [X] T047 Gate policy version maintenance actions (archive/restore/prune/force delete) to `Capabilities::TENANT_MANAGE` with UI disable + server-side abort in `app/Filament/Resources/PolicyVersionResource.php`
|
||||||
|
- [X] T048 Add regression tests for readonly tenant + policy version maintenance restrictions in `tests/Feature/Rbac/*.php` and update existing bootstrap test in `tests/Feature/TenantRBAC/TenantBootstrapAssignTest.php`
|
||||||
|
|
||||||
|
## Phase 5: Polish & Finalization
|
||||||
|
|
||||||
|
- [X] T042 [P] Review all changed code for adherence to project conventions in `app/` and `tests/`
|
||||||
|
- [X] T043 [P] Ensure all new UI text is translatable in `resources/lang/*/*.php`
|
||||||
|
- [X] T044 Run `./vendor/bin/sail bin pint --dirty` to format all changed files in `app/` and `tests/`
|
||||||
|
- [X] T045 Run the full test suite with `./vendor/bin/sail artisan test --compact` to ensure no regressions
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **US1 (Membership Management)** depends on **Phase 1** and **Phase 2**.
|
||||||
|
- **US2 (Authorization Enforcement)** depends on **Phase 1** and **Phase 2**. US1 should be completed first to allow for testing with different roles.
|
||||||
|
|
||||||
|
## Parallel Execution
|
||||||
|
|
||||||
|
- Within **Phase 2**, tasks T005, T006, T008, and T012 can be worked on in parallel.
|
||||||
|
- Within **Phase 4**, the domain enforcement tasks (T024–T030) can be split across agents once T022 (hitlist) is stable.
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
The implementation will follow an MVP-first approach. The initial focus will be on completing Phase 1 and 2 to establish the core data model and RBAC logic. Then, Phase 3 will be implemented to provide the essential UI for managing memberships. Phase 4 will be a sweep to enforce the new authorization rules across the application.
|
||||||
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();
|
Queue::fake();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -66,7 +66,8 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ListEntraGroups::class)
|
Livewire::test(ListEntraGroups::class)
|
||||||
->assertActionHidden('sync_groups');
|
->assertActionVisible('sync_groups')
|
||||||
|
->assertActionDisabled('sync_groups');
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -70,6 +70,5 @@
|
|||||||
->assertSee('Coverage')
|
->assertSee('Coverage')
|
||||||
->assertSee('Policies')
|
->assertSee('Policies')
|
||||||
->assertSee('Foundations')
|
->assertSee('Foundations')
|
||||||
->assertSee('Dependencies')
|
->assertSee('Dependencies');
|
||||||
->assertSee('✅');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -73,6 +73,49 @@
|
|||||||
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
|
expect($backupItem->metadata['scope_tag_names'] ?? null)->toBe(['Tag 1']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('readonly users cannot open restore wizard via policy version row action', function () {
|
||||||
|
$tenant = Tenant::create([
|
||||||
|
'tenant_id' => 'tenant-policy-version-wizard-readonly',
|
||||||
|
'name' => 'Tenant',
|
||||||
|
'metadata' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
|
||||||
|
$policy = Policy::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'external_id' => 'policy-ro-1',
|
||||||
|
'policy_type' => 'settingsCatalogPolicy',
|
||||||
|
'display_name' => 'Settings Catalog',
|
||||||
|
'platform' => 'windows',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$version = PolicyVersion::create([
|
||||||
|
'tenant_id' => $tenant->id,
|
||||||
|
'policy_id' => $policy->id,
|
||||||
|
'version_number' => 1,
|
||||||
|
'policy_type' => $policy->policy_type,
|
||||||
|
'platform' => $policy->platform,
|
||||||
|
'captured_at' => now(),
|
||||||
|
'snapshot' => ['id' => $policy->external_id, 'displayName' => $policy->display_name],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::factory()->create(['email' => 'readonly@example.com']);
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$tenant->getKey() => ['role' => 'readonly'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->actingAs($user);
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
Livewire::test(ListPolicyVersions::class)
|
||||||
|
->assertTableActionDisabled('restore_via_wizard', $version)
|
||||||
|
->callTableAction('restore_via_wizard', $version);
|
||||||
|
|
||||||
|
expect(BackupSet::query()->where('metadata->source', 'policy_version')->exists())->toBeFalse();
|
||||||
|
expect(BackupItem::query()->exists())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
test('restore run wizard can be prefilled from query params for policy version backup set', function () {
|
test('restore run wizard can be prefilled from query params for policy version backup set', function () {
|
||||||
$tenant = Tenant::create([
|
$tenant = Tenant::create([
|
||||||
'tenant_id' => 'tenant-policy-version-prefill',
|
'tenant_id' => 'tenant-policy-version-prefill',
|
||||||
|
|||||||
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
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\TenantResource\Pages\ListTenants;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\UserTenantPreference;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
test('make current action marks exactly one tenant as current', function () {
|
test('choosing a tenant persists last used tenant preference', function () {
|
||||||
$originalEnv = getenv('INTUNE_TENANT_ID');
|
$first = Tenant::factory()->create([
|
||||||
putenv('INTUNE_TENANT_ID=');
|
'status' => 'active',
|
||||||
|
|
||||||
$first = Tenant::create([
|
|
||||||
'tenant_id' => 'tenant-one',
|
|
||||||
'name' => 'Tenant One',
|
|
||||||
'is_current' => true,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$second = Tenant::create([
|
$second = Tenant::factory()->create([
|
||||||
'tenant_id' => 'tenant-two',
|
'status' => 'active',
|
||||||
'name' => 'Tenant Two',
|
|
||||||
'is_current' => false,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$user->tenants()->syncWithoutDetaching([
|
$user->tenants()->syncWithoutDetaching([
|
||||||
$first->getKey() => ['role' => 'owner'],
|
$first->getKey() => ['role' => 'owner'],
|
||||||
$second->getKey() => ['role' => 'owner'],
|
$second->getKey() => ['role' => 'owner'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Filament::setTenant($first, true);
|
Filament::setTenant($first, true);
|
||||||
|
|
||||||
Livewire::test(ListTenants::class)
|
Livewire::test(ChooseTenant::class)
|
||||||
->callTableAction('makeCurrent', $second);
|
->call('selectTenant', $second->getKey())
|
||||||
|
->assertRedirect(TenantDashboard::getUrl(tenant: $second));
|
||||||
|
|
||||||
expect(Tenant::find($second->id)->is_current)->toBeTrue();
|
$preference = UserTenantPreference::query()
|
||||||
expect(Tenant::find($first->id)->is_current)->toBeFalse();
|
->where('user_id', $user->getKey())
|
||||||
expect(Tenant::query()->where('is_current', true)->count())->toBe(1);
|
->where('tenant_id', $second->getKey())
|
||||||
|
->first();
|
||||||
|
|
||||||
$originalEnv !== false
|
expect($preference)->not->toBeNull();
|
||||||
? putenv("INTUNE_TENANT_ID={$originalEnv}")
|
expect($preference?->last_used_at)->not->toBeNull();
|
||||||
: putenv('INTUNE_TENANT_ID');
|
|
||||||
});
|
});
|
||||||
|
|||||||
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('Actions');
|
||||||
$response->assertSee($firstKey);
|
$response->assertSee($firstKey);
|
||||||
$response->assertSee('ok');
|
$response->assertSee('ok');
|
||||||
$response->assertSee('missing');
|
$response->assertSee('Missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tenant list shows Open in Entra action', function () {
|
test('tenant list shows Open in Entra action', function () {
|
||||||
|
|||||||
@ -204,7 +204,7 @@
|
|||||||
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1);
|
expect(OperationRun::query()->where('tenant_id', $tenant->id)->where('type', 'inventory.sync')->count())->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forbids unauthorized users from starting inventory sync', function () {
|
it('disables inventory sync start action for readonly users', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -212,7 +212,8 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ListInventoryItems::class)
|
Livewire::test(ListInventoryItems::class)
|
||||||
->assertActionHidden('run_inventory_sync');
|
->assertActionVisible('run_inventory_sync')
|
||||||
|
->assertActionDisabled('run_inventory_sync');
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
expect(InventorySyncRun::query()->where('tenant_id', $tenant->id)->exists())->toBeFalse();
|
||||||
|
|||||||
@ -166,5 +166,5 @@
|
|||||||
|
|
||||||
$message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
|
$message = (string) (($fresh?->failure_summary[0]['message'] ?? ''));
|
||||||
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
|
expect($message)->not->toContain('abcdefghijklmnopqrstuvwxyz');
|
||||||
expect($message)->toContain('[REDACTED]');
|
expect($message)->toContain('[REDACTED');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -165,7 +165,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides policy sync start action for readonly users', function () {
|
it('disables policy sync start action for readonly users', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
@ -175,7 +175,8 @@
|
|||||||
Filament::setTenant($tenant, true);
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
Livewire::test(ListPolicies::class)
|
Livewire::test(ListPolicies::class)
|
||||||
->assertActionHidden('sync');
|
->assertActionVisible('sync')
|
||||||
|
->assertActionDisabled('sync');
|
||||||
|
|
||||||
Queue::assertNothingPushed();
|
Queue::assertNothingPushed();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -91,3 +91,29 @@
|
|||||||
|
|
||||||
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
Queue::assertPushed(ProviderConnectionHealthCheckJob::class, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables connection check action for readonly users', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$connection = ProviderConnection::factory()->create([
|
||||||
|
'tenant_id' => $tenant->getKey(),
|
||||||
|
'provider' => 'microsoft',
|
||||||
|
'entra_tenant_id' => fake()->uuid(),
|
||||||
|
'status' => 'connected',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(EditProviderConnection::class, ['record' => $connection->getRouteKey()])
|
||||||
|
->assertActionVisible('check_connection')
|
||||||
|
->assertActionDisabled('check_connection')
|
||||||
|
->assertActionVisible('compliance_snapshot')
|
||||||
|
->assertActionDisabled('compliance_snapshot');
|
||||||
|
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|||||||
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\Models\TenantMembership;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -17,7 +16,7 @@
|
|||||||
|
|
||||||
$manager = app(TenantMembershipManager::class);
|
$manager = app(TenantMembershipManager::class);
|
||||||
|
|
||||||
$callback = fn () => $manager->changeRole($tenant, $actor, $membership, TenantRole::Readonly);
|
$callback = fn () => $manager->changeRole($tenant, $actor, $membership, 'readonly');
|
||||||
|
|
||||||
expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
|
expect($callback)->toThrow(DomainException::class, 'You cannot demote the last remaining owner.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
use App\Models\AuditLog;
|
use App\Models\AuditLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -14,8 +13,8 @@
|
|||||||
|
|
||||||
$manager = app(TenantMembershipManager::class);
|
$manager = app(TenantMembershipManager::class);
|
||||||
|
|
||||||
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
|
$membership = $manager->addMember($tenant, $actor, $member, 'readonly');
|
||||||
$manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
|
$manager->changeRole($tenant, $actor, $membership, 'operator');
|
||||||
$manager->removeMember($tenant, $actor, $membership);
|
$manager->removeMember($tenant, $actor, $membership);
|
||||||
|
|
||||||
$actions = AuditLog::query()
|
$actions = AuditLog::query()
|
||||||
|
|||||||
@ -12,6 +12,11 @@
|
|||||||
|
|
||||||
it('bootstraps tenant creator as owner and audits the assignment', function () {
|
it('bootstraps tenant creator as owner and audits the assignment', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
|
$existingTenant = Tenant::factory()->create();
|
||||||
|
$user->tenants()->syncWithoutDetaching([
|
||||||
|
$existingTenant->getKey() => ['role' => 'owner'],
|
||||||
|
]);
|
||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$tenantGuid = '11111111-1111-1111-1111-111111111111';
|
$tenantGuid = '11111111-1111-1111-1111-111111111111';
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\TenantMembershipManager;
|
use App\Services\Auth\TenantMembershipManager;
|
||||||
use App\Support\TenantRole;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -13,7 +12,7 @@
|
|||||||
|
|
||||||
$manager = app(TenantMembershipManager::class);
|
$manager = app(TenantMembershipManager::class);
|
||||||
|
|
||||||
$membership = $manager->addMember($tenant, $actor, $member, TenantRole::Readonly);
|
$membership = $manager->addMember($tenant, $actor, $member, 'readonly');
|
||||||
|
|
||||||
$this->assertDatabaseHas('tenant_memberships', [
|
$this->assertDatabaseHas('tenant_memberships', [
|
||||||
'id' => $membership->getKey(),
|
'id' => $membership->getKey(),
|
||||||
@ -23,7 +22,7 @@
|
|||||||
'source' => 'manual',
|
'source' => 'manual',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$updated = $manager->changeRole($tenant, $actor, $membership, TenantRole::Operator);
|
$updated = $manager->changeRole($tenant, $actor, $membership, 'operator');
|
||||||
|
|
||||||
expect($updated->role)->toBe('operator');
|
expect($updated->role)->toBe('operator');
|
||||||
|
|
||||||
|
|||||||
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 = User::factory()->create();
|
||||||
$owner->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']);
|
$owner->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Owner->value, 'source' => 'manual']);
|
||||||
|
|
||||||
|
$manager = User::factory()->create();
|
||||||
|
$manager->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Manager->value, 'source' => 'manual']);
|
||||||
|
|
||||||
$readonly = User::factory()->create();
|
$readonly = User::factory()->create();
|
||||||
$readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']);
|
$readonly->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Readonly->value, 'source' => 'manual']);
|
||||||
|
|
||||||
|
$operator = User::factory()->create();
|
||||||
|
$operator->tenants()->attach($tenant->getKey(), ['role' => TenantRole::Operator->value, 'source' => 'manual']);
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
$resolver = app(CapabilityResolver::class);
|
||||||
|
|
||||||
expect($resolver->isMember($owner, $tenant))->toBeTrue();
|
expect($resolver->isMember($owner, $tenant))->toBeTrue();
|
||||||
expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
|
expect($resolver->can($owner, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
|
||||||
expect($resolver->can($owner, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeTrue();
|
expect($resolver->can($owner, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeTrue();
|
||||||
|
expect($resolver->can($owner, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeTrue();
|
||||||
|
|
||||||
|
expect($resolver->isMember($manager, $tenant))->toBeTrue();
|
||||||
|
expect($resolver->can($manager, $tenant, Capabilities::PROVIDER_MANAGE))->toBeTrue();
|
||||||
|
expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeTrue();
|
||||||
|
expect($resolver->can($manager, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue();
|
||||||
|
expect($resolver->can($manager, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
|
||||||
|
expect($resolver->can($manager, $tenant, Capabilities::TENANT_ROLE_MAPPING_MANAGE))->toBeFalse();
|
||||||
|
|
||||||
|
expect($resolver->isMember($operator, $tenant))->toBeTrue();
|
||||||
|
expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeTrue();
|
||||||
|
expect($resolver->can($operator, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse();
|
||||||
|
|
||||||
expect($resolver->isMember($readonly, $tenant))->toBeTrue();
|
expect($resolver->isMember($readonly, $tenant))->toBeTrue();
|
||||||
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue();
|
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_VIEW))->toBeTrue();
|
||||||
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_MANAGE))->toBeFalse();
|
expect($resolver->can($readonly, $tenant, Capabilities::PROVIDER_MANAGE))->toBeFalse();
|
||||||
|
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_RUN))->toBeFalse();
|
||||||
|
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_BACKUP_SCHEDULES_MANAGE))->toBeFalse();
|
||||||
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
|
expect($resolver->can($readonly, $tenant, Capabilities::TENANT_MEMBERSHIP_MANAGE))->toBeFalse();
|
||||||
|
|
||||||
$outsider = User::factory()->create();
|
$outsider = User::factory()->create();
|
||||||
|
|||||||
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