Summary Adds a tenant-scoped Entra Groups “Directory Cache” to enable DB-only group name resolution across the app (no render-time Graph calls), plus sync runs + observability. What’s included • Entra Groups cache • New entra_groups storage (tenant-scoped) for group metadata (no memberships). • Retention semantics: groups become stale / retained per spec (no hard delete on first miss). • Group Sync Runs • New “Group Sync Runs” UI (list + detail) with tenant isolation (403 on cross-tenant access). • Manual “Sync Groups” action: creates/reuses a run, dispatches job, DB notification with “View run” link. • Scheduled dispatcher command wired in console.php. • DB-only label resolution (US3) • Shared EntraGroupLabelResolver with safe fallback Unresolved (…last8) and UUID guarding. • Refactors to prefer cached names (no typeahead / no live Graph) in: • Tenant RBAC group selects • Policy version assignments widget • Restore results + restore wizard group mapping labels Safety / Guardrails • No render-time Graph calls: fail-hard guard test verifies UI paths don’t call GraphClientInterface during page render. • Tenant isolation & authorization: policies + scoped queries enforced (cross-tenant access returns 403, not 404). • Data minimization: only group metadata is cached (no membership/owners). Tests / Verification • Added/updated tests under tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups: • Start sync → run record + job dispatch + upserts • Retention purge semantics • Scheduled dispatch wiring • Render-time Graph guard • UI/resource access isolation • Ran: • ./vendor/bin/pint --dirty • ./vendor/bin/sail artisan test tests/Feature/DirectoryGroups • ./vendor/bin/sail artisan test tests/Unit/DirectoryGroups Notes / Follow-ups • UI polish remains (picker/lookup UX, consistent progress widget/toasts across modules, navigation grouping). • pr-gate checklist still has non-blocking open items (mostly UX/ops polish); requirements gate is green. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #57
204 lines
7.6 KiB
PHP
204 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire;
|
|
|
|
use App\Filament\Resources\EntraGroupResource;
|
|
use App\Filament\Resources\EntraGroupSyncRunResource;
|
|
use App\Models\EntraGroup;
|
|
use App\Models\Tenant;
|
|
use Filament\Actions\Action;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Filters\SelectFilter;
|
|
use Filament\Tables\Table;
|
|
use Filament\Tables\TableComponent;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
|
|
class EntraGroupCachePickerTable extends TableComponent
|
|
{
|
|
public string $sourceGroupId;
|
|
|
|
public function mount(string $sourceGroupId): void
|
|
{
|
|
$this->sourceGroupId = $sourceGroupId;
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
$tenantId = Tenant::current()?->getKey();
|
|
|
|
$query = EntraGroup::query();
|
|
|
|
if ($tenantId) {
|
|
$query->where('tenant_id', $tenantId);
|
|
} else {
|
|
$query->whereRaw('1 = 0');
|
|
}
|
|
|
|
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
|
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
|
|
|
return $table
|
|
->queryStringIdentifier('entraGroupCachePicker')
|
|
->query($query)
|
|
->defaultSort('display_name')
|
|
->paginated([10, 25, 50])
|
|
->defaultPaginationPageOption(10)
|
|
->searchable()
|
|
->searchPlaceholder('Search groups…')
|
|
->deferLoading(! app()->runningUnitTests())
|
|
->columns([
|
|
TextColumn::make('display_name')
|
|
->label('Name')
|
|
->searchable()
|
|
->sortable()
|
|
->wrap()
|
|
->limit(60),
|
|
TextColumn::make('type')
|
|
->label('Type')
|
|
->badge()
|
|
->state(fn (EntraGroup $record): string => $this->groupTypeLabel($this->groupType($record)))
|
|
->color(fn (EntraGroup $record): string => $this->groupTypeColor($this->groupType($record)))
|
|
->toggleable(),
|
|
TextColumn::make('entra_id')
|
|
->label('ID')
|
|
->formatStateUsing(fn (?string $state): string => filled($state) ? ('…'.substr($state, -8)) : '—')
|
|
->extraAttributes(['class' => 'font-mono'])
|
|
->toggleable(),
|
|
TextColumn::make('last_seen_at')
|
|
->label('Last seen')
|
|
->since()
|
|
->sortable(),
|
|
])
|
|
->filters([
|
|
SelectFilter::make('stale')
|
|
->label('Stale')
|
|
->options([
|
|
'1' => 'Stale',
|
|
'0' => 'Fresh',
|
|
])
|
|
->query(function (Builder $query, array $data) use ($cutoff): Builder {
|
|
$value = $data['value'] ?? null;
|
|
|
|
if ($value === null || $value === '') {
|
|
return $query;
|
|
}
|
|
|
|
if ((string) $value === '1') {
|
|
return $query->where(function (Builder $q) use ($cutoff): void {
|
|
$q->whereNull('last_seen_at')
|
|
->orWhere('last_seen_at', '<', $cutoff);
|
|
});
|
|
}
|
|
|
|
return $query->where('last_seen_at', '>=', $cutoff);
|
|
}),
|
|
|
|
SelectFilter::make('group_type')
|
|
->label('Type')
|
|
->options([
|
|
'security' => 'Security',
|
|
'microsoft365' => 'Microsoft 365',
|
|
'mail' => 'Mail-enabled',
|
|
'unknown' => 'Unknown',
|
|
])
|
|
->query(function (Builder $query, array $data): Builder {
|
|
$value = (string) ($data['value'] ?? '');
|
|
|
|
if ($value === '') {
|
|
return $query;
|
|
}
|
|
|
|
return match ($value) {
|
|
'microsoft365' => $query->whereJsonContains('group_types', 'Unified'),
|
|
'security' => $query
|
|
->where('security_enabled', true)
|
|
->where(function (Builder $q): void {
|
|
$q->whereNull('group_types')
|
|
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
}),
|
|
'mail' => $query
|
|
->where('mail_enabled', true)
|
|
->where(function (Builder $q): void {
|
|
$q->whereNull('group_types')
|
|
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
}),
|
|
'unknown' => $query
|
|
->where(function (Builder $q): void {
|
|
$q->whereNull('group_types')
|
|
->orWhereJsonDoesntContain('group_types', 'Unified');
|
|
})
|
|
->where('security_enabled', false)
|
|
->where('mail_enabled', false),
|
|
default => $query,
|
|
};
|
|
}),
|
|
])
|
|
->actions([
|
|
Action::make('select')
|
|
->label('Select')
|
|
->icon('heroicon-o-check')
|
|
->color('primary')
|
|
->action(function (EntraGroup $record): void {
|
|
$this->dispatch('entra-group-cache-picked', sourceGroupId: $this->sourceGroupId, entraId: (string) $record->entra_id);
|
|
}),
|
|
])
|
|
->emptyStateHeading('No cached groups found')
|
|
->emptyStateDescription('Run “Sync Groups” first, then come back here.')
|
|
->emptyStateActions([
|
|
Action::make('open_groups')
|
|
->label('Directory Groups')
|
|
->icon('heroicon-o-user-group')
|
|
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())),
|
|
Action::make('open_sync_runs')
|
|
->label('Group Sync Runs')
|
|
->icon('heroicon-o-clock')
|
|
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())),
|
|
]);
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.entra-group-cache-picker-table');
|
|
}
|
|
|
|
private function groupType(EntraGroup $record): string
|
|
{
|
|
$groupTypes = $record->group_types;
|
|
|
|
if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) {
|
|
return 'microsoft365';
|
|
}
|
|
|
|
if ($record->security_enabled) {
|
|
return 'security';
|
|
}
|
|
|
|
if ($record->mail_enabled) {
|
|
return 'mail';
|
|
}
|
|
|
|
return 'unknown';
|
|
}
|
|
|
|
private function groupTypeLabel(string $type): string
|
|
{
|
|
return match ($type) {
|
|
'microsoft365' => 'Microsoft 365',
|
|
'security' => 'Security',
|
|
'mail' => 'Mail-enabled',
|
|
default => 'Unknown',
|
|
};
|
|
}
|
|
|
|
private function groupTypeColor(string $type): string
|
|
{
|
|
return match ($type) {
|
|
'microsoft365' => 'info',
|
|
'security' => 'success',
|
|
'mail' => 'warning',
|
|
default => 'gray',
|
|
};
|
|
}
|
|
}
|