schema([ Forms\Components\TextInput::make('name') ->required() ->maxLength(255), 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 table(Table $table): Table { return $table ->modifyQueryUsing(fn (\Illuminate\Database\Eloquent\Builder $query) => $query->withTrashed()) ->columns([ Tables\Columns\TextColumn::make('name') ->searchable(), Tables\Columns\TextColumn::make('tenant_id') ->label('Tenant ID') ->copyable() ->searchable(), Tables\Columns\TextColumn::make('domain') ->copyable() ->toggleable(), Tables\Columns\IconColumn::make('is_current') ->label('Current') ->boolean(), Tables\Columns\TextColumn::make('status') ->badge() ->sortable(), Tables\Columns\TextColumn::make('app_status') ->badge(), Tables\Columns\TextColumn::make('created_at') ->dateTime() ->since(), ]) ->filters([ Tables\Filters\TrashedFilter::make() ->label('Archive filter') ->placeholder('Active only') ->trueLabel('Active + archived') ->falseLabel('Archived only') ->default(true), Tables\Filters\SelectFilter::make('app_status') ->options([ 'ok' => 'OK', 'consent_required' => 'Consent required', 'error' => 'Error', 'unknown' => 'Unknown', ]), ]) ->actions([ Actions\ViewAction::make(), ActionGroup::make([ Actions\EditAction::make(), Actions\RestoreAction::make() ->label('Restore') ->color('success') ->successNotificationTitle('Tenant reactivated') ->after(function (Tenant $record, AuditLogger $auditLogger) { $auditLogger->log( tenant: $record, action: 'tenant.restored', resourceType: 'tenant', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['tenant_id' => $record->tenant_id]] ); }), Actions\Action::make('makeCurrent') ->label('Make current') ->color('success') ->icon('heroicon-o-check-circle') ->requiresConfirmation() ->visible(fn (Tenant $record) => $record->isActive() && ! $record->is_current) ->action(function (Tenant $record, AuditLogger $auditLogger) { $record->makeCurrent(); $auditLogger->log( tenant: $record, action: 'tenant.current_set', resourceType: 'tenant', resourceId: (string) $record->id, status: 'success', context: ['metadata' => ['tenant_id' => $record->tenant_id]] ); Notification::make() ->title('Current tenant updated') ->success() ->send(); }), Actions\Action::make('admin_consent') ->label('Admin consent') ->icon('heroicon-o-clipboard-document') ->url(fn (Tenant $record) => static::adminConsentUrl($record)) ->visible(fn (Tenant $record) => static::adminConsentUrl($record) !== null) ->openUrlInNewTab(), Actions\Action::make('verify') ->label('Verify configuration') ->icon('heroicon-o-check-badge') ->color('primary') ->requiresConfirmation() ->action(function ( Tenant $record, TenantConfigService $configService, TenantPermissionService $permissionService, RbacHealthService $rbacHealthService, AuditLogger $auditLogger ) { static::verifyTenant($record, $configService, $permissionService, $rbacHealthService, $auditLogger); }), static::rbacAction(), Actions\Action::make('archive') ->label('Deactivate') ->color('danger') ->icon('heroicon-o-archive-box-x-mark') ->requiresConfirmation() ->visible(fn (Tenant $record) => ! $record->trashed()) ->action(function (Tenant $record, AuditLogger $auditLogger) { $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(); }), Actions\Action::make('forceDelete') ->label('Force delete') ->color('danger') ->icon('heroicon-o-trash') ->requiresConfirmation() ->visible(fn (?Tenant $record) => $record?->trashed()) ->action(function (?Tenant $record, AuditLogger $auditLogger) { if ($record === null) { return; } $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(); }), ])->icon('heroicon-o-ellipsis-vertical'), ]) ->bulkActions([]) ->headerActions([]); } public static function infolist(Schema $schema): Schema { 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(), Infolists\Components\TextEntry::make('app_status')->badge(), 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(), 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)['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(), ]) ->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 rbacAction(): Actions\Action { 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)) ->hint(fn (?Tenant $record) => static::groupSearchHelper($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)) ->hint(fn (?Tenant $record) => static::groupSearchHelper($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) => $record->isActive()) ->requiresConfirmation() ->action(function ( array $data, Tenant $record, RbacOnboardingService $service, AuditLogger $auditLogger ) { $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', $record), ])), ]) ->warning() ->persistent() ->send(); return; } $actor = auth()->user(); $result = $service->run($record, $data, $actor, $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), ])); } public static function roleSearchHelper(?Tenant $tenant): ?string { return static::delegatedToken($tenant) ? null : 'Login to load roles'; } /** * @return array */ public static function roleSearchOptions(?Tenant $tenant, string $search): array { return static::searchRoleDefinitions($tenant, $search); } /** * @return array */ 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 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), ])); } public static function groupSearchHelper(?Tenant $tenant): ?string { return static::delegatedToken($tenant) ? null : 'Login to search groups'; } /** * @return array */ public static function groupSearchOptions(?Tenant $tenant, string $search): array { return static::searchSecurityGroups($tenant, $search); } /** * @return array */ private static function searchSecurityGroups(?Tenant $tenant, string $search): array { if (! $tenant || mb_strlen($search) < 2) { return []; } $token = static::delegatedToken($tenant); if (! $token) { return []; } try { $response = app(GraphClientInterface::class)->request( 'GET', 'groups', [ 'query' => [ '$filter' => sprintf( "securityEnabled eq true and startswith(displayName,'%s')", static::escapeOdataValue($search) ), '$select' => 'id,displayName', '$top' => 20, ], ] + $tenant->graphOptions() + [ 'access_token' => $token, ] ); } catch (Throwable) { static::notifyGroupLookupFailure(); return []; } if ($response->failed()) { static::notifyGroupLookupFailure(); return []; } return collect($response->data['value'] ?? []) ->filter(fn (array $group) => filled($group['id'] ?? null)) ->mapWithKeys(fn (array $group) => [ $group['id'] => static::formatGroupLabel($group['displayName'] ?? null, $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}", [ 'query' => [ '$select' => 'id,displayName', ], ] + $tenant->graphOptions() + [ 'access_token' => $token, ] ); } catch (Throwable) { static::notifyGroupLookupFailure(); return $groupId; } if ($response->failed()) { static::notifyGroupLookupFailure(); return $groupId; } $displayName = $response->data['displayName'] ?? null; $id = $response->data['id'] ?? $groupId; return static::formatGroupLabel($displayName, $id); } private static function formatGroupLabel(?string $displayName, string $id): string { $suffix = sprintf(' (%s)', Str::limit($id, 8, '')); return trim(($displayName ?: 'Security group').$suffix); } private static function escapeOdataValue(string $value): string { return str_replace("'", "''", $value); } private static function notifyGroupLookupFailure(): void { Notification::make() ->title('Group lookup failed') ->body('Delegated session may have expired. Login again to search security groups.') ->danger() ->send(); } 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']) { 'ok' => '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(); } }