TenantAtlas/app/Filament/Resources/TenantResource.php

950 lines
37 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Models\Tenant;
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 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\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
use UnitEnum;
class TenantResource extends Resource
{
protected static ?string $model = Tenant::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-building-office-2';
protected static string|UnitEnum|null $navigationGroup = 'Settings';
public static function form(Schema $schema): Schema
{
return $schema
->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<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 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<string, string>
*/
public static function groupSearchOptions(?Tenant $tenant, string $search): array
{
return static::searchSecurityGroups($tenant, $search);
}
/**
* @return array<string, string>
*/
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();
}
}