950 lines
37 KiB
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();
|
|
}
|
|
}
|