TenantAtlas/app/Filament/Resources/TenantResource.php
ahmido 3030dd9af2 054-unify-runs-suitewide (#63)
Summary

Kurz: Implementiert Feature 054 — canonical OperationRun-flow, Monitoring UI, dispatch-safety, notifications, dedupe, plus small UX safety clarifications (RBAC group search delegated; Restore group mapping DB-only).
What Changed

Core service: OperationRun lifecycle, dedupe and dispatch helpers — OperationRunService.php.
Model + migration: OperationRun model and migration — OperationRun.php, 2026_01_16_180642_create_operation_runs_table.php.
Notifications: queued + terminal DB notifications (initiator-only) — OperationRunQueued.php, OperationRunCompleted.php.
Monitoring UI: Filament list/detail + Livewire pieces (DB-only render) — OperationRunResource.php and related pages/views.
Start surfaces / Jobs: instrumented start surfaces, job middleware, and job updates to use canonical runs — multiple app/Jobs/* and app/Filament/* updates (see tests for full coverage).
RBAC + Restore UX clarifications: RBAC group search is delegated-Graph-based and disabled without delegated token; Restore group mapping remains DB-only (directory cache) and helper text always visible — TenantResource.php, RestoreRunResource.php.
Specs / Constitution: updated spec & quickstart and added one-line constitution guideline about Graph usage:
spec.md
quickstart.md
constitution.md
Tests & Verification

Unit / Feature tests added/updated for run lifecycle, notifications, idempotency, and UI guards: see tests/Feature/* (notably OperationRunServiceTest, MonitoringOperationsTest, OperationRunNotificationTest, and various Filament feature tests).
Full test run locally: ./vendor/bin/sail artisan test → 587 passed, 5 skipped.
Migrations

Adds create_operation_runs_table migration; run php artisan migrate in staging after review.
Notes / Rationale

Monitoring pages are explicitly DB-only at render time (no Graph calls). Start surfaces enqueue work only and return a “View run” link.
Delegated Graph access is used only for explicit user actions (RBAC group search); restore mapping intentionally uses cached DB data only to avoid render-time Graph calls.
Dispatch wrapper marks runs failed immediately if background dispatch throws synchronously to avoid misleading “queued” states.
Upgrade / Deploy Considerations

Run migrations: ./vendor/bin/sail artisan migrate.
Background workers should be running to process queued jobs (recommended to monitor queue health during rollout).
No secret or token persistence changes.
PR checklist

 Tests updated/added for changed behavior
 Specs updated: 054-unify-runs-suitewide docs + quickstart
 Constitution note added (.specify)
 Pint formatting applied

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #63
2026-01-17 22:25:00 +00:00

1220 lines
50 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantResource\Pages;
use App\Http\Controllers\RbacDelegatedAuthController;
use App\Jobs\BulkTenantSyncJob;
use App\Jobs\SyncPoliciesJob;
use App\Models\Tenant;
use App\Models\User;
use App\Services\BulkOperationService;
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\Support\OperationRunLinks;
use App\Support\TenantRole;
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\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 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');
}
$tenantIds = $user->tenants()
->withTrashed()
->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()
->color(fn (?string $state) => match ($state) {
'prod' => 'danger',
'dev' => 'warning',
'staging' => 'info',
default => 'gray',
})
->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()
->sortable(),
Tables\Columns\TextColumn::make('app_status')
->badge(),
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([
Actions\ViewAction::make(),
ActionGroup::make([
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->canSyncTenant($record);
})
->action(function (Tenant $record, AuditLogger $auditLogger): void {
// Phase 3: Canonical Operation Run Start
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $record,
type: 'policy.sync',
inputs: ['scope' => 'full'],
initiator: auth()->user()
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
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;
}
SyncPoliciesJob::dispatch($record->getKey(), 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]],
);
Notification::make()
->title('Sync started')
->body("Sync dispatched for {$record->name}.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->actions([
Actions\Action::make('view_run')
->label('View Run')
->url(OperationRunLinks::view($opRun, $record)),
])
->sendToDatabase(auth()->user())
->send();
}),
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()),
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('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(),
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([
Actions\BulkAction::make('syncSelected')
->label('Sync selected')
->icon('heroicon-o-arrow-path')
->color('warning')
->requiresConfirmation()
->visible(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->authorize(function (): bool {
$user = auth()->user();
if (! $user instanceof User) {
return false;
}
return $user->tenants()
->whereIn('role', [
TenantRole::Owner->value,
TenantRole::Manager->value,
TenantRole::Operator->value,
])
->exists();
})
->action(function (Collection $records, AuditLogger $auditLogger): void {
$user = auth()->user();
if (! $user instanceof User) {
return;
}
$eligible = $records
->filter(fn ($record) => $record instanceof Tenant && $record->isActive())
->filter(fn (Tenant $tenant) => $user->canSyncTenant($tenant));
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();
$service = app(BulkOperationService::class);
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
foreach ($eligible as $tenant) {
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
// Bulk operation across tenants is a higher level concept.
// Keeping it as is for now or migrating individually.
// If we want each tenant sync to show in its Monitoring, we should create opRun for each.
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRun(
tenant: $tenant,
type: 'policy.sync',
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
initiator: $user
);
SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun);
$auditLogger->log(
tenant: $tenant,
action: 'tenant.sync_dispatched',
resourceType: 'tenant',
resourceId: (string) $tenant->id,
status: 'success',
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]],
);
}
$count = $eligible->count();
Notification::make()
->title('Bulk sync started')
->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.")
->icon('heroicon-o-arrow-path')
->iconColor('warning')
->success()
->duration(8000)
->sendToDatabase($user)
->send();
BulkTenantSyncJob::dispatch($run->id);
})
->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()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'gray',
'suspended' => 'warning',
'error' => 'danger',
default => 'gray',
}),
Infolists\Components\TextEntry::make('app_status')
->badge()
->color(fn (string $state): string => match ($state) {
'ok', 'configured' => 'success',
'pending' => 'warning',
'error' => 'danger',
'requires_consent' => 'warning',
default => 'gray',
}),
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()
->color(fn (string $state): string => match ($state) {
'ok', 'configured' => 'success',
'manual_assignment_required' => 'warning',
'error', 'failed' => 'danger',
'not_configured' => 'gray',
default => 'gray',
}),
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()
->color(fn (string $state): string => match ($state) {
'granted' => 'success',
'missing' => 'warning',
'error' => 'danger',
default => 'gray',
}),
])
->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
{
// ... [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) => $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', [
'tenant' => $record->external_id,
'record' => $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' => $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();
}
}