TenantAtlas/app/Filament/Resources/TenantResource.php
ahmido 38d9826f5e feat: workspace context enforcement + ownership safeguards (#86)
Implements workspace-first enforcement and UX:
- Workspace selected before tenant flows; /admin routes into choose-workspace/choose-tenant
- Tenant lists and default tenant selection are scoped to current workspace
- Workspaces UI is tenantless at /admin/workspaces

Security hardening:
- Workspaces can never have 0 owners (blocks last-owner removal/demotion)
- Blocked attempts are audited with action_id=workspace_membership.last_owner_blocked + required metadata
- Optional break-glass recovery page to re-assign workspace owner (audited)

Tests:
- Added/updated Pest feature tests covering redirects, scoping, tenantless workspaces, last-owner guards, and break-glass recovery.

Notes:
- Filament v5 strict Page property signatures respected in RepairWorkspaceOwners.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box>
Reviewed-on: #86
2026-02-02 23:00:56 +00:00

1465 lines
60 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Filament\Resources\TenantResource\RelationManagers;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Models\WorkspaceMembership;
use App\Services\Auth\CapabilityResolver;
use App\Services\Auth\RoleCapabilityMap;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Services\Graph\GraphClientInterface;
use App\Services\Intune\AuditLogger;
use App\Services\Intune\RbacHealthService;
use App\Services\Intune\RbacOnboardingService;
use App\Services\Intune\TenantConfigService;
use App\Services\Intune\TenantPermissionService;
use App\Services\OperationRunService;
use App\Services\Operations\BulkSelectionIdentity;
use App\Support\Auth\Capabilities;
use App\Support\Auth\UiTooltips;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer;
use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\Rbac\UiEnforcement;
use App\Support\Workspaces\WorkspaceContext;
use BackedEnum;
use Filament\Actions;
use Filament\Actions\ActionGroup;
use Filament\Forms;
use Filament\Infolists;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class TenantResource extends Resource
{
// ... [Properties Omitted for Brevity] ...
protected static ?string $model = Tenant::class;
protected static bool $isScopedToTenant = false;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function canCreate(): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
if (static::userCanManageAnyTenant($user)) {
return true;
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
if ($workspaceId === null) {
return false;
}
return WorkspaceMembership::query()
->where('workspace_id', $workspaceId)
->where('user_id', $user->getKey())
->whereIn('role', ['owner', 'manager'])
->exists();
}
public static function canEdit(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
}
public static function canDelete(Model $record): bool
{
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $record instanceof Tenant
&& $resolver->can($user, $record, Capabilities::TENANT_DELETE);
}
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
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_MANAGE));
}
private static function userCanDeleteAnyTenant(User $user): bool
{
return $user->tenantMemberships()
->pluck('role')
->contains(fn (mixed $role): bool => RoleCapabilityMap::hasCapability((string) $role, Capabilities::TENANT_DELETE));
}
public static function form(Schema $schema): Schema
{
// ... [Schema Omitted - No Change] ...
return $schema
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Select::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
])
->default('other')
->required(),
Forms\Components\TextInput::make('tenant_id')
->label('Tenant ID (GUID)')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\TextInput::make('domain')
->label('Primary domain')
->maxLength(255),
Forms\Components\TextInput::make('app_client_id')
->label('App Client ID')
->maxLength(255),
Forms\Components\TextInput::make('app_client_secret')
->label('App Client Secret')
->password()
->dehydrateStateUsing(fn ($state) => filled($state) ? $state : null)
->dehydrated(fn ($state) => filled($state)),
Forms\Components\TextInput::make('app_certificate_thumbprint')
->label('Certificate thumbprint')
->maxLength(255),
Forms\Components\Textarea::make('app_notes')
->label('Notes')
->rows(3),
]);
}
public static function getEloquentQuery(): Builder
{
// ... [Query Omitted - No Change] ...
$user = auth()->user();
if (! $user instanceof User) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
if ($workspaceId === null) {
return parent::getEloquentQuery()->whereRaw('1 = 0');
}
$tenantIds = $user->tenants()
->withTrashed()
->where('workspace_id', $workspaceId)
->pluck('tenants.id');
return parent::getEloquentQuery()
->withTrashed()
->whereIn('id', $tenantIds)
->withCount('policies')
->withMax('policies as last_policy_sync_at', 'last_synced_at');
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('tenant_id')
->label('Tenant ID')
->copyable()
->searchable(),
Tables\Columns\TextColumn::make('environment')
->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::TenantEnvironment))
->color(TagBadgeRenderer::color(TagBadgeDomain::TenantEnvironment))
->sortable(),
Tables\Columns\TextColumn::make('policies_count')
->label('Policies')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('last_policy_sync_at')
->label('Last Sync')
->since()
->sortable(),
Tables\Columns\TextColumn::make('domain')
->copyable()
->toggleable(),
Tables\Columns\IconColumn::make('is_current')
->label('Current')
->boolean(),
Tables\Columns\TextColumn::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus))
->sortable(),
Tables\Columns\TextColumn::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->since(),
])
->filters([
Tables\Filters\TrashedFilter::make()
->label('Archived')
->placeholder('Active')
->trueLabel('All')
->falseLabel('Archived')
->default(true),
Tables\Filters\SelectFilter::make('environment')
->options([
'prod' => 'PROD',
'dev' => 'DEV',
'staging' => 'STAGING',
'other' => 'Other',
]),
Tables\Filters\SelectFilter::make('app_status')
->options([
'ok' => 'OK',
'consent_required' => 'Consent required',
'error' => 'Error',
'unknown' => 'Unknown',
]),
])
->actions([
ActionGroup::make([
Actions\Action::make('view')
->label('View')
->icon('heroicon-o-eye')
->url(fn (Tenant $record) => static::getUrl('view', ['record' => $record], tenant: $record)),
UiEnforcement::forAction(
Actions\Action::make('syncTenant')
->label('Sync')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (Tenant $record): bool {
if (! $record->isActive()) {
return false;
}
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->canAccessTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger, \Filament\Tables\Contracts\HasTable $livewire): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
if (! $user->canAccessTenant($record)) {
abort(404);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_SYNC)) {
abort(403);
}
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$supportedTypes = config('tenantpilot.supported_policy_types', []);
$typeNames = array_map(
static fn (array $typeConfig): string => (string) $typeConfig['type'],
$supportedTypes,
);
sort($typeNames);
$inputs = [
'scope' => 'full',
'types' => $typeNames,
];
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
if (! $opRun->wasRecentlyCreated && $opService->isStaleQueuedRun($opRun)) {
$opService->failStaleQueuedRun(
$opRun,
message: 'Run was queued but never started (likely a previous dispatch error). Re-queuing.'
);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: $inputs,
initiator: auth()->user()
);
}
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
Notification::make()
->title('Policy sync already active')
->body('This operation is already queued or running.')
->warning()
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
return;
}
$opService->dispatchOrFail($opRun, function () use ($record, $supportedTypes, $opRun): void {
SyncPoliciesJob::dispatch((int) $record->getKey(), $supportedTypes, null, $opRun);
});
$auditLogger->log(
tenant: $record,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]],
);
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
OperationUxPresenter::queuedToast((string) $opRun->type)
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->send();
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_SYNC)
->apply(),
Actions\Action::make('openTenant')
->label('Open')
->icon('heroicon-o-arrow-right')
->color('primary')
->url(fn (Tenant $record) => \App\Filament\Resources\PolicyResource::getUrl('index', tenant: $record))
->visible(fn (Tenant $record) => $record->isActive()),
UiEnforcement::forAction(
Actions\Action::make('edit')
->label('Edit')
->icon('heroicon-o-pencil-square')
->url(fn (Tenant $record) => static::getUrl('edit', ['record' => $record], tenant: $record))
)
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('restore')
->label('Restore')
->color('success')
->icon('heroicon-o-arrow-uturn-left')
->successNotificationTitle('Tenant reactivated')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->restore();
$auditLogger->log(
tenant: $record,
action: 'tenant.restored',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
})
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('admin_consent')
->label('Admin consent')
->icon('heroicon-o-clipboard-document')
->url(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null)
->openUrlInNewTab(),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
Actions\Action::make('open_in_entra')
->label('Open in Entra')
->icon('heroicon-o-arrow-top-right-on-square')
->url(fn (Tenant $record) => static::entraUrl($record))
->visible(fn (Tenant $record) => static::entraUrl($record) !== null)
->openUrlInNewTab(),
UiEnforcement::forAction(
Actions\Action::make('verify')
->label('Verify configuration')
->icon('heroicon-o-check-badge')
->color('primary')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => $record->isActive())
->action(function (
Tenant $record,
TenantConfigService $configService,
TenantPermissionService $permissionService,
RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
abort(403);
}
static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger);
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_MANAGE)
->apply(),
static::rbacAction(),
UiEnforcement::forAction(
Actions\Action::make('archive')
->label('Deactivate')
->color('danger')
->icon('heroicon-o-archive-box-x-mark')
->requiresConfirmation()
->visible(fn (Tenant $record): bool => ! $record->trashed())
->action(function (Tenant $record, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$record->delete();
$auditLogger->log(
tenant: $record,
action: 'tenant.archived',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $record->tenant_id]]
);
Notification::make()
->title('Tenant deactivated')
->body('The tenant has been archived and hidden from lists.')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
UiEnforcement::forAction(
Actions\Action::make('forceDelete')
->label('Force delete')
->color('danger')
->icon('heroicon-o-trash')
->requiresConfirmation()
->visible(fn (?Tenant $record): bool => (bool) $record?->trashed())
->action(function (?Tenant $record, AuditLogger $auditLogger): void {
if ($record === null) {
return;
}
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_DELETE)) {
abort(403);
}
$tenant = Tenant::withTrashed()->find($record->id);
if (! $tenant?->trashed()) {
Notification::make()
->title('Tenant must be archived first')
->danger()
->send();
return;
}
$auditLogger->log(
tenant: $tenant,
action: 'tenant.force_deleted',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]]
);
$tenant->forceDelete();
Notification::make()
->title('Tenant permanently deleted')
->success()
->send();
}),
)
->preserveVisibility()
->requireCapability(Capabilities::TENANT_DELETE)
->apply(),
]),
])
->bulkActions([
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(fn (): bool => auth()->user() instanceof User)
->authorize(fn (): bool => auth()->user() instanceof User)
->disabled(function (Collection $records): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
if ($records->isEmpty()) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return $records
->filter(fn ($record) => $record instanceof Tenant)
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
})
->tooltip(function (Collection $records): ?string {
$user = auth()->user();
if (! $user instanceof User) {
return UiTooltips::insufficientPermission();
}
if ($records->isEmpty()) {
return null;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$isDenied = $records
->filter(fn ($record) => $record instanceof Tenant)
->contains(fn (Tenant $tenant): bool => ! $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
return $isDenied ? UiTooltips::insufficientPermission() : null;
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
$eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
->filter(fn (Tenant $tenant) => $resolver->can($user, $tenant, Capabilities::TENANT_SYNC));
if ($eligible->isEmpty()) {
Notification::make()
->title('Bulk sync skipped')
->body('No eligible tenants selected.')
->icon('heroicon-o-information-circle')
->info()
->sendToDatabase($user)
->send();
return;
}
$tenantContext = Tenant::current() ?? $eligible->first();
if (! $tenantContext) {
return;
}
$ids = $eligible->pluck('id')->toArray();
$count = $eligible->count();
/** @var BulkSelectionIdentity $selection */
$selection = app(BulkSelectionIdentity::class);
$selectionIdentity = $selection->fromIds($ids);
/** @var OperationRunService $runs */
$runs = app(OperationRunService::class);
$opRun = $runs->enqueueBulkOperation(
tenant: $tenantContext,
type: 'tenant.sync',
targetScope: [
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
],
selectionIdentity: $selectionIdentity,
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
BulkTenantSyncJob::dispatch(
tenantId: (int) $tenantContext->getKey(),
userId: (int) $user->getKey(),
tenantIds: $ids,
operationRun: $operationRun,
);
},
initiator: $user,
extraContext: [
'tenant_count' => $count,
],
emitQueuedNotification: false,
);
OperationUxPresenter::queuedToast('tenant.sync')
->actions([
Actions\Action::make('view_run')
->label('View run')
->url(OperationRunLinks::view($opRun, $tenantContext)),
])
->send();
})
->deselectRecordsAfterCompletion(),
])
->headerActions([]);
}
public static function infolist(Schema $schema): Schema
{
// ... [Infolist Omitted - No Change] ...
return $schema
->schema([
Infolists\Components\TextEntry::make('name'),
Infolists\Components\TextEntry::make('tenant_id')->label('Tenant ID')->copyable(),
Infolists\Components\TextEntry::make('domain')->copyable(),
Infolists\Components\TextEntry::make('app_client_id')->label('App Client ID')->copyable(),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantStatus)),
Infolists\Components\TextEntry::make('app_status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantAppStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantAppStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantAppStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantAppStatus)),
Infolists\Components\TextEntry::make('app_notes')->label('Notes'),
Infolists\Components\TextEntry::make('created_at')->dateTime(),
Infolists\Components\TextEntry::make('updated_at')->dateTime(),
Infolists\Components\TextEntry::make('rbac_status')
->label('RBAC status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantRbacStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantRbacStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantRbacStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantRbacStatus)),
Infolists\Components\TextEntry::make('rbac_status_reason')->label('RBAC reason'),
Infolists\Components\TextEntry::make('rbac_last_checked_at')->label('RBAC last checked')->since(),
Infolists\Components\TextEntry::make('rbac_role_display_name')->label('RBAC role'),
Infolists\Components\TextEntry::make('rbac_role_definition_id')->label('Role definition ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_scope_mode')->label('RBAC scope'),
Infolists\Components\TextEntry::make('rbac_scope_id')->label('Scope ID'),
Infolists\Components\TextEntry::make('rbac_group_id')->label('RBAC group ID')->copyable(),
Infolists\Components\TextEntry::make('rbac_role_assignment_id')->label('Role assignment ID')->copyable(),
Infolists\Components\ViewEntry::make('rbac_summary')
->label('Last RBAC Setup')
->view('filament.infolists.entries.rbac-summary')
->visible(fn (Tenant $record) => filled($record->rbac_last_setup_at)),
Infolists\Components\TextEntry::make('admin_consent_url')
->label('Admin consent URL')
->state(fn (Tenant $record) => static::adminConsentUrl($record))
->visible(fn (?string $state) => filled($state))
->copyable(),
Infolists\Components\RepeatableEntry::make('permissions')
->label('Required permissions')
->state(fn (Tenant $record) => app(TenantPermissionService::class)->compare($record, persist: false, useConfiguredStub: false)['permissions'])
->schema([
Infolists\Components\TextEntry::make('key')->label('Permission')->badge(),
Infolists\Components\TextEntry::make('type')->badge(),
Infolists\Components\TextEntry::make('features')
->label('Features')
->formatStateUsing(fn ($state) => is_array($state) ? implode(', ', $state) : (string) $state),
Infolists\Components\TextEntry::make('status')
->badge()
->formatStateUsing(BadgeRenderer::label(BadgeDomain::TenantPermissionStatus))
->color(BadgeRenderer::color(BadgeDomain::TenantPermissionStatus))
->icon(BadgeRenderer::icon(BadgeDomain::TenantPermissionStatus))
->iconColor(BadgeRenderer::iconColor(BadgeDomain::TenantPermissionStatus)),
])
->columnSpanFull(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListTenants::route('/'),
'create' => Pages\CreateTenant::route('/create'),
'view' => Pages\ViewTenant::route('/{record}'),
'edit' => Pages\EditTenant::route('/{record}/edit'),
];
}
public static function getRelations(): array
{
return [
RelationManagers\TenantMembershipsRelationManager::class,
];
}
public static function rbacAction(): Actions\Action
{
// ... [RBAC Action Omitted - No Change] ...
return Actions\Action::make('setup_rbac')
->label('Setup Intune RBAC')
->icon('heroicon-o-shield-check')
->color('primary')
->form([
Forms\Components\Select::make('role_definition_id')
->label('RBAC role')
->required()
->searchable()
->optionsLimit(20)
->searchDebounce(400)
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::roleSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::formatRoleLabel(
static::resolveRoleName($record, $value),
$value ?? ''
))
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::roleSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchRolesAction($record))
->hint('Wizard grants Intune RBAC roles only. "Intune Administrator" is an Entra directory role and is not assigned here.')
->noSearchResultsMessage('No Intune RBAC roleDefinitions found (tenant may restrict RBAC or missing permission).')
->loadingMessage('Loading roles...')
->afterStateUpdated(function (Set $set, ?string $state, ?Tenant $record) {
$set('role_display_name', static::resolveRoleName($record, $state));
}),
Forms\Components\Hidden::make('role_display_name')
->dehydrated(),
Forms\Components\Select::make('scope')
->label('Scope')
->required()
->options([
'all_devices' => 'All devices (global)',
'scope_group' => 'Scope group (enter ID)',
])
->default('all_devices')
->live(),
Forms\Components\Select::make('scope_group_id')
->label('Scope group')
->searchable()
->searchPrompt('Type at least 2 characters')
->optionsLimit(20)
->searchDebounce(400)
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('scope') === 'scope_group')
->required(fn (Get $get) => $get('scope') === 'scope_group')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
Forms\Components\Select::make('group_mode')
->label('Group mode')
->required()
->options([
'create' => 'Create new security group',
'existing' => 'Use existing security group',
])
->default('create')
->live(),
Forms\Components\TextInput::make('group_name')
->label('Group name')
->default('TenantPilot-Intune-RBAC')
->visible(fn (callable $get) => $get('group_mode') === 'create'),
Forms\Components\Select::make('existing_group_id')
->label('Security group')
->searchable()
->searchPrompt('Type at least 2 characters')
->optionsLimit(20)
->searchDebounce(400)
->placeholder('Search security groups')
->visible(fn (Get $get) => $get('group_mode') === 'existing')
->required(fn (Get $get) => $get('group_mode') === 'existing')
->disabled(fn (?Tenant $record) => static::delegatedToken($record) === null)
->helperText(fn (?Tenant $record) => static::groupSearchHelper($record))
->hintAction(fn (?Tenant $record) => static::loginToSearchGroupsAction($record))
->getSearchResultsUsing(fn (string $search, ?Tenant $record) => static::groupSearchOptions($record, $search))
->getOptionLabelUsing(fn (?string $value, ?Tenant $record) => static::resolveGroupLabel($record, $value))
->noSearchResultsMessage('No security groups found')
->loadingMessage('Searching groups...'),
])
->visible(fn (Tenant $record): bool => $record->isActive())
->disabled(function (Tenant $record): bool {
$user = auth()->user();
if (! $user instanceof User) {
return true;
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
return ! $resolver->can($user, $record, Capabilities::TENANT_MANAGE);
})
->requiresConfirmation()
->action(function (
array $data,
Tenant $record,
RbacOnboardingService $service,
AuditLogger $auditLogger
) {
$user = auth()->user();
if (! $user instanceof User) {
abort(403);
}
/** @var CapabilityResolver $resolver */
$resolver = app(CapabilityResolver::class);
if (! $resolver->can($user, $record, Capabilities::TENANT_MANAGE)) {
abort(403);
}
$cacheKey = RbacDelegatedAuthController::cacheKey($record, auth()->id(), session()->getId());
$token = Cache::get($cacheKey);
if (! $token) {
Notification::make()
->title('Login to grant RBAC')
->body('Delegated login required to continue.')
->actions([
Actions\Action::make('open_rbac_login')
->label('Open RBAC login')
->url(route('admin.rbac.start', [
'tenant' => $record->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $record->external_id,
'record' => $record,
]),
])),
])
->warning()
->persistent()
->send();
return;
}
$result = $service->run($record, $data, $user, $token);
Cache::forget($cacheKey);
if ($result['status'] === 'success') {
Notification::make()
->title('RBAC setup completed')
->body(sprintf(
'Role: %s | Scope: %s | Group: %s | Assignment: %s',
$data['role_display_name'] ?? $data['role_definition_id'] ?? 'n/a',
$data['scope'] ?? 'n/a',
$result['group_id'] ?? 'n/a',
$result['role_assignment_id'] ?? 'n/a'
))
->success()
->send();
if (($data['scope'] ?? null) === 'scope_group') {
Notification::make()
->title('Scope-limited selection')
->body('RBAC scope is limited to a scope group; inventory/restore may be partial.')
->warning()
->send();
}
if (config('tenantpilot.features.conditional_access', false) === false) {
Notification::make()
->title('CA canary disabled')
->body('Conditional Access canary is disabled by feature flag.')
->warning()
->send();
}
return;
}
$auditLogger->log(
tenant: $record,
action: 'rbac.setup.failed',
resourceType: 'tenant',
resourceId: (string) $record->id,
status: 'error',
context: ['metadata' => ['error' => $result['message'] ?? 'unknown']],
);
Notification::make()
->title('RBAC setup failed')
->body($result['message'] ?? 'Unknown error')
->danger()
->send();
});
}
public static function adminConsentUrl(Tenant $tenant): ?string
{
$tenantId = $tenant->graphTenantId();
$clientId = $tenant->app_client_id;
$redirectUri = route('admin.consent.callback');
$state = sprintf('tenantpilot|%s', $tenant->id);
if (! $tenantId || ! $clientId || ! $redirectUri) {
return null;
}
// Build explicit scope list from required permissions
$requiredPermissions = config('intune_permissions.permissions', []);
$scopes = collect($requiredPermissions)
->pluck('key')
->map(fn (string $permission) => "https://graph.microsoft.com/{$permission}")
->join(' ');
// Fallback to .default if no permissions configured
if (empty($scopes)) {
$scopes = 'https://graph.microsoft.com/.default';
}
$query = http_build_query([
'client_id' => $clientId,
'state' => $state,
'redirect_uri' => $redirectUri,
'scope' => $scopes,
]);
return sprintf('https://login.microsoftonline.com/%s/v2.0/adminconsent?%s', $tenantId, $query);
}
public static function entraUrl(Tenant $tenant): ?string
{
if ($tenant->app_client_id) {
return sprintf(
'https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/%s',
$tenant->app_client_id
);
}
if ($tenant->graphTenantId()) {
return sprintf(
'https://entra.microsoft.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/Overview/tenantId/%s',
$tenant->graphTenantId()
);
}
return null;
}
private static function delegatedToken(?Tenant $tenant): ?string
{
if (! $tenant) {
return null;
}
$userKey = RbacDelegatedAuthController::cacheKey($tenant, auth()->id(), null);
$sessionKey = RbacDelegatedAuthController::cacheKey($tenant, auth()->id(), session()->getId());
return Cache::get($userKey) ?? Cache::get($sessionKey);
}
private static function loginToSearchRolesAction(?Tenant $tenant): ?Actions\Action
{
if (! $tenant) {
return null;
}
return Actions\Action::make('login_to_load_roles')
->label('Login to load roles')
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));
}
public static function roleSearchHelper(?Tenant $tenant): ?string
{
return static::delegatedToken($tenant) ? null : 'Login to load roles';
}
/**
* @return array<string, string>
*/
public static function roleSearchOptions(?Tenant $tenant, string $search): array
{
return static::searchRoleDefinitions($tenant, $search);
}
/**
* @return array<string, string>
*/
private static function searchRoleDefinitions(?Tenant $tenant, string $search): array
{
if (! $tenant) {
return [];
}
$token = static::delegatedToken($tenant);
if (! $token) {
return [];
}
if (Str::contains(Str::lower($search), 'intune administrator')) {
Notification::make()
->title('Intune Administrator is a directory role')
->body('Das ist eine Entra Directory Role, nicht Intune RBAC; wird vom Wizard nicht vergeben.')
->warning()
->persistent()
->send();
}
$filter = mb_strlen($search) >= 2
? sprintf("startswith(displayName,'%s')", static::escapeOdataValue($search))
: null;
$query = [
'$select' => 'id,displayName,isBuiltIn',
'$top' => 20,
];
if ($filter) {
$query['$filter'] = $filter;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'deviceManagement/roleDefinitions',
[
'query' => $query,
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
static::notifyRoleLookupFailure();
return [];
}
if ($response->failed()) {
static::notifyRoleLookupFailure();
return [];
}
$roles = collect($response->data['value'] ?? [])
->filter(fn (array $role) => filled($role['id'] ?? null))
->mapWithKeys(fn (array $role) => [
$role['id'] => static::formatRoleLabel($role['displayName'] ?? null, $role['id']),
])
->all();
if (empty($roles)) {
static::logEmptyRoleDefinitions($tenant, $response->data['value'] ?? []);
}
return $roles;
}
private static function resolveRoleName(?Tenant $tenant, ?string $roleId): ?string
{
if (! $tenant || blank($roleId)) {
return $roleId;
}
$token = static::delegatedToken($tenant);
if (! $token) {
return $roleId;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
"deviceManagement/roleDefinitions/{$roleId}",
[
'query' => [
'$select' => 'id,displayName',
],
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
static::notifyRoleLookupFailure();
return $roleId;
}
if ($response->failed()) {
static::notifyRoleLookupFailure();
return $roleId;
}
$displayName = $response->data['displayName'] ?? null;
$id = $response->data['id'] ?? $roleId;
return $displayName ?: $id;
}
private static function formatRoleLabel(?string $displayName, string $id): string
{
$suffix = sprintf(' (%s)', Str::limit($id, 8, ''));
return trim(($displayName ?: 'RBAC role').$suffix);
}
private static function escapeOdataValue(string $value): string
{
return str_replace("'", "''", $value);
}
private static function notifyRoleLookupFailure(): void
{
Notification::make()
->title('Role lookup failed')
->body('Delegated session may have expired. Login again to load Intune RBAC roles.')
->danger()
->send();
}
private static function logEmptyRoleDefinitions(Tenant $tenant, array $roles): void
{
$names = collect($roles)->pluck('displayName')->filter()->take(5)->values()->all();
Log::warning('rbac.role_definitions.empty', [
'tenant_id' => $tenant->id,
'count' => count($roles),
'sample' => $names,
]);
try {
app(AuditLogger::class)->log(
tenant: $tenant,
action: 'rbac.roles.empty',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'warning',
context: ['metadata' => ['count' => count($roles), 'sample' => $names]],
);
} catch (Throwable) {
Log::notice('rbac.role_definitions.audit_failed', ['tenant_id' => $tenant->id]);
}
}
private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Action
{
if (! $tenant) {
return null;
}
return Actions\Action::make('login_to_search_groups')
->label('Login to search groups')
->url(route('admin.rbac.start', [
'tenant' => $tenant->graphTenantId(),
'return' => route('filament.admin.resources.tenants.view', [
'tenant' => $tenant->external_id,
'record' => $tenant,
]),
]));
}
public static function groupSearchHelper(?Tenant $tenant): ?string
{
if (! $tenant) {
return null;
}
return static::delegatedToken($tenant) ? null : 'Login to search groups';
}
/**
* @return array<string, string>
*/
public static function groupSearchOptions(?Tenant $tenant, string $search): array
{
if (! $tenant || mb_strlen($search) < 2) {
return [];
}
$token = static::delegatedToken($tenant);
if (! $token) {
return [];
}
$filter = sprintf(
"securityEnabled eq true and startswith(displayName,'%s')",
static::escapeOdataValue($search)
);
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups',
[
'query' => [
'$select' => 'id,displayName',
'$top' => 20,
'$filter' => $filter,
],
] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return [];
}
if ($response->failed()) {
return [];
}
return collect($response->data['value'] ?? [])
->filter(fn (array $group) => filled($group['id'] ?? null))
->mapWithKeys(fn (array $group) => [
(string) $group['id'] => EntraGroupLabelResolver::formatLabel($group['displayName'] ?? null, (string) $group['id']),
])
->all();
}
private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?string
{
if (! $tenant || blank($groupId)) {
return $groupId;
}
$token = static::delegatedToken($tenant);
if (! $token) {
return $groupId;
}
try {
$response = app(GraphClientInterface::class)->request(
'GET',
'groups/'.$groupId,
[] + $tenant->graphOptions() + [
'access_token' => $token,
]
);
} catch (Throwable) {
return $groupId;
}
if ($response->failed()) {
return $groupId;
}
return EntraGroupLabelResolver::formatLabel(
$response->data['displayName'] ?? null,
$response->data['id'] ?? $groupId
);
}
public static function verifyTenant(
Tenant $tenant,
TenantConfigService $configService,
TenantPermissionService $permissionService,
RbacHealthService $rbacHealthService,
AuditLogger $auditLogger
): void {
$configResult = $configService->testConnectivity($tenant);
// Fetch actual permissions from Graph API with liveCheck=true
$permissions = $permissionService->compare($tenant, null, true, true);
$rbac = $rbacHealthService->check($tenant);
$appStatus = $configResult['success']
? 'ok'
: ($configResult['requires_consent'] ? 'consent_required' : 'error');
$tenant->update([
'app_status' => $appStatus,
'app_notes' => $configResult['error_message'],
]);
$user = auth()->user();
$auditLogger->log(
tenant: $tenant,
action: 'tenant.config.verified',
context: [
'metadata' => [
'app_status' => $appStatus,
'error' => $configResult['error_message'],
],
],
actorId: $user?->id,
actorEmail: $user?->email,
actorName: $user?->name,
status: $appStatus === 'ok' ? 'success' : 'error',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
);
$auditLogger->log(
tenant: $tenant,
action: 'tenant.permissions.checked',
context: [
'metadata' => [
'overall_status' => $permissions['overall_status'],
],
],
actorId: $user?->id,
actorEmail: $user?->email,
actorName: $user?->name,
status: match ($permissions['overall_status']) {
'granted' => 'success',
'error' => 'error',
default => 'partial',
},
resourceType: 'tenant',
resourceId: (string) $tenant->id,
);
$auditLogger->log(
tenant: $tenant,
action: 'tenant.rbac.checked',
context: [
'metadata' => [
'status' => $rbac['status'],
'reason' => $rbac['reason'] ?? null,
],
],
status: $rbac['status'] === 'ok' ? 'success' : 'error',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
);
$notification = Notification::make()
->title($configResult['success'] ? 'Configuration verified' : 'Verification failed')
->body($configResult['success']
? 'Graph connectivity confirmed. Permission status: '.$permissions['overall_status']
: ($configResult['error_message'] ?? 'Graph connectivity failed'));
if ($configResult['success']) {
$notification->success();
} elseif ($configResult['requires_consent']) {
$notification->warning();
} else {
$notification->danger();
}
$notification->send();
}
}