Compare commits
No commits in common. "51a752d57fb68178e7acb9ab8276f4145a2e1ad1" and "bcf4996a1e28d7edf382aa0baf7f7c3c15ee5f52" have entirely different histories.
51a752d57f
...
bcf4996a1e
@ -1,116 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantpilotDispatchDirectoryGroupsSync extends Command
|
||||
{
|
||||
protected $signature = 'tenantpilot:directory-groups:dispatch {--tenant=* : Limit to tenant_id/external_id}';
|
||||
|
||||
protected $description = 'Dispatch scheduled directory group sync runs (idempotent per tenant minute-slot).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! (bool) config('directory_groups.schedule.enabled', false)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now('UTC');
|
||||
$timeUtc = (string) config('directory_groups.schedule.time_utc', '02:00');
|
||||
|
||||
if (! $this->isDueAt($now, $timeUtc)) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! class_exists(\App\Jobs\EntraGroupSyncJob::class)) {
|
||||
$this->warn('EntraGroupSyncJob is not available; skipping scheduled directory group sync dispatch.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$tenantIdentifiers = array_values(array_filter(array_map('strval', array_merge(
|
||||
(array) $this->option('tenant'),
|
||||
(array) config('directory_groups.schedule.tenants', []),
|
||||
))));
|
||||
|
||||
$tenants = $this->resolveTenants($tenantIdentifiers);
|
||||
|
||||
$selectionKey = 'groups-v1:all';
|
||||
$slotKey = $now->format('YmdHi').'Z';
|
||||
|
||||
$created = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($tenants as $tenant) {
|
||||
$inserted = DB::table('entra_group_sync_runs')->insertOrIgnore([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
'status' => 'pending',
|
||||
'initiator_user_id' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($inserted === 1) {
|
||||
$created++;
|
||||
|
||||
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
||||
tenantId: $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: $slotKey,
|
||||
));
|
||||
} else {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'Scanned %d tenant(s), created %d run(s), skipped %d duplicate run(s).',
|
||||
$tenants->count(),
|
||||
$created,
|
||||
$skipped,
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $tenantIdentifiers
|
||||
*/
|
||||
private function resolveTenants(array $tenantIdentifiers): \Illuminate\Support\Collection
|
||||
{
|
||||
$query = Tenant::activeQuery();
|
||||
|
||||
if ($tenantIdentifiers !== []) {
|
||||
$query->where(function ($subQuery) use ($tenantIdentifiers) {
|
||||
foreach ($tenantIdentifiers as $identifier) {
|
||||
if (ctype_digit($identifier)) {
|
||||
$subQuery->orWhereKey((int) $identifier);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$subQuery->orWhere('tenant_id', $identifier)
|
||||
->orWhere('external_id', $identifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
}
|
||||
|
||||
private function isDueAt(CarbonImmutable $now, string $timeUtc): bool
|
||||
{
|
||||
if (! preg_match('/^(?<hour>[01]\\d|2[0-3]):(?<minute>[0-5]\\d)$/', $timeUtc, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $matches['hour'] === (int) $now->format('H')
|
||||
&& (int) $matches['minute'] === (int) $now->format('i');
|
||||
}
|
||||
}
|
||||
@ -1,235 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Support\RunIdempotency;
|
||||
use BackedEnum;
|
||||
use Filament\Pages\Page;
|
||||
use UnitEnum;
|
||||
|
||||
class DriftLanding extends Page
|
||||
{
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-arrows-right-left';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Drift';
|
||||
|
||||
protected string $view = 'filament.pages.drift-landing';
|
||||
|
||||
public ?string $state = null;
|
||||
|
||||
public ?string $message = null;
|
||||
|
||||
public ?string $scopeKey = null;
|
||||
|
||||
public ?int $baselineRunId = null;
|
||||
|
||||
public ?int $currentRunId = null;
|
||||
|
||||
public ?string $baselineFinishedAt = null;
|
||||
|
||||
public ?string $currentFinishedAt = null;
|
||||
|
||||
public ?int $bulkOperationRunId = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $statusCounts = null;
|
||||
|
||||
public static function canAccess(): bool
|
||||
{
|
||||
return FindingResource::canAccess();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
if (! $user instanceof User) {
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$latestSuccessful = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No successful inventory runs found yet.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||
$this->scopeKey = $scopeKey;
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
$comparison = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
if ($comparison === null) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'Need at least 2 successful runs for this scope to calculate drift.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$baseline = $comparison['baseline'];
|
||||
$current = $comparison['current'];
|
||||
|
||||
$this->baselineRunId = (int) $baseline->getKey();
|
||||
$this->currentRunId = (int) $current->getKey();
|
||||
|
||||
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
||||
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$exists = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
$this->state = 'ready';
|
||||
$newCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestRun = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
if ($activeRun instanceof BulkOperationRun) {
|
||||
$this->state = 'generating';
|
||||
$this->bulkOperationRunId = (int) $activeRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
|
||||
$this->state = 'ready';
|
||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||
|
||||
$newCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||
|
||||
if ($newCount === 0) {
|
||||
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) {
|
||||
$this->state = 'error';
|
||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bulkOperationService = app(BulkOperationService::class);
|
||||
$run = $bulkOperationService->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'drift',
|
||||
action: 'generate',
|
||||
itemIds: [$scopeKey],
|
||||
totalItems: 1,
|
||||
);
|
||||
|
||||
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||
|
||||
$this->state = 'generating';
|
||||
$this->bulkOperationRunId = (int) $run->getKey();
|
||||
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
bulkOperationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
public function getFindingsUrl(): string
|
||||
{
|
||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getBaselineRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->baselineRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getCurrentRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->currentRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||
}
|
||||
|
||||
public function getBulkRunUrl(): ?string
|
||||
{
|
||||
if (! is_int($this->bulkOperationRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BulkOperationRunResource::getUrl('view', ['record' => $this->bulkOperationRunId], tenant: Tenant::current());
|
||||
}
|
||||
}
|
||||
@ -1,211 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource\Pages;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class EntraGroupResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = EntraGroup::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Groups';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Group')
|
||||
->schema([
|
||||
TextEntry::make('display_name')->label('Name'),
|
||||
TextEntry::make('entra_id')->label('Entra ID')->copyable(),
|
||||
TextEntry::make('type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record))),
|
||||
TextEntry::make('security_enabled')->label('Security')->badge(),
|
||||
TextEntry::make('mail_enabled')->label('Mail')->badge(),
|
||||
TextEntry::make('last_seen_at')->label('Last seen')->dateTime()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Raw groupTypes')
|
||||
->schema([
|
||||
ViewEntry::make('group_types')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EntraGroup $record) => $record->group_types ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('display_name')
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')->label('Name')->searchable(),
|
||||
Tables\Columns\TextColumn::make('entra_id')->label('Entra ID')->copyable()->toggleable(),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->state(fn (EntraGroup $record): string => static::groupTypeLabel(static::groupType($record)))
|
||||
->color(fn (EntraGroup $record): string => static::groupTypeColor(static::groupType($record))),
|
||||
Tables\Columns\TextColumn::make('last_seen_at')->since()->label('Last seen'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('stale')
|
||||
->label('Stale')
|
||||
->options([
|
||||
'1' => 'Stale',
|
||||
'0' => 'Fresh',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
|
||||
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([
|
||||
Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()->latest('id');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEntraGroups::route('/'),
|
||||
'view' => Pages\ViewEntraGroup::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static 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 static function groupTypeLabel(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'Microsoft 365',
|
||||
'security' => 'Security',
|
||||
'mail' => 'Mail-enabled',
|
||||
default => 'Unknown',
|
||||
};
|
||||
}
|
||||
|
||||
private static function groupTypeColor(string $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
'microsoft365' => 'info',
|
||||
'security' => 'success',
|
||||
'mail' => 'warning',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroups extends ListRecords
|
||||
{
|
||||
protected static string $resource = EntraGroupResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('view_group_sync_runs')
|
||||
->label('Group Sync Runs')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => (bool) Tenant::current()),
|
||||
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
return $role?->canSync() ?? false;
|
||||
})
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
if (! ($role?->canSync() ?? false)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $selectionKey)
|
||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof EntraGroupSyncRun) {
|
||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'directory_groups',
|
||||
'run_id' => (int) $existing->getKey(),
|
||||
'status' => $normalizedStatus,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = EntraGroupSyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
dispatch(new EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
));
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'directory_groups',
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'queued',
|
||||
]));
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEntraGroup extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EntraGroupResource::class;
|
||||
}
|
||||
@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class EntraGroupSyncRunResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = EntraGroupSyncRun::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Group Sync Runs';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Sync Run')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)),
|
||||
TextEntry::make('selection_key')->label('Selection'),
|
||||
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
|
||||
TextEntry::make('started_at')->dateTime(),
|
||||
TextEntry::make('finished_at')->dateTime(),
|
||||
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
|
||||
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
||||
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||
TextEntry::make('error_count')->label('Errors')->numeric(),
|
||||
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
|
||||
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Error Summary')
|
||||
->schema([
|
||||
TextEntry::make('error_code')->placeholder('—'),
|
||||
TextEntry::make('error_category')->placeholder('—'),
|
||||
ViewEntry::make('error_summary')
|
||||
->label('Safe error summary')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('initiator.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->color(fn (EntraGroupSyncRun $record): string => static::statusColor($record->status)),
|
||||
Tables\Columns\TextColumn::make('selection_key')
|
||||
->label('Selection')
|
||||
->limit(24)
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('slot_key')
|
||||
->label('Slot')
|
||||
->placeholder('—')
|
||||
->limit(16)
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('started_at')->since(),
|
||||
Tables\Columns\TextColumn::make('finished_at')->since(),
|
||||
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
|
||||
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
|
||||
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->with('initiator')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
|
||||
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
private static function statusColor(?string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
EntraGroupSyncRun::STATUS_SUCCEEDED => 'success',
|
||||
EntraGroupSyncRun::STATUS_PARTIAL => 'warning',
|
||||
EntraGroupSyncRun::STATUS_FAILED => 'danger',
|
||||
EntraGroupSyncRun::STATUS_RUNNING => 'info',
|
||||
EntraGroupSyncRun::STATUS_PENDING => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\Directory\EntraGroupSelection;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroupSyncRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('sync_groups')
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->visible(function (): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
return $role?->canSync() ?? false;
|
||||
})
|
||||
->action(function (): void {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
if (! ($role?->canSync() ?? false)) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $selectionKey)
|
||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof EntraGroupSyncRun) {
|
||||
$normalizedStatus = $existing->status === EntraGroupSyncRun::STATUS_RUNNING ? 'running' : 'queued';
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'directory_groups',
|
||||
'run_id' => (int) $existing->getKey(),
|
||||
'status' => $normalizedStatus,
|
||||
]));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = EntraGroupSyncRun::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
dispatch(new EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
));
|
||||
|
||||
$user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'run_type' => 'directory_groups',
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => 'queued',
|
||||
]));
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEntraGroupSyncRun extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||
}
|
||||
@ -1,390 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Drift\DriftFindingDiffBuilder;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use UnitEnum;
|
||||
|
||||
class FindingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Finding::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-exclamation-triangle';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Drift';
|
||||
|
||||
protected static ?string $navigationLabel = 'Findings';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Finding')
|
||||
->schema([
|
||||
TextEntry::make('finding_type')->badge()->label('Type'),
|
||||
TextEntry::make('status')->badge(),
|
||||
TextEntry::make('severity')->badge(),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable(),
|
||||
TextEntry::make('scope_key')->label('Scope')->copyable(),
|
||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||
TextEntry::make('subject_type')->label('Subject type'),
|
||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||
TextEntry::make('baseline_run_id')
|
||||
->label('Baseline run')
|
||||
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('current_run_id')
|
||||
->label('Current run')
|
||||
->url(fn (Finding $record): ?string => $record->current_run_id
|
||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('acknowledged_by_user_id')->label('Acknowledged by')->placeholder('—'),
|
||||
TextEntry::make('created_at')->label('Created')->dateTime(),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Diff')
|
||||
->schema([
|
||||
ViewEntry::make('settings_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.normalized-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant) {
|
||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
|
||||
$baselineVersion = is_numeric($baselineId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||
: null;
|
||||
|
||||
$currentVersion = is_numeric($currentId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||
: null;
|
||||
|
||||
$diff = app(DriftFindingDiffBuilder::class)->buildSettingsDiff($baselineVersion, $currentVersion);
|
||||
|
||||
$addedCount = (int) Arr::get($diff, 'summary.added', 0);
|
||||
$removedCount = (int) Arr::get($diff, 'summary.removed', 0);
|
||||
$changedCount = (int) Arr::get($diff, 'summary.changed', 0);
|
||||
|
||||
if (($addedCount + $removedCount + $changedCount) === 0) {
|
||||
Arr::set(
|
||||
$diff,
|
||||
'summary.message',
|
||||
'No normalized changes were found. This drift finding may be based on fields that are intentionally excluded from normalization.'
|
||||
);
|
||||
}
|
||||
|
||||
return $diff;
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_snapshot')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('scope_tags_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.scope-tags-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant) {
|
||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
|
||||
$baselineVersion = is_numeric($baselineId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||
: null;
|
||||
|
||||
$currentVersion = is_numeric($currentId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||
: null;
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildScopeTagsDiff($baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_scope_tags')
|
||||
->columnSpanFull(),
|
||||
|
||||
ViewEntry::make('assignments_diff')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.assignments-diff')
|
||||
->state(function (Finding $record): array {
|
||||
$tenant = Tenant::current();
|
||||
if (! $tenant) {
|
||||
return ['summary' => ['message' => 'No tenant context'], 'added' => [], 'removed' => [], 'changed' => []];
|
||||
}
|
||||
|
||||
$baselineId = Arr::get($record->evidence_jsonb ?? [], 'baseline.policy_version_id');
|
||||
$currentId = Arr::get($record->evidence_jsonb ?? [], 'current.policy_version_id');
|
||||
|
||||
$baselineVersion = is_numeric($baselineId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $baselineId)
|
||||
: null;
|
||||
|
||||
$currentVersion = is_numeric($currentId)
|
||||
? PolicyVersion::query()->where('tenant_id', $tenant->getKey())->find((int) $currentId)
|
||||
: null;
|
||||
|
||||
return app(DriftFindingDiffBuilder::class)->buildAssignmentsDiff($tenant, $baselineVersion, $currentVersion);
|
||||
})
|
||||
->visible(fn (Finding $record): bool => Arr::get($record->evidence_jsonb ?? [], 'summary.kind') === 'policy_assignments')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsed()
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Evidence (Sanitized)')
|
||||
->schema([
|
||||
ViewEntry::make('evidence_jsonb')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (Finding $record) => $record->evidence_jsonb ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('created_at', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('finding_type')->badge()->label('Type'),
|
||||
Tables\Columns\TextColumn::make('status')->badge(),
|
||||
Tables\Columns\TextColumn::make('severity')->badge(),
|
||||
Tables\Columns\TextColumn::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||
Tables\Columns\TextColumn::make('subject_type')->label('Subject type')->searchable(),
|
||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options([
|
||||
Finding::STATUS_NEW => 'New',
|
||||
Finding::STATUS_ACKNOWLEDGED => 'Acknowledged',
|
||||
])
|
||||
->default(Finding::STATUS_NEW),
|
||||
Tables\Filters\SelectFilter::make('finding_type')
|
||||
->options([
|
||||
Finding::FINDING_TYPE_DRIFT => 'Drift',
|
||||
])
|
||||
->default(Finding::FINDING_TYPE_DRIFT),
|
||||
Tables\Filters\Filter::make('scope_key')
|
||||
->form([
|
||||
TextInput::make('scope_key')
|
||||
->label('Scope key')
|
||||
->placeholder('Inventory selection hash')
|
||||
->maxLength(255),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$scopeKey = $data['scope_key'] ?? null;
|
||||
|
||||
if (! is_string($scopeKey) || $scopeKey === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->where('scope_key', $scopeKey);
|
||||
}),
|
||||
Tables\Filters\Filter::make('run_ids')
|
||||
->label('Run IDs')
|
||||
->form([
|
||||
TextInput::make('baseline_run_id')
|
||||
->label('Baseline run id')
|
||||
->numeric(),
|
||||
TextInput::make('current_run_id')
|
||||
->label('Current run id')
|
||||
->numeric(),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$baselineRunId = $data['baseline_run_id'] ?? null;
|
||||
if (is_numeric($baselineRunId)) {
|
||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||
}
|
||||
|
||||
$currentRunId = $data['current_run_id'] ?? null;
|
||||
if (is_numeric($currentRunId)) {
|
||||
$query->where('current_run_id', (int) $currentRunId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\Action::make('acknowledge')
|
||||
->label('Acknowledge')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (Finding $record): bool => $record->status === Finding::STATUS_NEW)
|
||||
->authorize(function (Finding $record): bool {
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->can('update', $record);
|
||||
})
|
||||
->action(function (Finding $record): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
Notification::make()
|
||||
->title('Finding belongs to a different tenant')
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$record->acknowledge($user);
|
||||
|
||||
Notification::make()
|
||||
->title('Finding acknowledged')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
BulkAction::make('acknowledge_selected')
|
||||
->label('Acknowledge selected')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->authorizeIndividualRecords('update')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = $records->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$acknowledgedCount = 0;
|
||||
$skippedCount = 0;
|
||||
|
||||
foreach ($records as $record) {
|
||||
if (! $record instanceof Finding) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((int) $record->tenant_id !== (int) $tenant->getKey()) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($record->status !== Finding::STATUS_NEW) {
|
||||
$skippedCount++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$record->acknowledge($user);
|
||||
$acknowledgedCount++;
|
||||
}
|
||||
|
||||
$body = "Acknowledged {$acknowledgedCount} finding".($acknowledgedCount === 1 ? '' : 's').'.';
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk acknowledge completed')
|
||||
->body($body)
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->addSelect([
|
||||
'subject_display_name' => InventoryItem::query()
|
||||
->select('display_name')
|
||||
->whereColumn('inventory_items.tenant_id', 'findings.tenant_id')
|
||||
->whereColumn('inventory_items.external_id', 'findings.subject_external_id')
|
||||
->limit(1),
|
||||
])
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListFindings::route('/'),
|
||||
'view' => Pages\ViewFinding::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ListFindings extends ListRecords
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('acknowledge_all_matching')
|
||||
->label('Acknowledge all matching')
|
||||
->icon('heroicon-o-check')
|
||||
->color('gray')
|
||||
->requiresConfirmation()
|
||||
->authorize(function (): bool {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$probe = new Finding(['tenant_id' => $tenant->getKey()]);
|
||||
|
||||
return $user->can('update', $probe);
|
||||
})
|
||||
->visible(fn (): bool => $this->getStatusFilterValue() === Finding::STATUS_NEW)
|
||||
->modalDescription(function (): string {
|
||||
$count = $this->getAllMatchingCount();
|
||||
|
||||
return "You are about to acknowledge {$count} finding".($count === 1 ? '' : 's').' matching the current filters.';
|
||||
})
|
||||
->form(function (): array {
|
||||
$count = $this->getAllMatchingCount();
|
||||
|
||||
if ($count <= 100) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
TextInput::make('confirmation')
|
||||
->label('Type ACKNOWLEDGE to confirm')
|
||||
->required()
|
||||
->in(['ACKNOWLEDGE'])
|
||||
->validationMessages([
|
||||
'in' => 'Please type ACKNOWLEDGE to confirm.',
|
||||
]),
|
||||
];
|
||||
})
|
||||
->action(function (array $data): void {
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant || ! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query = $this->buildAllMatchingQuery();
|
||||
$count = (clone $query)->count();
|
||||
|
||||
if ($count === 0) {
|
||||
Notification::make()
|
||||
->title('No matching findings')
|
||||
->body('There are no new findings matching the current filters.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$firstRecord = (clone $query)->first();
|
||||
if ($firstRecord instanceof Finding) {
|
||||
Gate::authorize('update', $firstRecord);
|
||||
}
|
||||
|
||||
$updated = $query->update([
|
||||
'status' => Finding::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->deselectAllTableRecords();
|
||||
$this->resetPage();
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk acknowledge completed')
|
||||
->body("Acknowledged {$updated} finding".($updated === 1 ? '' : 's').'.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
protected function buildAllMatchingQuery(): Builder
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$query = Finding::query();
|
||||
|
||||
if (! $tenant) {
|
||||
return $query->whereRaw('1 = 0');
|
||||
}
|
||||
|
||||
$query->where('tenant_id', $tenant->getKey());
|
||||
|
||||
$query->where('status', Finding::STATUS_NEW);
|
||||
|
||||
$findingType = $this->getFindingTypeFilterValue();
|
||||
if (is_string($findingType) && $findingType !== '') {
|
||||
$query->where('finding_type', $findingType);
|
||||
}
|
||||
|
||||
$scopeKeyState = $this->getTableFilterState('scope_key') ?? [];
|
||||
$scopeKey = Arr::get($scopeKeyState, 'scope_key');
|
||||
if (is_string($scopeKey) && $scopeKey !== '') {
|
||||
$query->where('scope_key', $scopeKey);
|
||||
}
|
||||
|
||||
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
||||
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
||||
if (is_numeric($baselineRunId)) {
|
||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||
}
|
||||
|
||||
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
||||
if (is_numeric($currentRunId)) {
|
||||
$query->where('current_run_id', (int) $currentRunId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function getAllMatchingCount(): int
|
||||
{
|
||||
return (int) $this->buildAllMatchingQuery()->count();
|
||||
}
|
||||
|
||||
protected function getStatusFilterValue(): string
|
||||
{
|
||||
$state = $this->getTableFilterState('status') ?? [];
|
||||
$value = Arr::get($state, 'value');
|
||||
|
||||
return is_string($value) && $value !== ''
|
||||
? $value
|
||||
: Finding::STATUS_NEW;
|
||||
}
|
||||
|
||||
protected function getFindingTypeFilterValue(): string
|
||||
{
|
||||
$state = $this->getTableFilterState('finding_type') ?? [];
|
||||
$value = Arr::get($state, 'value');
|
||||
|
||||
return is_string($value) && $value !== ''
|
||||
? $value
|
||||
: Finding::FINDING_TYPE_DRIFT;
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\FindingResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewFinding extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FindingResource::class;
|
||||
}
|
||||
@ -9,12 +9,10 @@
|
||||
use App\Jobs\ExecuteRestoreRunJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreDiffGenerator;
|
||||
use App\Services\Intune\RestoreRiskChecker;
|
||||
@ -113,21 +111,7 @@ public static function form(Schema $schema): Schema
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
||||
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
||||
return array_map(function (array $group): Forms\Components\TextInput {
|
||||
$groupId = $group['id'];
|
||||
$label = $group['label'];
|
||||
|
||||
@ -136,28 +120,7 @@ public static function form(Schema $schema): Schema
|
||||
->placeholder('SKIP or target group Object ID (GUID)')
|
||||
->rules([new SkipOrUuidRule])
|
||||
->required()
|
||||
->suffixAction(
|
||||
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->iconButton()
|
||||
->tooltip('Select from Directory cache')
|
||||
->modalHeading('Select from Directory cache')
|
||||
->modalWidth('5xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
||||
'sourceGroupId' => $groupId,
|
||||
]))
|
||||
)
|
||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||
->hintAction(
|
||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
|
||||
}, $unresolved);
|
||||
})
|
||||
->visible(function (Get $get): bool {
|
||||
@ -354,21 +317,7 @@ public static function getWizardSteps(): array
|
||||
tenant: $tenant
|
||||
);
|
||||
|
||||
$groupCacheQuery = EntraGroup::query()->where('tenant_id', $tenant->getKey());
|
||||
$hasCachedGroups = $groupCacheQuery->exists();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
$cutoff = now('UTC')->subDays(max(1, $stalenessDays));
|
||||
$latestSeen = $groupCacheQuery->max('last_seen_at');
|
||||
$isStale = $hasCachedGroups && (! $latestSeen || $latestSeen < $cutoff);
|
||||
|
||||
$cacheNotice = match (true) {
|
||||
! $hasCachedGroups => 'No cached groups found. Run "Sync Groups" first.',
|
||||
$isStale => "Cached groups may be stale (>${stalenessDays} days). Consider running \"Sync Groups\".",
|
||||
default => null,
|
||||
};
|
||||
|
||||
return array_map(function (array $group) use ($cacheNotice): Forms\Components\TextInput {
|
||||
return array_map(function (array $group): Forms\Components\TextInput {
|
||||
$groupId = $group['id'];
|
||||
$label = $group['label'];
|
||||
|
||||
@ -386,28 +335,7 @@ public static function getWizardSteps(): array
|
||||
$set('preview_ran_at', null);
|
||||
})
|
||||
->required()
|
||||
->suffixAction(
|
||||
Actions\Action::make('select_from_directory_cache_'.str_replace('-', '_', $groupId))
|
||||
->icon('heroicon-o-magnifying-glass')
|
||||
->iconButton()
|
||||
->tooltip('Select from Directory cache')
|
||||
->modalHeading('Select from Directory cache')
|
||||
->modalWidth('5xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(fn () => view('filament.modals.entra-group-cache-picker', [
|
||||
'sourceGroupId' => $groupId,
|
||||
]))
|
||||
)
|
||||
->helperText(fn (): string => $cacheNotice ? ($cacheNotice.' Labels use cached directory groups only (no live Graph lookups). Paste a GUID or use SKIP.') : 'Paste the target Entra ID group Object ID (GUID). Labels use cached directory groups only (no live Graph lookups). Use SKIP to omit the assignment.')
|
||||
->hintAction(
|
||||
Actions\Action::make('open_directory_groups_'.str_replace('-', '_', $groupId))
|
||||
->label('Sync Groups')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->color('warning')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current()))
|
||||
->visible(fn (): bool => $cacheNotice !== null)
|
||||
);
|
||||
->helperText('Paste the target Entra ID group Object ID (GUID). Names are not resolved in this phase. Use SKIP to omit the assignment.');
|
||||
}, $unresolved);
|
||||
})
|
||||
->visible(function (Get $get): bool {
|
||||
@ -1613,16 +1541,10 @@ private static function unresolvedGroups(?int $backupSetId, ?array $selectedItem
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(EntraGroupLabelResolver::class);
|
||||
$cached = $resolver->lookupMany($tenant, $groupIds);
|
||||
|
||||
return array_map(function (string $groupId) use ($sourceNames, $cached): array {
|
||||
$cachedName = $cached[strtolower($groupId)] ?? null;
|
||||
$fallbackName = $cachedName ?? ($sourceNames[$groupId] ?? null);
|
||||
|
||||
return array_map(function (string $groupId) use ($sourceNames): array {
|
||||
return [
|
||||
'id' => $groupId,
|
||||
'label' => EntraGroupLabelResolver::formatLabel($fallbackName, $groupId),
|
||||
'label' => static::formatGroupLabel($sourceNames[$groupId] ?? null, $groupId),
|
||||
];
|
||||
}, $groupIds);
|
||||
}
|
||||
@ -1731,4 +1653,11 @@ private static function normalizeGroupMapping(mixed $mapping): array
|
||||
|
||||
return array_filter($result, static fn (?string $value): bool => is_string($value) && $value !== '');
|
||||
}
|
||||
|
||||
private static function formatGroupLabel(?string $displayName, string $id): string
|
||||
{
|
||||
$suffix = '…'.mb_substr($id, -8);
|
||||
|
||||
return trim(($displayName ?: 'Security group').' ('.$suffix.')');
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use Filament\Resources\Pages\Concerns\HasWizard;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Livewire\Attributes\On;
|
||||
|
||||
class CreateRestoreRun extends CreateRecord
|
||||
{
|
||||
@ -120,23 +119,4 @@ protected function handleRecordCreation(array $data): Model
|
||||
{
|
||||
return RestoreRunResource::createRestoreRun($data);
|
||||
}
|
||||
|
||||
#[On('entra-group-cache-picked')]
|
||||
public function applyEntraGroupCachePick(string $sourceGroupId, string $entraId): void
|
||||
{
|
||||
data_set($this->data, "group_mapping.{$sourceGroupId}", $entraId);
|
||||
|
||||
$this->data['check_summary'] = null;
|
||||
$this->data['check_results'] = [];
|
||||
$this->data['checks_ran_at'] = null;
|
||||
$this->data['preview_summary'] = null;
|
||||
$this->data['preview_diffs'] = [];
|
||||
$this->data['preview_ran_at'] = null;
|
||||
|
||||
$this->form->fill($this->data);
|
||||
|
||||
if (method_exists($this, 'unmountAction')) {
|
||||
$this->unmountAction();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,9 @@
|
||||
use App\Http\Controllers\RbacDelegatedAuthController;
|
||||
use App\Jobs\BulkTenantSyncJob;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\EntraGroup;
|
||||
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;
|
||||
@ -587,7 +585,10 @@ public static function rbacAction(): Actions\Action
|
||||
->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')
|
||||
@ -614,7 +615,10 @@ public static function rbacAction(): Actions\Action
|
||||
->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')
|
||||
@ -924,11 +928,6 @@ private static function formatRoleLabel(?string $displayName, string $id): strin
|
||||
return trim(($displayName ?: 'RBAC role').$suffix);
|
||||
}
|
||||
|
||||
private static function escapeOdataValue(string $value): string
|
||||
{
|
||||
return str_replace("'", "''", $value);
|
||||
}
|
||||
|
||||
private static function notifyRoleLookupFailure(): void
|
||||
{
|
||||
Notification::make()
|
||||
@ -981,30 +980,65 @@ private static function loginToSearchGroupsAction(?Tenant $tenant): ?Actions\Act
|
||||
|
||||
public static function groupSearchHelper(?Tenant $tenant): ?string
|
||||
{
|
||||
if (! $tenant) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Uses cached directory groups only (no live Graph lookups). Run “Sync Groups” if results are empty.';
|
||||
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 [];
|
||||
}
|
||||
|
||||
return EntraGroup::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('display_name', 'ilike', '%'.str_replace('%', '\\%', $search).'%')
|
||||
->orderBy('display_name')
|
||||
->limit(20)
|
||||
->get(['entra_id', 'display_name'])
|
||||
->mapWithKeys(fn (EntraGroup $group) => [
|
||||
(string) $group->entra_id => EntraGroupLabelResolver::formatLabel($group->display_name, (string) $group->entra_id),
|
||||
$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();
|
||||
}
|
||||
@ -1015,7 +1049,61 @@ private static function resolveGroupLabel(?Tenant $tenant, ?string $groupId): ?s
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
return app(EntraGroupLabelResolver::class)->resolveOne($tenant, $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(
|
||||
|
||||
@ -1,139 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
|
||||
class EntraGroupSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public string $selectionKey,
|
||||
public ?string $slotKey = null,
|
||||
public ?int $runId = null,
|
||||
) {}
|
||||
|
||||
public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLogger): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$run = $this->resolveRun($tenant);
|
||||
|
||||
if ($run->status !== EntraGroupSyncRun::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->update([
|
||||
'status' => EntraGroupSyncRun::STATUS_RUNNING,
|
||||
'started_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'directory_groups.sync.started',
|
||||
context: [
|
||||
'selection_key' => $run->selection_key,
|
||||
'run_id' => $run->getKey(),
|
||||
'slot_key' => $run->slot_key,
|
||||
],
|
||||
actorId: $run->initiator_user_id,
|
||||
status: 'success',
|
||||
resourceType: 'entra_group_sync_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
|
||||
$result = $syncService->sync($tenant, $run);
|
||||
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED;
|
||||
|
||||
if ($result['error_code'] !== null) {
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_FAILED;
|
||||
} elseif ($result['safety_stop_triggered'] === true) {
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL;
|
||||
}
|
||||
|
||||
$run->update([
|
||||
'status' => $terminalStatus,
|
||||
'pages_fetched' => $result['pages_fetched'],
|
||||
'items_observed_count' => $result['items_observed_count'],
|
||||
'items_upserted_count' => $result['items_upserted_count'],
|
||||
'error_count' => $result['error_count'],
|
||||
'safety_stop_triggered' => $result['safety_stop_triggered'],
|
||||
'safety_stop_reason' => $result['safety_stop_reason'],
|
||||
'error_code' => $result['error_code'],
|
||||
'error_category' => $result['error_category'],
|
||||
'error_summary' => $result['error_summary'],
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
||||
? 'directory_groups.sync.succeeded'
|
||||
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
|
||||
? 'directory_groups.sync.partial'
|
||||
: 'directory_groups.sync.failed'),
|
||||
context: [
|
||||
'selection_key' => $run->selection_key,
|
||||
'run_id' => $run->getKey(),
|
||||
'slot_key' => $run->slot_key,
|
||||
'pages_fetched' => $run->pages_fetched,
|
||||
'items_observed_count' => $run->items_observed_count,
|
||||
'items_upserted_count' => $run->items_upserted_count,
|
||||
'error_code' => $run->error_code,
|
||||
'error_category' => $run->error_category,
|
||||
],
|
||||
actorId: $run->initiator_user_id,
|
||||
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
|
||||
resourceType: 'entra_group_sync_run',
|
||||
resourceId: (string) $run->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveRun(Tenant $tenant): EntraGroupSyncRun
|
||||
{
|
||||
if ($this->runId !== null) {
|
||||
$run = EntraGroupSyncRun::query()
|
||||
->whereKey($this->runId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->first();
|
||||
|
||||
if ($run instanceof EntraGroupSyncRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
throw new RuntimeException('EntraGroupSyncRun not found.');
|
||||
}
|
||||
|
||||
if ($this->slotKey !== null) {
|
||||
$run = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $this->selectionKey)
|
||||
->where('slot_key', $this->slotKey)
|
||||
->first();
|
||||
|
||||
if ($run instanceof EntraGroupSyncRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
throw new RuntimeException('EntraGroupSyncRun not found for slot.');
|
||||
}
|
||||
|
||||
throw new RuntimeException('Job missing runId/slotKey.');
|
||||
}
|
||||
}
|
||||
@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GenerateDriftFindingsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $baselineRunId,
|
||||
public int $currentRunId,
|
||||
public string $scopeKey,
|
||||
public int $bulkOperationRunId,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
|
||||
{
|
||||
Log::info('GenerateDriftFindingsJob: started', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||
if (! $baseline instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
}
|
||||
|
||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||
if (! $current instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
}
|
||||
|
||||
$run = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($this->bulkOperationRunId);
|
||||
|
||||
if (! $run instanceof BulkOperationRun) {
|
||||
throw new RuntimeException('Bulk operation run not found.');
|
||||
}
|
||||
|
||||
$bulkOperationService->start($run);
|
||||
|
||||
try {
|
||||
$created = $generator->generate(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
current: $current,
|
||||
scopeKey: $this->scopeKey,
|
||||
);
|
||||
|
||||
Log::info('GenerateDriftFindingsJob: completed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
'created_findings_count' => $created,
|
||||
]);
|
||||
|
||||
$bulkOperationService->recordSuccess($run);
|
||||
$bulkOperationService->complete($run);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('GenerateDriftFindingsJob: failed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$bulkOperationService->fail($run, $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,203 +0,0 @@
|
||||
<?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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,6 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use Livewire\Component;
|
||||
|
||||
class PolicyVersionAssignmentsWidget extends Component
|
||||
@ -21,75 +19,9 @@ public function render(): \Illuminate\Contracts\View\View
|
||||
return view('livewire.policy-version-assignments-widget', [
|
||||
'version' => $this->version,
|
||||
'compliance' => $this->complianceNotifications(),
|
||||
'groupLabels' => $this->groupLabels(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function groupLabels(): array
|
||||
{
|
||||
$assignments = $this->version->assignments;
|
||||
|
||||
if (! is_array($assignments) || $assignments === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$tenant = rescue(fn () => Tenant::current(), null);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$groupIds = [];
|
||||
$sourceNames = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $assignment['target'] ?? null;
|
||||
|
||||
if (! is_array($target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupId = $target['groupId'] ?? null;
|
||||
|
||||
if (! is_string($groupId) || $groupId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupIds[] = $groupId;
|
||||
|
||||
$displayName = $target['group_display_name'] ?? null;
|
||||
|
||||
if (is_string($displayName) && $displayName !== '') {
|
||||
$sourceNames[$groupId] = $displayName;
|
||||
}
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique($groupIds));
|
||||
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resolver = app(EntraGroupLabelResolver::class);
|
||||
$cached = $resolver->lookupMany($tenant, $groupIds);
|
||||
|
||||
$labels = [];
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$cachedName = $cached[strtolower($groupId)] ?? null;
|
||||
$labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{total:int,templates:array<int,string>,items:array<int,array{rule_name:?string,template_id:string,template_key:string}>}
|
||||
*/
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EntraGroup extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'group_types' => 'array',
|
||||
'security_enabled' => 'boolean',
|
||||
'mail_enabled' => 'boolean',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EntraGroupSyncRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'safety_stop_triggered' => 'boolean',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiator_user_id');
|
||||
}
|
||||
}
|
||||
@ -1,67 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Finding extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\FindingFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const string FINDING_TYPE_DRIFT = 'drift';
|
||||
|
||||
public const string SEVERITY_LOW = 'low';
|
||||
|
||||
public const string SEVERITY_MEDIUM = 'medium';
|
||||
|
||||
public const string SEVERITY_HIGH = 'high';
|
||||
|
||||
public const string STATUS_NEW = 'new';
|
||||
|
||||
public const string STATUS_ACKNOWLEDGED = 'acknowledged';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'acknowledged_at' => 'datetime',
|
||||
'evidence_jsonb' => 'array',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function baselineRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
|
||||
}
|
||||
|
||||
public function currentRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'current_run_id');
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'acknowledged_by_user_id');
|
||||
}
|
||||
|
||||
public function acknowledge(User $user): void
|
||||
{
|
||||
if ($this->status === self::STATUS_ACKNOWLEDGED) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forceFill([
|
||||
'status' => self::STATUS_ACKNOWLEDGED,
|
||||
'acknowledged_at' => now(),
|
||||
'acknowledged_by_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
@ -195,16 +195,6 @@ public function restoreRuns(): HasMany
|
||||
return $this->hasMany(RestoreRun::class);
|
||||
}
|
||||
|
||||
public function entraGroups(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntraGroup::class);
|
||||
}
|
||||
|
||||
public function entraGroupSyncRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntraGroupSyncRun::class);
|
||||
}
|
||||
|
||||
public function auditLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditLog::class);
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Tenant;
|
||||
use Filament\Actions\Action;
|
||||
@ -61,34 +60,13 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
$actions = [];
|
||||
|
||||
if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) {
|
||||
if (in_array($runType, ['bulk_operation', 'restore'], true) && $tenantId > 0 && $runId > 0) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
|
||||
if ($tenant) {
|
||||
$url = match ($runType) {
|
||||
'bulk_operation' => BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
'format' => 'filament',
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'color' => $color,
|
||||
'duration' => 'persistent',
|
||||
'actions' => [],
|
||||
'icon' => null,
|
||||
'iconColor' => null,
|
||||
'status' => null,
|
||||
'view' => null,
|
||||
'viewData' => [
|
||||
'metadata' => $this->metadata,
|
||||
],
|
||||
];
|
||||
}
|
||||
$url = $runType === 'bulk_operation'
|
||||
? BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
||||
: RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant);
|
||||
|
||||
$actions[] = Action::make('view_run')
|
||||
->label('View run')
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class EntraGroupPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, EntraGroup $group): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $group->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class EntraGroupSyncRunPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, EntraGroupSyncRun $run): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $run->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\TenantRole;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class FindingPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $finding->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
public function update(User $user, Finding $finding): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((int) $finding->tenant_id !== (int) $tenant->getKey()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$role = $user->tenantRole($tenant);
|
||||
|
||||
return match ($role) {
|
||||
TenantRole::Owner,
|
||||
TenantRole::Manager,
|
||||
TenantRole::Operator => true,
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -3,18 +3,10 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Policies\BulkOperationRunPolicy;
|
||||
use App\Policies\EntraGroupPolicy;
|
||||
use App\Policies\EntraGroupSyncRunPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\MicrosoftGraphClient;
|
||||
use App\Services\Graph\NullGraphClient;
|
||||
@ -116,8 +108,5 @@ public function boot(): void
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||
Gate::policy(Finding::class, FindingPolicy::class);
|
||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,8 +45,6 @@ public function createRun(
|
||||
array $itemIds,
|
||||
int $totalItems
|
||||
): BulkOperationRun {
|
||||
$effectiveTotalItems = max($totalItems, count($itemIds));
|
||||
|
||||
$run = BulkOperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
@ -54,7 +52,7 @@ public function createRun(
|
||||
'action' => $action,
|
||||
'status' => 'pending',
|
||||
'item_ids' => $itemIds,
|
||||
'total_items' => $effectiveTotalItems,
|
||||
'total_items' => $totalItems,
|
||||
'processed_items' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
@ -68,7 +66,7 @@ public function createRun(
|
||||
context: [
|
||||
'metadata' => [
|
||||
'bulk_run_id' => $run->id,
|
||||
'total_items' => $effectiveTotalItems,
|
||||
'total_items' => $totalItems,
|
||||
],
|
||||
],
|
||||
actorId: $user->id,
|
||||
@ -141,14 +139,6 @@ public function complete(BulkOperationRun $run): void
|
||||
{
|
||||
$run->refresh();
|
||||
|
||||
if ($run->processed_items > $run->total_items) {
|
||||
BulkOperationRun::query()
|
||||
->whereKey($run->id)
|
||||
->update(['total_items' => $run->processed_items]);
|
||||
|
||||
$run->refresh();
|
||||
}
|
||||
|
||||
if (! in_array($run->status, ['pending', 'running'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Directory;
|
||||
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EntraGroupLabelResolver
|
||||
{
|
||||
public function resolveOne(Tenant $tenant, string $groupId): string
|
||||
{
|
||||
$labels = $this->resolveMany($tenant, [$groupId]);
|
||||
|
||||
return $labels[$groupId] ?? self::formatLabel(null, $groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $groupIds
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function resolveMany(Tenant $tenant, array $groupIds): array
|
||||
{
|
||||
$groupIds = array_values(array_unique(array_filter($groupIds, fn ($id) => is_string($id) && $id !== '')));
|
||||
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$displayNames = $this->lookupMany($tenant, $groupIds);
|
||||
|
||||
$labels = [];
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
$lookupId = Str::isUuid($groupId) ? strtolower($groupId) : $groupId;
|
||||
$labels[$groupId] = self::formatLabel($displayNames[$lookupId] ?? null, $groupId);
|
||||
}
|
||||
|
||||
return $labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $groupIds
|
||||
* @return array<string, string> Map of groupId (lowercased UUID) => display_name
|
||||
*/
|
||||
public function lookupMany(Tenant $tenant, array $groupIds): array
|
||||
{
|
||||
$uuids = [];
|
||||
|
||||
foreach ($groupIds as $groupId) {
|
||||
if (! is_string($groupId) || $groupId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! Str::isUuid($groupId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uuids[] = strtolower($groupId);
|
||||
}
|
||||
|
||||
$uuids = array_values(array_unique($uuids));
|
||||
|
||||
if ($uuids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return EntraGroup::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('entra_id', $uuids)
|
||||
->pluck('display_name', 'entra_id')
|
||||
->mapWithKeys(fn (string $displayName, string $entraId) => [strtolower($entraId) => $displayName])
|
||||
->all();
|
||||
}
|
||||
|
||||
public static function formatLabel(?string $displayName, string $id): string
|
||||
{
|
||||
$name = filled($displayName) ? $displayName : 'Unresolved';
|
||||
|
||||
return sprintf('%s (%s)', trim($name), self::shortToken($id));
|
||||
}
|
||||
|
||||
private static function shortToken(string $id): string
|
||||
{
|
||||
$normalized = preg_replace('/[^a-zA-Z0-9]/', '', $id);
|
||||
|
||||
if (! is_string($normalized) || $normalized === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (mb_strlen($normalized) <= 8) {
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
return '…'.mb_substr($normalized, -8);
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Directory;
|
||||
|
||||
use Illuminate\Support\CarbonImmutable;
|
||||
|
||||
class EntraGroupSelection
|
||||
{
|
||||
public const KEY_ALL_GROUPS_V1 = 'groups-v1:all';
|
||||
|
||||
public static function allGroupsV1(): string
|
||||
{
|
||||
return self::KEY_ALL_GROUPS_V1;
|
||||
}
|
||||
|
||||
public static function scheduledSlotKey(CarbonImmutable $nowUtc): string
|
||||
{
|
||||
return $nowUtc->format('YmdHi').'Z';
|
||||
}
|
||||
}
|
||||
@ -1,262 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Directory;
|
||||
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphContractRegistry;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
class EntraGroupSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GraphClientInterface $graph,
|
||||
private readonly GraphContractRegistry $contracts,
|
||||
) {}
|
||||
|
||||
public function startManualSync(Tenant $tenant, User $user): EntraGroupSyncRun
|
||||
{
|
||||
$selectionKey = EntraGroupSelection::allGroupsV1();
|
||||
|
||||
$existing = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $selectionKey)
|
||||
->whereIn('status', [EntraGroupSyncRun::STATUS_PENDING, EntraGroupSyncRun::STATUS_RUNNING])
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($existing instanceof EntraGroupSyncRun) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$run = EntraGroupSyncRun::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => $selectionKey,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
dispatch(new \App\Jobs\EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: $selectionKey,
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
));
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* pages_fetched:int,
|
||||
* items_observed_count:int,
|
||||
* items_upserted_count:int,
|
||||
* error_count:int,
|
||||
* safety_stop_triggered:bool,
|
||||
* safety_stop_reason:?string,
|
||||
* error_code:?string,
|
||||
* error_category:?string,
|
||||
* error_summary:?string
|
||||
* }
|
||||
*/
|
||||
public function sync(Tenant $tenant, EntraGroupSyncRun $run): array
|
||||
{
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$policyType = $this->contracts->directoryGroupsPolicyType();
|
||||
$path = $this->contracts->directoryGroupsListPath();
|
||||
|
||||
$contract = $this->contracts->get($policyType);
|
||||
$query = [];
|
||||
|
||||
if (isset($contract['allowed_select']) && is_array($contract['allowed_select']) && $contract['allowed_select'] !== []) {
|
||||
$query['$select'] = $contract['allowed_select'];
|
||||
}
|
||||
|
||||
$pageSize = (int) config('directory_groups.page_size', 999);
|
||||
if ($pageSize > 0) {
|
||||
$query['$top'] = $pageSize;
|
||||
}
|
||||
|
||||
$sanitized = $this->contracts->sanitizeQuery($policyType, $query);
|
||||
$query = $sanitized['query'];
|
||||
|
||||
$maxPages = (int) config('directory_groups.safety_stop.max_pages', 200);
|
||||
$maxRuntimeSeconds = (int) config('directory_groups.safety_stop.max_runtime_seconds', 600);
|
||||
$deadline = $nowUtc->addSeconds(max(1, $maxRuntimeSeconds));
|
||||
|
||||
$pagesFetched = 0;
|
||||
$observed = 0;
|
||||
$upserted = 0;
|
||||
|
||||
$safetyStopTriggered = false;
|
||||
$safetyStopReason = null;
|
||||
|
||||
$errorCode = null;
|
||||
$errorCategory = null;
|
||||
$errorSummary = null;
|
||||
$errorCount = 0;
|
||||
|
||||
$options = $tenant->graphOptions();
|
||||
$useQuery = $query;
|
||||
$nextPath = $path;
|
||||
|
||||
while ($nextPath) {
|
||||
if (CarbonImmutable::now('UTC')->greaterThan($deadline)) {
|
||||
$safetyStopTriggered = true;
|
||||
$safetyStopReason = 'runtime_exceeded';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if ($pagesFetched >= $maxPages) {
|
||||
$safetyStopTriggered = true;
|
||||
$safetyStopReason = 'max_pages_exceeded';
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$response = $this->requestWithRetry('GET', $nextPath, $options + ['query' => $useQuery]);
|
||||
|
||||
if ($response->failed()) {
|
||||
[$errorCode, $errorCategory, $errorSummary] = $this->categorizeError($response);
|
||||
$errorCount = 1;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$pagesFetched++;
|
||||
|
||||
$data = $response->data;
|
||||
$pageItems = $data['value'] ?? (is_array($data) ? $data : []);
|
||||
|
||||
if (is_array($pageItems)) {
|
||||
foreach ($pageItems as $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entraId = $item['id'] ?? null;
|
||||
if (! is_string($entraId) || $entraId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayName = $item['displayName'] ?? null;
|
||||
$groupTypes = $item['groupTypes'] ?? null;
|
||||
|
||||
$values = [
|
||||
'display_name' => is_string($displayName) ? $displayName : $entraId,
|
||||
'group_types' => is_array($groupTypes) ? $groupTypes : [],
|
||||
'security_enabled' => (bool) ($item['securityEnabled'] ?? false),
|
||||
'mail_enabled' => (bool) ($item['mailEnabled'] ?? false),
|
||||
'last_seen_at' => $nowUtc,
|
||||
];
|
||||
|
||||
EntraGroup::query()->updateOrCreate([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'entra_id' => $entraId,
|
||||
], $values);
|
||||
|
||||
$observed++;
|
||||
$upserted++;
|
||||
}
|
||||
}
|
||||
|
||||
$nextLink = is_array($data) ? ($data['@odata.nextLink'] ?? null) : null;
|
||||
|
||||
if (! is_string($nextLink) || $nextLink === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
$nextPath = $this->stripGraphBaseUrl($nextLink);
|
||||
$useQuery = [];
|
||||
}
|
||||
|
||||
$retentionDays = (int) config('directory_groups.retention_days', 90);
|
||||
if ($retentionDays > 0) {
|
||||
$cutoff = $nowUtc->subDays($retentionDays);
|
||||
|
||||
EntraGroup::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereNotNull('last_seen_at')
|
||||
->where('last_seen_at', '<', $cutoff)
|
||||
->delete();
|
||||
}
|
||||
|
||||
return [
|
||||
'pages_fetched' => $pagesFetched,
|
||||
'items_observed_count' => $observed,
|
||||
'items_upserted_count' => $upserted,
|
||||
'error_count' => $errorCount,
|
||||
'safety_stop_triggered' => $safetyStopTriggered,
|
||||
'safety_stop_reason' => $safetyStopReason,
|
||||
'error_code' => $errorCode,
|
||||
'error_category' => $errorCategory,
|
||||
'error_summary' => $errorSummary,
|
||||
];
|
||||
}
|
||||
|
||||
private function requestWithRetry(string $method, string $path, array $options): GraphResponse
|
||||
{
|
||||
$maxRetries = (int) config('directory_groups.safety_stop.max_retries', 8);
|
||||
$maxRetries = max(0, $maxRetries);
|
||||
|
||||
for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
|
||||
$response = $this->graph->request($method, $path, $options);
|
||||
|
||||
if ($response->successful()) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$status = (int) ($response->status ?? 0);
|
||||
|
||||
if (! in_array($status, [429, 503], true) || $attempt >= $maxRetries) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$baseDelaySeconds = min(30, 1 << $attempt);
|
||||
$jitterMillis = random_int(0, 250);
|
||||
usleep(($baseDelaySeconds * 1000 + $jitterMillis) * 1000);
|
||||
}
|
||||
|
||||
return new GraphResponse(success: false, data: [], status: 500, errors: [['message' => 'Retry loop exceeded']]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:string}
|
||||
*/
|
||||
private function categorizeError(GraphResponse $response): array
|
||||
{
|
||||
$status = (int) ($response->status ?? 0);
|
||||
|
||||
if (in_array($status, [401, 403], true)) {
|
||||
return ['permission_denied', 'permission', 'Graph permission denied for groups listing.'];
|
||||
}
|
||||
|
||||
if ($status === 429) {
|
||||
return ['throttled', 'throttling', 'Graph throttled the groups listing request.'];
|
||||
}
|
||||
|
||||
if (in_array($status, [500, 502, 503, 504], true)) {
|
||||
return ['graph_unavailable', 'transient', 'Graph returned a transient server error.'];
|
||||
}
|
||||
|
||||
return ['graph_request_failed', 'unknown', 'Graph request failed.'];
|
||||
}
|
||||
|
||||
private function stripGraphBaseUrl(string $nextLink): string
|
||||
{
|
||||
$base = rtrim((string) config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
||||
.'/'.trim((string) config('graph.version', 'v1.0'), '/');
|
||||
|
||||
if (str_starts_with($nextLink, $base)) {
|
||||
return ltrim((string) substr($nextLink, strlen($base)), '/');
|
||||
}
|
||||
|
||||
return ltrim($nextLink, '/');
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
class DriftEvidence
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function sanitize(array $payload): array
|
||||
{
|
||||
$allowedKeys = [
|
||||
'change_type',
|
||||
'summary',
|
||||
'baseline',
|
||||
'current',
|
||||
'diff',
|
||||
'notes',
|
||||
];
|
||||
|
||||
$safe = [];
|
||||
foreach ($allowedKeys as $key) {
|
||||
if (array_key_exists($key, $payload)) {
|
||||
$safe[$key] = $payload[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $safe;
|
||||
}
|
||||
}
|
||||
@ -1,304 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Drift\Normalizers\AssignmentsNormalizer;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use App\Services\Intune\VersionDiff;
|
||||
|
||||
class DriftFindingDiffBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SettingsNormalizer $settingsNormalizer,
|
||||
private readonly AssignmentsNormalizer $assignmentsNormalizer,
|
||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
private readonly VersionDiff $versionDiff,
|
||||
private readonly EntraGroupLabelResolver $groupLabelResolver,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildSettingsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||
{
|
||||
$policyType = $currentVersion?->policy_type ?? $baselineVersion?->policy_type ?? '';
|
||||
$platform = $currentVersion?->platform ?? $baselineVersion?->platform;
|
||||
|
||||
$from = $baselineVersion
|
||||
? $this->settingsNormalizer->normalizeForDiff(is_array($baselineVersion->snapshot) ? $baselineVersion->snapshot : [], (string) $policyType, $platform)
|
||||
: [];
|
||||
|
||||
$to = $currentVersion
|
||||
? $this->settingsNormalizer->normalizeForDiff(is_array($currentVersion->snapshot) ? $currentVersion->snapshot : [], (string) $policyType, $platform)
|
||||
: [];
|
||||
|
||||
$result = $this->versionDiff->compare($from, $to);
|
||||
$result['policy_type'] = $policyType;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion, int $limit = 200): array
|
||||
{
|
||||
$baseline = $baselineVersion ? $this->assignmentsNormalizer->normalizeForDiff($baselineVersion->assignments) : [];
|
||||
$current = $currentVersion ? $this->assignmentsNormalizer->normalizeForDiff($currentVersion->assignments) : [];
|
||||
|
||||
$baselineMap = [];
|
||||
foreach ($baseline as $row) {
|
||||
$baselineMap[$row['key']] = $row;
|
||||
}
|
||||
|
||||
$currentMap = [];
|
||||
foreach ($current as $row) {
|
||||
$currentMap[$row['key']] = $row;
|
||||
}
|
||||
|
||||
$allKeys = array_values(array_unique(array_merge(array_keys($baselineMap), array_keys($currentMap))));
|
||||
sort($allKeys);
|
||||
|
||||
$added = [];
|
||||
$removed = [];
|
||||
$changed = [];
|
||||
|
||||
foreach ($allKeys as $key) {
|
||||
$from = $baselineMap[$key] ?? null;
|
||||
$to = $currentMap[$key] ?? null;
|
||||
|
||||
if ($from === null && is_array($to)) {
|
||||
$added[] = $to;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($to === null && is_array($from)) {
|
||||
$removed[] = $from;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_array($from) || ! is_array($to)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$diffFields = [
|
||||
'filter_type',
|
||||
'filter_id',
|
||||
'intent',
|
||||
'mode',
|
||||
];
|
||||
|
||||
$fieldChanges = [];
|
||||
|
||||
foreach ($diffFields as $field) {
|
||||
$fromValue = $from[$field] ?? null;
|
||||
$toValue = $to[$field] ?? null;
|
||||
|
||||
if ($fromValue !== $toValue) {
|
||||
$fieldChanges[$field] = [
|
||||
'from' => $fromValue,
|
||||
'to' => $toValue,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($fieldChanges !== []) {
|
||||
$changed[] = [
|
||||
'key' => $key,
|
||||
'include_exclude' => $to['include_exclude'],
|
||||
'target_type' => $to['target_type'],
|
||||
'target_id' => $to['target_id'],
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'changes' => $fieldChanges,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = false;
|
||||
|
||||
$total = count($added) + count($removed) + count($changed);
|
||||
if ($total > $limit) {
|
||||
$truncated = true;
|
||||
|
||||
$budget = $limit;
|
||||
|
||||
$changed = array_slice($changed, 0, min(count($changed), $budget));
|
||||
$budget -= count($changed);
|
||||
|
||||
$added = array_slice($added, 0, min(count($added), $budget));
|
||||
$budget -= count($added);
|
||||
|
||||
$removed = array_slice($removed, 0, min(count($removed), $budget));
|
||||
}
|
||||
|
||||
$labels = $this->groupLabelsForDiff($tenant, $added, $removed, $changed);
|
||||
|
||||
$decorateAssignment = function (array $row) use ($labels): array {
|
||||
$row['target_label'] = $this->targetLabel($row, $labels);
|
||||
|
||||
return $row;
|
||||
};
|
||||
|
||||
$decorateChanged = function (array $row) use ($decorateAssignment): array {
|
||||
$row['from'] = is_array($row['from'] ?? null) ? $decorateAssignment($row['from']) : $row['from'];
|
||||
$row['to'] = is_array($row['to'] ?? null) ? $decorateAssignment($row['to']) : $row['to'];
|
||||
$row['target_label'] = is_array($row['to'] ?? null) ? ($row['to']['target_label'] ?? null) : null;
|
||||
|
||||
return $row;
|
||||
};
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'added' => count($added),
|
||||
'removed' => count($removed),
|
||||
'changed' => count($changed),
|
||||
'message' => sprintf('%d added, %d removed, %d changed', count($added), count($removed), count($changed)),
|
||||
'truncated' => $truncated,
|
||||
'limit' => $limit,
|
||||
],
|
||||
'added' => array_map($decorateAssignment, $added),
|
||||
'removed' => array_map($decorateAssignment, $removed),
|
||||
'changed' => array_map($decorateChanged, $changed),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildScopeTagsDiff(?PolicyVersion $baselineVersion, ?PolicyVersion $currentVersion): array
|
||||
{
|
||||
$baselineIds = $baselineVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags) ?? []) : [];
|
||||
$currentIds = $currentVersion ? ($this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags) ?? []) : [];
|
||||
|
||||
$baselineLabels = $baselineVersion ? $this->scopeTagsNormalizer->labelsById($baselineVersion->scope_tags) : [];
|
||||
$currentLabels = $currentVersion ? $this->scopeTagsNormalizer->labelsById($currentVersion->scope_tags) : [];
|
||||
|
||||
$baselineSet = array_fill_keys($baselineIds, true);
|
||||
$currentSet = array_fill_keys($currentIds, true);
|
||||
|
||||
$addedIds = array_values(array_diff($currentIds, $baselineIds));
|
||||
$removedIds = array_values(array_diff($baselineIds, $currentIds));
|
||||
|
||||
sort($addedIds);
|
||||
sort($removedIds);
|
||||
|
||||
$decorate = static function (array $ids, array $labels): array {
|
||||
$rows = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id) || $id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'id' => $id,
|
||||
'name' => $labels[$id] ?? ($id === '0' ? 'Default' : $id),
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
};
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'added' => count($addedIds),
|
||||
'removed' => count($removedIds),
|
||||
'changed' => 0,
|
||||
'message' => sprintf('%d added, %d removed', count($addedIds), count($removedIds)),
|
||||
'baseline_count' => count($baselineSet),
|
||||
'current_count' => count($currentSet),
|
||||
],
|
||||
'added' => $decorate($addedIds, $currentLabels),
|
||||
'removed' => $decorate($removedIds, $baselineLabels),
|
||||
'baseline' => $decorate($baselineIds, $baselineLabels),
|
||||
'current' => $decorate($currentIds, $currentLabels),
|
||||
'changed' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $added
|
||||
* @param array<int, array<string, mixed>> $removed
|
||||
* @param array<int, array<string, mixed>> $changed
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function groupLabelsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array
|
||||
{
|
||||
$groupIds = [];
|
||||
|
||||
foreach ([$added, $removed] as $items) {
|
||||
foreach ($items as $row) {
|
||||
$targetType = $row['target_type'] ?? null;
|
||||
$targetId = $row['target_id'] ?? null;
|
||||
|
||||
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupIds[] = $targetId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($changed as $row) {
|
||||
$targetType = $row['target_type'] ?? null;
|
||||
$targetId = $row['target_id'] ?? null;
|
||||
|
||||
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_contains($targetType, 'groupassignmenttarget')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupIds[] = $targetId;
|
||||
}
|
||||
|
||||
$groupIds = array_values(array_unique($groupIds));
|
||||
|
||||
if ($groupIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->groupLabelResolver->resolveMany($tenant, $groupIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $assignment
|
||||
* @param array<string, string> $groupLabels
|
||||
*/
|
||||
private function targetLabel(array $assignment, array $groupLabels): string
|
||||
{
|
||||
$targetType = $assignment['target_type'] ?? null;
|
||||
$targetId = $assignment['target_id'] ?? null;
|
||||
|
||||
if (! is_string($targetType) || ! is_string($targetId)) {
|
||||
return 'Unknown target';
|
||||
}
|
||||
|
||||
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||
return 'All devices';
|
||||
}
|
||||
|
||||
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||
return 'All users';
|
||||
}
|
||||
|
||||
if (str_contains($targetType, 'groupassignmenttarget')) {
|
||||
return $groupLabels[$targetId] ?? EntraGroupLabelResolver::formatLabel(null, $targetId);
|
||||
}
|
||||
|
||||
return sprintf('%s (%s)', $targetType, $targetId);
|
||||
}
|
||||
}
|
||||
@ -1,305 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Drift\Normalizers\ScopeTagsNormalizer;
|
||||
use App\Services\Drift\Normalizers\SettingsNormalizer;
|
||||
use Illuminate\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
class DriftFindingGenerator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DriftHasher $hasher,
|
||||
private readonly DriftEvidence $evidence,
|
||||
private readonly SettingsNormalizer $settingsNormalizer,
|
||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
||||
{
|
||||
if (! $baseline->finished_at || ! $current->finished_at) {
|
||||
throw new RuntimeException('Baseline/current run must be finished.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $selection */
|
||||
$selection = is_array($current->selection_payload) ? $current->selection_payload : [];
|
||||
|
||||
$policyTypes = Arr::get($selection, 'policy_types');
|
||||
if (! is_array($policyTypes)) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
$policyTypes = array_values(array_filter(array_map('strval', $policyTypes)));
|
||||
|
||||
$created = 0;
|
||||
|
||||
Policy::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereIn('policy_type', $policyTypes)
|
||||
->orderBy('id')
|
||||
->chunk(200, function ($policies) use ($tenant, $baseline, $current, $scopeKey, &$created): void {
|
||||
foreach ($policies as $policy) {
|
||||
if (! $policy instanceof Policy) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineVersion = $this->versionForRun($policy, $baseline);
|
||||
$currentVersion = $this->versionForRun($policy, $current);
|
||||
|
||||
if ($baselineVersion instanceof PolicyVersion || $currentVersion instanceof PolicyVersion) {
|
||||
$policyType = (string) ($policy->policy_type ?? '');
|
||||
$platform = is_string($policy->platform ?? null) ? $policy->platform : null;
|
||||
|
||||
$baselineSnapshot = $baselineVersion instanceof PolicyVersion && is_array($baselineVersion->snapshot)
|
||||
? $baselineVersion->snapshot
|
||||
: [];
|
||||
$currentSnapshot = $currentVersion instanceof PolicyVersion && is_array($currentVersion->snapshot)
|
||||
? $currentVersion->snapshot
|
||||
: [];
|
||||
|
||||
$baselineNormalized = $this->settingsNormalizer->normalizeForDiff($baselineSnapshot, $policyType, $platform);
|
||||
$currentNormalized = $this->settingsNormalizer->normalizeForDiff($currentSnapshot, $policyType, $platform);
|
||||
|
||||
$baselineSnapshotHash = $this->hasher->hashNormalized($baselineNormalized);
|
||||
$currentSnapshotHash = $this->hasher->hashNormalized($currentNormalized);
|
||||
|
||||
if ($baselineSnapshotHash !== $currentSnapshotHash) {
|
||||
$changeType = match (true) {
|
||||
$baselineVersion instanceof PolicyVersion && ! $currentVersion instanceof PolicyVersion => 'removed',
|
||||
! $baselineVersion instanceof PolicyVersion && $currentVersion instanceof PolicyVersion => 'added',
|
||||
default => 'modified',
|
||||
};
|
||||
|
||||
$fingerprint = $this->hasher->fingerprint(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'policy',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
changeType: $changeType,
|
||||
baselineHash: $baselineSnapshotHash,
|
||||
currentHash: $currentSnapshotHash,
|
||||
);
|
||||
|
||||
$rawEvidence = [
|
||||
'change_type' => $changeType,
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion?->getKey(),
|
||||
'snapshot_hash' => $baselineSnapshotHash,
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion?->getKey(),
|
||||
'snapshot_hash' => $currentSnapshotHash,
|
||||
],
|
||||
];
|
||||
|
||||
$finding = Finding::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
$wasNew = ! $finding->exists;
|
||||
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||
]);
|
||||
|
||||
if ($wasNew) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$finding->save();
|
||||
|
||||
if ($wasNew) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $baselineVersion instanceof PolicyVersion || ! $currentVersion instanceof PolicyVersion) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineAssignments = is_array($baselineVersion->assignments) ? $baselineVersion->assignments : [];
|
||||
$currentAssignments = is_array($currentVersion->assignments) ? $currentVersion->assignments : [];
|
||||
|
||||
$baselineAssignmentsHash = $this->hasher->hashNormalized($baselineAssignments);
|
||||
$currentAssignmentsHash = $this->hasher->hashNormalized($currentAssignments);
|
||||
|
||||
if ($baselineAssignmentsHash !== $currentAssignmentsHash) {
|
||||
$fingerprint = $this->hasher->fingerprint(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'assignment',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
changeType: 'modified',
|
||||
baselineHash: (string) ($baselineAssignmentsHash ?? ''),
|
||||
currentHash: (string) ($currentAssignmentsHash ?? ''),
|
||||
);
|
||||
|
||||
$rawEvidence = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_assignments',
|
||||
'changed_fields' => ['assignments_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'assignments_hash' => $baselineAssignmentsHash,
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'assignments_hash' => $currentAssignmentsHash,
|
||||
],
|
||||
];
|
||||
|
||||
$finding = Finding::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
$wasNew = ! $finding->exists;
|
||||
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||
]);
|
||||
|
||||
if ($wasNew) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$finding->save();
|
||||
|
||||
if ($wasNew) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
|
||||
$baselineScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($baselineVersion->scope_tags);
|
||||
$currentScopeTagIds = $this->scopeTagsNormalizer->normalizeIdsForHash($currentVersion->scope_tags);
|
||||
|
||||
if ($baselineScopeTagIds === null || $currentScopeTagIds === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baselineScopeTagsHash = $this->hasher->hashNormalized($baselineScopeTagIds);
|
||||
$currentScopeTagsHash = $this->hasher->hashNormalized($currentScopeTagIds);
|
||||
|
||||
if ($baselineScopeTagsHash === $currentScopeTagsHash) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fingerprint = $this->hasher->fingerprint(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
subjectType: 'scope_tag',
|
||||
subjectExternalId: (string) $policy->external_id,
|
||||
changeType: 'modified',
|
||||
baselineHash: $baselineScopeTagsHash,
|
||||
currentHash: $currentScopeTagsHash,
|
||||
);
|
||||
|
||||
$rawEvidence = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_scope_tags',
|
||||
'changed_fields' => ['scope_tags_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'scope_tags_hash' => $baselineScopeTagsHash,
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'scope_tags_hash' => $currentScopeTagsHash,
|
||||
],
|
||||
];
|
||||
|
||||
$finding = Finding::query()->firstOrNew([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
|
||||
$wasNew = ! $finding->exists;
|
||||
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'scope_tag',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'evidence_jsonb' => $this->evidence->sanitize($rawEvidence),
|
||||
]);
|
||||
|
||||
if ($wasNew) {
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$finding->save();
|
||||
|
||||
if ($wasNew) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion
|
||||
{
|
||||
if (! $run->finished_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', $policy->tenant_id)
|
||||
->where('policy_id', $policy->getKey())
|
||||
->where('captured_at', '<=', $run->finished_at)
|
||||
->latest('captured_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
class DriftHasher
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $volatileKeys
|
||||
*/
|
||||
public function hashNormalized(mixed $value, array $volatileKeys = [
|
||||
'@odata.context',
|
||||
'@odata.etag',
|
||||
'createdDateTime',
|
||||
'lastModifiedDateTime',
|
||||
'modifiedDateTime',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
]): string
|
||||
{
|
||||
$normalized = $this->normalizeValue($value, $volatileKeys);
|
||||
|
||||
return hash('sha256', json_encode($normalized, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
|
||||
public function fingerprint(
|
||||
int $tenantId,
|
||||
string $scopeKey,
|
||||
string $subjectType,
|
||||
string $subjectExternalId,
|
||||
string $changeType,
|
||||
string $baselineHash,
|
||||
string $currentHash,
|
||||
): string {
|
||||
$parts = [
|
||||
(string) $tenantId,
|
||||
$this->normalize($scopeKey),
|
||||
$this->normalize($subjectType),
|
||||
$this->normalize($subjectExternalId),
|
||||
$this->normalize($changeType),
|
||||
$this->normalize($baselineHash),
|
||||
$this->normalize($currentHash),
|
||||
];
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return trim(mb_strtolower($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $volatileKeys
|
||||
*/
|
||||
private function normalizeValue(mixed $value, array $volatileKeys): mixed
|
||||
{
|
||||
if (is_array($value)) {
|
||||
if ($this->isList($value)) {
|
||||
$items = array_map(fn ($item) => $this->normalizeValue($item, $volatileKeys), $value);
|
||||
|
||||
usort($items, function ($a, $b): int {
|
||||
return strcmp(
|
||||
json_encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
json_encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
});
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_string($key) && in_array($key, $volatileKeys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$key] = $this->normalizeValue($item, $volatileKeys);
|
||||
}
|
||||
|
||||
ksort($result);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (is_string($value)) {
|
||||
return trim($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function isList(array $value): bool
|
||||
{
|
||||
if ($value === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_keys($value) === range(0, count($value) - 1);
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
|
||||
class DriftRunSelector
|
||||
{
|
||||
/**
|
||||
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null
|
||||
*/
|
||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||
{
|
||||
$runs = InventorySyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_hash', $scopeKey)
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if ($runs->count() < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$current = $runs->first();
|
||||
$baseline = $runs->last();
|
||||
|
||||
if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'baseline' => $baseline,
|
||||
'current' => $current,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
class DriftScopeKey
|
||||
{
|
||||
public function fromRun(InventorySyncRun $run): string
|
||||
{
|
||||
return (string) $run->selection_hash;
|
||||
}
|
||||
}
|
||||
@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift\Normalizers;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class AssignmentsNormalizer
|
||||
{
|
||||
/**
|
||||
* @return array<int, array{key:string,include_exclude:string,target_type:string,target_id:string,filter_type:string,filter_id:?string,intent:?string,mode:?string}>
|
||||
*/
|
||||
public function normalizeForDiff(mixed $assignments): array
|
||||
{
|
||||
if (! is_array($assignments)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $assignment['target'] ?? null;
|
||||
if (! is_array($target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawType = $target['@odata.type'] ?? null;
|
||||
$targetType = $this->normalizeOdataType(is_string($rawType) ? $rawType : '');
|
||||
|
||||
$includeExclude = str_contains($targetType, 'exclusion') ? 'exclude' : 'include';
|
||||
$targetId = $this->extractTargetId($targetType, $target);
|
||||
|
||||
if ($targetId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterId = $target['deviceAndAppManagementAssignmentFilterId'] ?? null;
|
||||
$filterType = $target['deviceAndAppManagementAssignmentFilterType'] ?? 'none';
|
||||
|
||||
$intent = $assignment['intent'] ?? null;
|
||||
$mode = $assignment['mode'] ?? null;
|
||||
|
||||
$row = [
|
||||
'key' => implode('|', [
|
||||
$includeExclude,
|
||||
$targetType,
|
||||
$targetId,
|
||||
]),
|
||||
'include_exclude' => $includeExclude,
|
||||
'target_type' => $targetType,
|
||||
'target_id' => $targetId,
|
||||
'filter_type' => is_string($filterType) && $filterType !== '' ? strtolower(trim($filterType)) : 'none',
|
||||
'filter_id' => is_string($filterId) && $filterId !== '' ? $filterId : null,
|
||||
'intent' => is_string($intent) && $intent !== '' ? strtolower(trim($intent)) : null,
|
||||
'mode' => is_string($mode) && $mode !== '' ? strtolower(trim($mode)) : null,
|
||||
];
|
||||
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
usort($rows, function (array $a, array $b): int {
|
||||
return strcmp($a['key'], $b['key']);
|
||||
});
|
||||
|
||||
return array_values($rows);
|
||||
}
|
||||
|
||||
private function normalizeOdataType(string $odataType): string
|
||||
{
|
||||
$value = trim($odataType);
|
||||
$value = ltrim($value, '#');
|
||||
|
||||
if ($value === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
if (str_contains($value, '.')) {
|
||||
$value = (string) strrchr($value, '.');
|
||||
$value = ltrim($value, '.');
|
||||
}
|
||||
|
||||
return strtolower(trim($value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $target
|
||||
*/
|
||||
private function extractTargetId(string $targetType, array $target): string
|
||||
{
|
||||
if (str_contains($targetType, 'alldevicesassignmenttarget')) {
|
||||
return 'all_devices';
|
||||
}
|
||||
|
||||
if (str_contains($targetType, 'allusersassignmenttarget')) {
|
||||
return 'all_users';
|
||||
}
|
||||
|
||||
$groupId = Arr::get($target, 'groupId');
|
||||
if (is_string($groupId) && $groupId !== '') {
|
||||
return $groupId;
|
||||
}
|
||||
|
||||
$collectionId = Arr::get($target, 'collectionId');
|
||||
if (is_string($collectionId) && $collectionId !== '') {
|
||||
return $collectionId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@ -1,136 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift\Normalizers;
|
||||
|
||||
class ScopeTagsNormalizer
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function normalizeIds(mixed $scopeTags): array
|
||||
{
|
||||
return $this->normalizeIdsForHash($scopeTags) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* For drift hashing/comparison we need stable, reliable IDs.
|
||||
*
|
||||
* Legacy policy versions may have only `names` without `ids`. In that case we:
|
||||
* - infer `Default` as id `0`
|
||||
* - otherwise return null (unknown/unreliable; should not create drift)
|
||||
*
|
||||
* @return array<int, string>|null
|
||||
*/
|
||||
public function normalizeIdsForHash(mixed $scopeTags): ?array
|
||||
{
|
||||
if (! is_array($scopeTags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = $scopeTags['ids'] ?? null;
|
||||
if (is_array($ids)) {
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (! is_string($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim($id);
|
||||
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $id;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$names = $scopeTags['names'] ?? null;
|
||||
if (! is_array($names) || $names === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$normalizedNames = [];
|
||||
|
||||
foreach ($names as $name) {
|
||||
if (! is_string($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($name);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedNames[] = strtolower($name);
|
||||
}
|
||||
|
||||
$normalizedNames = array_values(array_unique($normalizedNames));
|
||||
sort($normalizedNames);
|
||||
|
||||
if ($normalizedNames === ['default']) {
|
||||
return ['0'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function labelsById(mixed $scopeTags): array
|
||||
{
|
||||
if (! is_array($scopeTags)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$ids = is_array($scopeTags['ids'] ?? null) ? $scopeTags['ids'] : null;
|
||||
$names = is_array($scopeTags['names'] ?? null) ? $scopeTags['names'] : [];
|
||||
|
||||
if (! is_array($ids)) {
|
||||
$inferred = $this->normalizeIdsForHash($scopeTags);
|
||||
|
||||
if ($inferred === ['0'] && $names !== []) {
|
||||
return ['0' => 'Default'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$labels = [];
|
||||
|
||||
foreach ($ids as $index => $id) {
|
||||
if (! is_string($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim($id);
|
||||
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $names[$index] ?? '';
|
||||
$name = is_string($name) ? trim($name) : '';
|
||||
|
||||
if ($name === '') {
|
||||
$name = $id === '0' ? 'Default' : $id;
|
||||
}
|
||||
|
||||
if (! array_key_exists($id, $labels) || $labels[$id] === $id) {
|
||||
$labels[$id] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
ksort($labels);
|
||||
|
||||
return $labels;
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Drift\Normalizers;
|
||||
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
|
||||
class SettingsNormalizer
|
||||
{
|
||||
public function __construct(private readonly PolicyNormalizer $policyNormalizer) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $snapshot
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function normalizeForDiff(?array $snapshot, string $policyType, ?string $platform = null): array
|
||||
{
|
||||
return $this->policyNormalizer->flattenForDiff($snapshot ?? [], $policyType, $platform);
|
||||
}
|
||||
}
|
||||
@ -6,18 +6,6 @@
|
||||
|
||||
class GraphContractRegistry
|
||||
{
|
||||
public function directoryGroupsPolicyType(): string
|
||||
{
|
||||
return 'directoryGroups';
|
||||
}
|
||||
|
||||
public function directoryGroupsListPath(): string
|
||||
{
|
||||
$resource = $this->resourcePath($this->directoryGroupsPolicyType()) ?? 'groups';
|
||||
|
||||
return '/'.ltrim($resource, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'staleness_days' => (int) env('DIRECTORY_GROUPS_STALENESS_DAYS', 30),
|
||||
|
||||
'retention_days' => (int) env('DIRECTORY_GROUPS_RETENTION_DAYS', 90),
|
||||
|
||||
'page_size' => (int) env('DIRECTORY_GROUPS_PAGE_SIZE', 999),
|
||||
|
||||
'schedule' => [
|
||||
'enabled' => (bool) env('DIRECTORY_GROUPS_SCHEDULE_ENABLED', true),
|
||||
|
||||
// Daily scheduled sync (UTC). The dispatcher is still expected to run every minute.
|
||||
'time_utc' => (string) env('DIRECTORY_GROUPS_SCHEDULE_TIME_UTC', '02:00'),
|
||||
|
||||
// Optional: limit scheduled dispatch to specific tenant ids/external ids (comma-separated).
|
||||
'tenants' => array_values(array_filter(array_map('trim', explode(',', (string) env('DIRECTORY_GROUPS_SCHEDULE_TENANTS', ''))))),
|
||||
],
|
||||
|
||||
'safety_stop' => [
|
||||
'max_pages' => (int) env('DIRECTORY_GROUPS_MAX_PAGES', 200),
|
||||
'max_runtime_seconds' => (int) env('DIRECTORY_GROUPS_MAX_RUNTIME_SECONDS', 600),
|
||||
'max_retries' => (int) env('DIRECTORY_GROUPS_MAX_RETRIES', 8),
|
||||
],
|
||||
];
|
||||
@ -12,11 +12,6 @@
|
||||
|
|
||||
*/
|
||||
'types' => [
|
||||
'directoryGroups' => [
|
||||
'resource' => 'groups',
|
||||
'allowed_select' => ['id', 'displayName', 'groupTypes', 'securityEnabled', 'mailEnabled'],
|
||||
'allowed_expand' => [],
|
||||
],
|
||||
'deviceConfiguration' => [
|
||||
'resource' => 'deviceManagement/deviceConfigurations',
|
||||
'allowed_select' => ['id', 'displayName', 'description', '@odata.type', 'version', 'lastModifiedDateTime'],
|
||||
|
||||
@ -6,13 +6,13 @@
|
||||
'key' => 'DeviceManagementConfiguration.ReadWrite.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Read and write Intune device configuration policies.',
|
||||
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization', 'drift'],
|
||||
'features' => ['policy-sync', 'backup', 'restore', 'settings-normalization'],
|
||||
],
|
||||
[
|
||||
'key' => 'DeviceManagementConfiguration.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Read Intune device configuration policies (least-privilege for inventory).',
|
||||
'features' => ['policy-sync', 'backup', 'settings-normalization', 'drift'],
|
||||
'features' => ['policy-sync', 'backup', 'settings-normalization'],
|
||||
],
|
||||
[
|
||||
'key' => 'DeviceManagementApps.ReadWrite.All',
|
||||
@ -72,7 +72,7 @@
|
||||
'key' => 'Group.Read.All',
|
||||
'type' => 'application',
|
||||
'description' => 'Read group information for resolving assignment group names and cross-tenant group mapping.',
|
||||
'features' => ['assignments', 'group-mapping', 'backup-metadata', 'directory-groups', 'group-directory-cache', 'drift'],
|
||||
'features' => ['assignments', 'group-mapping', 'backup-metadata'],
|
||||
],
|
||||
[
|
||||
'key' => 'DeviceManagementScripts.ReadWrite.All',
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EntraGroup>
|
||||
*/
|
||||
class EntraGroupFactory extends Factory
|
||||
{
|
||||
protected $model = EntraGroup::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'entra_id' => fake()->uuid(),
|
||||
'display_name' => fake()->company(),
|
||||
'group_types' => [],
|
||||
'security_enabled' => true,
|
||||
'mail_enabled' => false,
|
||||
'last_seen_at' => now('UTC'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EntraGroupSyncRun>
|
||||
*/
|
||||
class EntraGroupSyncRunFactory extends Factory
|
||||
{
|
||||
protected $model = EntraGroupSyncRun::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'selection_key' => 'groups-v1:all',
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => User::factory(),
|
||||
'pages_fetched' => 0,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'error_count' => 0,
|
||||
'safety_stop_triggered' => false,
|
||||
'safety_stop_reason' => null,
|
||||
'error_code' => null,
|
||||
'error_category' => null,
|
||||
'error_summary' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduled(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'initiator_user_id' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Finding>
|
||||
*/
|
||||
class FindingFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => hash('sha256', fake()->uuid()),
|
||||
'baseline_run_id' => null,
|
||||
'current_run_id' => null,
|
||||
'fingerprint' => hash('sha256', fake()->uuid()),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => fake()->uuid(),
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
'evidence_jsonb' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entra_groups', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->uuid('entra_id');
|
||||
$table->string('display_name');
|
||||
$table->jsonb('group_types')->nullable();
|
||||
$table->boolean('security_enabled')->default(false);
|
||||
$table->boolean('mail_enabled')->default(false);
|
||||
|
||||
$table->timestampTz('last_seen_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'entra_id']);
|
||||
$table->index(['tenant_id', 'display_name']);
|
||||
$table->index(['tenant_id', 'last_seen_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entra_groups');
|
||||
}
|
||||
};
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('entra_group_sync_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
|
||||
$table->string('selection_key');
|
||||
$table->string('slot_key')->nullable();
|
||||
|
||||
$table->string('status');
|
||||
|
||||
$table->string('error_code')->nullable();
|
||||
$table->string('error_category')->nullable();
|
||||
$table->text('error_summary')->nullable();
|
||||
|
||||
$table->boolean('safety_stop_triggered')->default(false);
|
||||
$table->string('safety_stop_reason')->nullable();
|
||||
|
||||
$table->unsignedInteger('pages_fetched')->default(0);
|
||||
$table->unsignedInteger('items_observed_count')->default(0);
|
||||
$table->unsignedInteger('items_upserted_count')->default(0);
|
||||
$table->unsignedInteger('error_count')->default(0);
|
||||
|
||||
$table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestampTz('started_at')->nullable();
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'selection_key']);
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'finished_at']);
|
||||
$table->unique(['tenant_id', 'selection_key', 'slot_key']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('entra_group_sync_runs');
|
||||
}
|
||||
};
|
||||
@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('findings', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
|
||||
$table->string('finding_type');
|
||||
$table->string('scope_key');
|
||||
|
||||
$table->foreignId('baseline_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
$table->foreignId('current_run_id')->nullable()->constrained('inventory_sync_runs');
|
||||
|
||||
$table->string('fingerprint', 64);
|
||||
|
||||
$table->string('subject_type');
|
||||
$table->string('subject_external_id');
|
||||
|
||||
$table->string('severity');
|
||||
$table->string('status');
|
||||
|
||||
$table->timestampTz('acknowledged_at')->nullable();
|
||||
$table->foreignId('acknowledged_by_user_id')->nullable()->constrained('users');
|
||||
|
||||
$table->jsonb('evidence_jsonb')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['tenant_id', 'fingerprint']);
|
||||
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'scope_key']);
|
||||
$table->index(['tenant_id', 'baseline_run_id']);
|
||||
$table->index(['tenant_id', 'current_run_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('findings');
|
||||
}
|
||||
};
|
||||
@ -1,114 +0,0 @@
|
||||
@php
|
||||
$diff = $getState() ?? [];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
|
||||
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||
$changed = is_array($diff['changed'] ?? null) ? $diff['changed'] : [];
|
||||
|
||||
$renderRow = static function (array $row): array {
|
||||
return [
|
||||
'include_exclude' => (string) ($row['include_exclude'] ?? 'include'),
|
||||
'target_label' => (string) ($row['target_label'] ?? 'Unknown target'),
|
||||
'filter_type' => (string) ($row['filter_type'] ?? 'none'),
|
||||
'filter_id' => $row['filter_id'] ?? null,
|
||||
'intent' => $row['intent'] ?? null,
|
||||
'mode' => $row['mode'] ?? null,
|
||||
];
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Assignments diff"
|
||||
:description="$summary['message'] ?? sprintf('%d added, %d removed, %d changed', $summary['added'] ?? 0, $summary['removed'] ?? 0, $summary['changed'] ?? 0)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="warning">
|
||||
{{ (int) ($summary['changed'] ?? 0) }} changed
|
||||
</x-filament::badge>
|
||||
|
||||
@if (($summary['truncated'] ?? false) === true)
|
||||
<x-filament::badge color="gray">
|
||||
Truncated to {{ (int) ($summary['limit'] ?? 0) }} items
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($changed !== [])
|
||||
<x-filament::section heading="Changed" collapsible>
|
||||
<div class="space-y-3">
|
||||
@foreach ($changed as $row)
|
||||
@php
|
||||
$to = is_array($row['to'] ?? null) ? $renderRow($row['to']) : $renderRow([]);
|
||||
$from = is_array($row['from'] ?? null) ? $renderRow($row['from']) : $renderRow([]);
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">
|
||||
{{ $to['target_label'] }}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid gap-2 text-sm md:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">From</div>
|
||||
<div class="mt-1 space-y-1">
|
||||
<div>Type: {{ $from['include_exclude'] }}</div>
|
||||
<div>Filter: {{ $from['filter_type'] }}@if($from['filter_id']) ({{ $from['filter_id'] }})@endif</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold uppercase text-gray-500 dark:text-gray-400">To</div>
|
||||
<div class="mt-1 space-y-1">
|
||||
<div>Type: {{ $to['include_exclude'] }}</div>
|
||||
<div>Filter: {{ $to['filter_type'] }}@if($to['filter_id']) ({{ $to['filter_id'] }})@endif</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($added !== [])
|
||||
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($added as $row)
|
||||
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($removed !== [])
|
||||
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($removed as $row)
|
||||
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $row['target_label'] }}</div>
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">
|
||||
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</div>
|
||||
@ -13,26 +13,6 @@
|
||||
$foundationItems = collect($results)->filter($isFoundationEntry);
|
||||
$policyItems = collect($results)->reject($isFoundationEntry);
|
||||
}
|
||||
|
||||
$tenant = rescue(fn () => \App\Models\Tenant::current(), null);
|
||||
$groupLabelResolver = $tenant ? app(\App\Services\Directory\EntraGroupLabelResolver::class) : null;
|
||||
|
||||
$formatGroupId = function ($groupId, $fallbackName = null) use ($tenant, $groupLabelResolver) {
|
||||
if (! is_string($groupId) || $groupId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cachedName = null;
|
||||
|
||||
if ($tenant && $groupLabelResolver) {
|
||||
$cached = $groupLabelResolver->lookupMany($tenant, [$groupId]);
|
||||
$cachedName = $cached[strtolower($groupId)] ?? null;
|
||||
}
|
||||
|
||||
$name = is_string($fallbackName) && $fallbackName !== '' ? $fallbackName : null;
|
||||
|
||||
return \App\Services\Directory\EntraGroupLabelResolver::formatLabel($cachedName ?? $name, $groupId);
|
||||
};
|
||||
@endphp
|
||||
|
||||
@if ($foundationItems->isEmpty() && $policyItems->isEmpty())
|
||||
@ -172,15 +152,12 @@
|
||||
};
|
||||
$assignmentGroupId = $outcome['group_id']
|
||||
?? ($outcome['assignment']['target']['groupId'] ?? null);
|
||||
$assignmentGroupLabel = $formatGroupId(is_string($assignmentGroupId) ? $assignmentGroupId : null);
|
||||
$mappedGroupId = $outcome['mapped_group_id'] ?? null;
|
||||
$mappedGroupLabel = $formatGroupId(is_string($mappedGroupId) ? $mappedGroupId : null);
|
||||
@endphp
|
||||
|
||||
<div class="rounded border border-amber-200 bg-white p-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-semibold text-gray-900">
|
||||
Assignment {{ $assignmentGroupLabel ?? ($assignmentGroupId ?? 'unknown group') }}
|
||||
Assignment {{ $assignmentGroupId ?? 'unknown group' }}
|
||||
</div>
|
||||
<span class="rounded border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide {{ $outcomeColor }}">
|
||||
{{ $outcomeStatus }}
|
||||
@ -189,7 +166,7 @@
|
||||
|
||||
@if (! empty($outcome['mapped_group_id']))
|
||||
<div class="mt-1 text-[11px] text-gray-800">
|
||||
Mapped to: {{ $mappedGroupLabel ?? $outcome['mapped_group_id'] }}
|
||||
Mapped to: {{ $outcome['mapped_group_id'] }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
@ -1,111 +0,0 @@
|
||||
@php
|
||||
$diff = $getState() ?? [];
|
||||
$summary = $diff['summary'] ?? [];
|
||||
|
||||
$added = is_array($diff['added'] ?? null) ? $diff['added'] : [];
|
||||
$removed = is_array($diff['removed'] ?? null) ? $diff['removed'] : [];
|
||||
$baseline = is_array($diff['baseline'] ?? null) ? $diff['baseline'] : [];
|
||||
$current = is_array($diff['current'] ?? null) ? $diff['current'] : [];
|
||||
@endphp
|
||||
|
||||
<div class="space-y-4">
|
||||
<x-filament::section
|
||||
heading="Scope tags diff"
|
||||
:description="$summary['message'] ?? sprintf('%d added, %d removed', $summary['added'] ?? 0, $summary['removed'] ?? 0)"
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-filament::badge color="success">
|
||||
{{ (int) ($summary['added'] ?? 0) }} added
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="danger">
|
||||
{{ (int) ($summary['removed'] ?? 0) }} removed
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
Baseline: {{ (int) ($summary['baseline_count'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray">
|
||||
Current: {{ (int) ($summary['current_count'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
|
||||
@if ($added !== [])
|
||||
<x-filament::section heading="Added" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($added as $row)
|
||||
@php
|
||||
$name = (string) ($row['name'] ?? 'Unknown');
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $name }}</div>
|
||||
@if ($id !== '')
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($removed !== [])
|
||||
<x-filament::section heading="Removed" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($removed as $row)
|
||||
@php
|
||||
$name = (string) ($row['name'] ?? 'Unknown');
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $name }}</div>
|
||||
@if ($id !== '')
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($current !== [])
|
||||
<x-filament::section heading="Current" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($current as $row)
|
||||
@php
|
||||
$name = (string) ($row['name'] ?? 'Unknown');
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $name }}</div>
|
||||
@if ($id !== '')
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
|
||||
@if ($baseline !== [])
|
||||
<x-filament::section heading="Baseline" collapsible :collapsed="true">
|
||||
<div class="space-y-2">
|
||||
@foreach ($baseline as $row)
|
||||
@php
|
||||
$name = (string) ($row['name'] ?? 'Unknown');
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
@endphp
|
||||
|
||||
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div class="font-medium">{{ $name }}</div>
|
||||
@if ($id !== '')
|
||||
<div class="mt-1 text-gray-600 dark:text-gray-300">{{ $id }}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</x-filament::section>
|
||||
@endif
|
||||
</div>
|
||||
@ -1,3 +0,0 @@
|
||||
<div class="space-y-4">
|
||||
<livewire:entra-group-cache-picker-table :sourceGroupId="$sourceGroupId" />
|
||||
</div>
|
||||
@ -1,107 +0,0 @@
|
||||
<x-filament::page>
|
||||
<x-filament::section>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Review new drift findings between the last two inventory sync runs for the current scope.
|
||||
</div>
|
||||
|
||||
@if (filled($scopeKey))
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Scope: {{ $scopeKey }}
|
||||
@if ($baselineRunId && $currentRunId)
|
||||
· Baseline
|
||||
@if ($this->getBaselineRunUrl())
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBaselineRunUrl() }}">
|
||||
#{{ $baselineRunId }}
|
||||
</a>
|
||||
@else
|
||||
#{{ $baselineRunId }}
|
||||
@endif
|
||||
@if (filled($baselineFinishedAt))
|
||||
({{ $baselineFinishedAt }})
|
||||
@endif
|
||||
· Current
|
||||
@if ($this->getCurrentRunUrl())
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getCurrentRunUrl() }}">
|
||||
#{{ $currentRunId }}
|
||||
</a>
|
||||
@else
|
||||
#{{ $currentRunId }}
|
||||
@endif
|
||||
@if (filled($currentFinishedAt))
|
||||
({{ $currentFinishedAt }})
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($state === 'blocked')
|
||||
<x-filament::badge color="gray">
|
||||
Blocked
|
||||
</x-filament::badge>
|
||||
|
||||
@if (filled($message))
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
@elseif ($state === 'generating')
|
||||
<x-filament::badge color="warning">
|
||||
Generating
|
||||
</x-filament::badge>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Drift generation has been queued. Refresh this page once it finishes.
|
||||
</div>
|
||||
|
||||
@if ($this->getBulkRunUrl())
|
||||
<div class="text-sm">
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||
View run #{{ $bulkOperationRunId }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@elseif ($state === 'error')
|
||||
<x-filament::badge color="danger">
|
||||
Error
|
||||
</x-filament::badge>
|
||||
|
||||
@if (filled($message))
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($this->getBulkRunUrl())
|
||||
<div class="text-sm">
|
||||
<a class="text-primary-600 hover:underline" href="{{ $this->getBulkRunUrl() }}">
|
||||
View run #{{ $bulkOperationRunId }}
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
@elseif ($state === 'ready')
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<x-filament::badge color="success">
|
||||
New: {{ (int) ($statusCounts['new'] ?? 0) }}
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
@if (filled($message))
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $message }}
|
||||
</div>
|
||||
@endif
|
||||
@else
|
||||
<x-filament::badge color="gray">
|
||||
Ready
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<x-filament::button tag="a" :href="$this->getFindingsUrl()">
|
||||
Findings
|
||||
</x-filament::button>
|
||||
</div>
|
||||
</div>
|
||||
</x-filament::section>
|
||||
</x-filament::page>
|
||||
@ -3,8 +3,6 @@
|
||||
@if($runs->isNotEmpty())
|
||||
<div class="fixed bottom-4 right-4 z-[999999] w-96 space-y-2" style="pointer-events: auto;">
|
||||
@foreach ($runs as $run)
|
||||
@php($effectiveTotal = max((int) $run->total_items, (int) $run->processed_items))
|
||||
@php($percent = $effectiveTotal > 0 ? min(100, round(($run->processed_items / $effectiveTotal) * 100)) : 0)
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl border-2 border-primary-500 dark:border-primary-400 p-4 transition-all animate-in slide-in-from-right duration-300"
|
||||
wire:key="run-{{ $run->id }}">
|
||||
|
||||
@ -40,17 +38,17 @@
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ $run->processed_items }} / {{ $effectiveTotal }}
|
||||
{{ $run->processed_items }} / {{ $run->total_items }}
|
||||
</span>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ $percent }}%
|
||||
{{ $run->total_items > 0 ? round(($run->processed_items / $run->total_items) * 100) : 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 rounded-full h-3 dark:bg-gray-700 overflow-hidden">
|
||||
<div class="bg-primary-600 dark:bg-primary-500 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
style="width: {{ $percent }}%"></div>
|
||||
style="width: {{ $run->total_items > 0 ? ($run->processed_items / $run->total_items) * 100 : 0 }}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex items-center justify-between text-xs">
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
<div class="space-y-2">
|
||||
{{ $this->table }}
|
||||
</div>
|
||||
@ -66,24 +66,21 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
|
||||
|
||||
@if($groupId)
|
||||
@php
|
||||
$groupLabel = $groupLabels[$groupId] ?? \App\Services\Directory\EntraGroupLabelResolver::formatLabel(
|
||||
is_string($groupName) ? $groupName : null,
|
||||
(string) $groupId,
|
||||
);
|
||||
@endphp
|
||||
<span class="text-gray-600 dark:text-gray-400">:</span>
|
||||
@if($groupOrphaned)
|
||||
<span class="text-warning-600 dark:text-warning-400">
|
||||
⚠️ {{ $groupLabel }}
|
||||
⚠️ Unknown group (ID: {{ $groupId }})
|
||||
</span>
|
||||
@elseif($groupLabel)
|
||||
@elseif($groupName)
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{{ $groupLabel }}
|
||||
{{ $groupName }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
({{ $groupId }})
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{{ $groupId }}
|
||||
Group ID: {{ $groupId }}
|
||||
</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@ -9,4 +9,3 @@
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('tenantpilot:schedules:dispatch')->everyMinute();
|
||||
Schedule::command('tenantpilot:directory-groups:dispatch')->everyMinute();
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Drift MVP (044)
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to implementation
|
||||
**Created**: 2026-01-12
|
||||
**Feature**: [specs/044-drift-mvp/spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs) (spec.md contains scenarios/rules/states/acceptance only)
|
||||
- [x] Focused on user value and business needs (spec.md: Purpose, User Scenarios, Acceptance Criteria)
|
||||
- [x] Written for non-technical stakeholders (spec.md uses plain language; avoids code/framework terms)
|
||||
- [x] All mandatory sections completed (spec.md includes Purpose, User Scenarios, Rules, Acceptance Criteria)
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain (spec.md: no "[NEEDS CLARIFICATION]" markers)
|
||||
- [x] Requirements are testable and unambiguous (spec.md: Rules + Acceptance Criteria)
|
||||
- [x] Success criteria are measurable (spec.md: Acceptance Criteria)
|
||||
- [x] Success criteria are technology-agnostic (no implementation details) (spec.md: Acceptance Criteria)
|
||||
- [x] All acceptance scenarios are defined (spec.md: Scenario 1/2/3)
|
||||
- [x] Edge cases are identified (spec.md: blocked state; error state; acknowledgement per comparison)
|
||||
- [x] Scope is clearly bounded (spec.md: Rules → Coverage (MVP))
|
||||
- [x] Dependencies and assumptions identified (spec.md: Rules → UI states; Run tracking)
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria (spec.md: Rules + Acceptance Criteria)
|
||||
- [x] User scenarios cover primary flows (spec.md: Scenario 1/2/3)
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria (spec.md: Acceptance Criteria are measurable and testable)
|
||||
- [x] No implementation details leak into specification (spec.md avoids implementation and names a generic “persisted run record” only)
|
||||
|
||||
## Notes
|
||||
|
||||
- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`.
|
||||
- Constitution gate: this checklist must exist for features that change runtime behavior.
|
||||
@ -1,167 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Admin Findings API (Internal)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Internal contracts for the generic Findings pipeline.
|
||||
Drift MVP is the first generator (finding_type=drift).
|
||||
|
||||
servers:
|
||||
- url: /admin/api
|
||||
|
||||
paths:
|
||||
/findings:
|
||||
get:
|
||||
summary: List findings
|
||||
parameters:
|
||||
- in: query
|
||||
name: finding_type
|
||||
schema:
|
||||
type: string
|
||||
enum: [drift, audit, compare]
|
||||
- in: query
|
||||
name: status
|
||||
schema:
|
||||
type: string
|
||||
enum: [new, acknowledged]
|
||||
- in: query
|
||||
name: scope_key
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: current_run_id
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/findings/{id}:
|
||||
get:
|
||||
summary: Get finding detail
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/findings/{id}/acknowledge:
|
||||
post:
|
||||
summary: Acknowledge a finding
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Finding'
|
||||
|
||||
/drift/generate:
|
||||
post:
|
||||
summary: Generate drift findings (async)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
scope_key:
|
||||
type: string
|
||||
description: Inventory selection hash
|
||||
required: [scope_key]
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DriftGenerateAccepted'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
DriftGenerateAccepted:
|
||||
type: object
|
||||
properties:
|
||||
bulk_operation_run_id:
|
||||
type: integer
|
||||
description: Canonical async run record (status/errors/idempotency)
|
||||
scope_key:
|
||||
type: string
|
||||
baseline_run_id:
|
||||
type: integer
|
||||
current_run_id:
|
||||
type: integer
|
||||
required: [bulk_operation_run_id, scope_key, baseline_run_id, current_run_id]
|
||||
|
||||
Finding:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
finding_type:
|
||||
type: string
|
||||
enum: [drift, audit, compare]
|
||||
tenant_id:
|
||||
type: integer
|
||||
scope_key:
|
||||
type: string
|
||||
baseline_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
current_run_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
fingerprint:
|
||||
type: string
|
||||
subject_type:
|
||||
type: string
|
||||
subject_external_id:
|
||||
type: string
|
||||
severity:
|
||||
type: string
|
||||
enum: [low, medium, high]
|
||||
status:
|
||||
type: string
|
||||
enum: [new, acknowledged]
|
||||
acknowledged_at:
|
||||
type: string
|
||||
nullable: true
|
||||
acknowledged_by_user_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
evidence_jsonb:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
@ -1,57 +0,0 @@
|
||||
# Phase 1 Design: Data Model (044)
|
||||
|
||||
## Entities
|
||||
|
||||
### Finding
|
||||
|
||||
New table: `findings`
|
||||
|
||||
**Purpose**: Generic, persisted pipeline for analytic findings (Drift now; Audit/Compare later).
|
||||
|
||||
**Core fields (MVP)**
|
||||
- `id` (pk)
|
||||
- `tenant_id` (fk tenants)
|
||||
- `finding_type` (`drift` in MVP; later `audit`/`compare`)
|
||||
- `scope_key` (string; deterministic; reuse Inventory selection hash)
|
||||
- `baseline_run_id` (nullable fk inventory_sync_runs)
|
||||
- `current_run_id` (nullable fk inventory_sync_runs)
|
||||
- `fingerprint` (string; deterministic)
|
||||
- `subject_type` (string; e.g. policy type)
|
||||
- `subject_external_id` (string; Graph external id)
|
||||
- `severity` (`low|medium|high`; MVP default `medium`)
|
||||
- `status` (`new|acknowledged`)
|
||||
- `acknowledged_at` (nullable)
|
||||
- `acknowledged_by_user_id` (nullable fk users)
|
||||
- `evidence_jsonb` (jsonb; sanitized, small; allowlist)
|
||||
|
||||
**Prepared for later (nullable, out of MVP)**
|
||||
- `rule_id`, `control_id`, `expected_value`, `source`
|
||||
|
||||
## Constraints & Indexes
|
||||
|
||||
**Uniqueness**
|
||||
- Unique: `(tenant_id, fingerprint)`
|
||||
|
||||
**Lookup indexes (suggested)**
|
||||
- `(tenant_id, finding_type, status)`
|
||||
- `(tenant_id, scope_key)`
|
||||
- `(tenant_id, current_run_id)`
|
||||
- `(tenant_id, baseline_run_id)`
|
||||
- `(tenant_id, subject_type, subject_external_id)`
|
||||
|
||||
## Relationships
|
||||
|
||||
- `Finding` belongs to `Tenant`.
|
||||
- `Finding` belongs to `User` via `acknowledged_by_user_id`.
|
||||
- `Finding` belongs to `InventorySyncRun` via `baseline_run_id` (nullable) and `current_run_id` (nullable).
|
||||
|
||||
## Evidence shape (MVP allowlist)
|
||||
|
||||
For Drift MVP, `evidence_jsonb` should contain only:
|
||||
- `change_type`
|
||||
- `changed_fields` (list) and/or `change_counts`
|
||||
- `run`:
|
||||
- `baseline_run_id`, `current_run_id`
|
||||
- `baseline_finished_at`, `current_finished_at`
|
||||
|
||||
No raw policy payload dumps; exclude secrets/tokens; exclude volatile fields for hashing.
|
||||
@ -1,115 +1,24 @@
|
||||
# Implementation Plan: Drift MVP (044)
|
||||
# Implementation Plan: Drift MVP
|
||||
|
||||
**Branch**: `feat/044-drift-mvp` | **Date**: 2026-01-12 | **Spec**: `specs/044-drift-mvp/spec.md`
|
||||
**Input**: Feature specification from `specs/044-drift-mvp/spec.md`
|
||||
**Date**: 2026-01-07
|
||||
**Spec**: `specs/044-drift-mvp/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Introduce a generic, persisted Finding pipeline and implement Drift as the first generator.
|
||||
Add drift findings generation and UI using inventory and sync run metadata.
|
||||
|
||||
- Drift compares Inventory Sync Runs for the same selection scope (`scope_key`).
|
||||
- Baseline run = previous successful run for the same scope; comparison run = latest successful run.
|
||||
- Findings are persisted with deterministic fingerprints and support MVP triage (`new` → `acknowledged`).
|
||||
- UI is DB-only for label/name resolution (no render-time Graph calls).
|
||||
- Drift generation is tracked via `BulkOperationRun` for status/errors across refresh and idempotency.
|
||||
## Dependencies
|
||||
|
||||
## Technical Context
|
||||
- Inventory core + run tracking (Spec 040)
|
||||
- Inventory UI patterns (Spec 041)
|
||||
|
||||
**Language/Version**: PHP 8.4.x
|
||||
**Framework**: Laravel 12
|
||||
**Admin UI**: Filament v4 + Livewire v3
|
||||
**Storage**: PostgreSQL (JSONB)
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Docker (Sail-first local), Dokploy container deployments
|
||||
**Project Type**: Laravel monolith
|
||||
**Performance Goals**:
|
||||
- Drift generation happens async (job), with deterministic output
|
||||
- Drift listing remains filterable and index-backed
|
||||
**Constraints**:
|
||||
- Tenant isolation for all reads/writes
|
||||
- No render-time Graph calls; labels resolved from DB caches
|
||||
- Evidence minimization (sanitized allowlist; no raw payload dumps)
|
||||
- Drift generation is job-only and uses `BulkOperationRun` (`resource=drift`, `action=generate`) as the canonical run record
|
||||
**Scale/Scope**:
|
||||
- Tenants may have large inventories; findings must be indexed for typical filtering
|
||||
## Deliverables
|
||||
|
||||
## Constitution Check
|
||||
- Baseline definition and drift finding generation
|
||||
- Drift summary + detail UI
|
||||
- Acknowledge/triage actions
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
## Risks
|
||||
|
||||
- Inventory-first: Drift is derived from Inventory Sync Runs and Inventory Items (“last observed” state).
|
||||
- Read/write separation: Drift generation is analytical; writes are limited to triage acknowledgement and must be audited + tested.
|
||||
- Graph contract path: Drift UI performs no Graph calls; Graph calls remain isolated in existing Inventory/Graph client layers.
|
||||
- Deterministic capabilities: drift scope derives from existing selection hashing and inventory type registries.
|
||||
- Tenant isolation: all reads/writes tenant-scoped; no cross-tenant leakage.
|
||||
- Automation: drift generation is queued; jobs are deduped/locked per scope+run pair and observable.
|
||||
- Data minimization: store only minimized evidence JSON; logs contain no secrets/tokens.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/044-drift-mvp/
|
||||
├── plan.md # This file (/speckit.plan output)
|
||||
├── research.md # Phase 0 output
|
||||
├── data-model.md # Phase 1 output
|
||||
├── quickstart.md # Phase 1 output
|
||||
├── contracts/ # Phase 1 output
|
||||
│ └── admin-findings.openapi.yaml
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ │ └── DriftLanding.php # drift landing page (summary + generation status)
|
||||
│ └── Resources/
|
||||
│ └── FindingResource/ # list/detail + acknowledge action (tenant-scoped)
|
||||
├── Jobs/
|
||||
│ └── GenerateDriftFindingsJob.php # async generator (on-demand)
|
||||
├── Models/
|
||||
│ └── Finding.php # generic finding model
|
||||
└── Services/
|
||||
└── Drift/
|
||||
├── DriftFindingGenerator.php # computes deterministic findings for baseline/current
|
||||
├── DriftHasher.php # baseline_hash/current_hash helpers
|
||||
└── DriftScopeKey.php # scope_key is InventorySyncRun.selection_hash (single canonical definition)
|
||||
|
||||
database/migrations/
|
||||
└── 2026_.._.._create_findings_table.php
|
||||
|
||||
tests/Feature/Drift/
|
||||
├── DriftGenerationDeterminismTest.php
|
||||
├── DriftTenantIsolationTest.php
|
||||
└── DriftAcknowledgeTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith using Filament pages/resources and queued jobs.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| (none) | | |
|
||||
|
||||
## Phase 0 Output (Research)
|
||||
|
||||
Completed in `specs/044-drift-mvp/research.md`.
|
||||
|
||||
## Phase 1 Output (Design)
|
||||
|
||||
Completed in:
|
||||
|
||||
- `specs/044-drift-mvp/data-model.md`
|
||||
- `specs/044-drift-mvp/contracts/`
|
||||
- `specs/044-drift-mvp/quickstart.md`
|
||||
|
||||
## Phase 2 Planning Notes
|
||||
|
||||
Next step is expanding `specs/044-drift-mvp/tasks.md` (via `/speckit.tasks`) with phased, test-first implementation tasks.
|
||||
- False positives if baseline definition is unclear
|
||||
- Data volume for large tenants
|
||||
|
||||
@ -1,32 +0,0 @@
|
||||
# Quickstart: Drift MVP (044)
|
||||
|
||||
## Run locally (Sail)
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail up -d
|
||||
./vendor/bin/sail artisan queue:work --tries=1
|
||||
```
|
||||
|
||||
## Prepare data
|
||||
|
||||
1. Open the admin panel and select a tenant context.
|
||||
2. Navigate to Inventory and run an Inventory Sync **twice** with the same selection (same `selection_hash`).
|
||||
|
||||
## Use Drift
|
||||
|
||||
1. Navigate to the new Drift area.
|
||||
2. On first open, Drift will queue background generation and record status in a persisted run record.
|
||||
3. Generation produces findings for:
|
||||
- baseline = previous successful run for the same `scope_key`
|
||||
- current = latest successful run for the same `scope_key`
|
||||
4. Refresh the page once generation finishes.
|
||||
|
||||
## Triage
|
||||
|
||||
- Acknowledge a finding; it moves out of the default “new” view but remains visible/auditable.
|
||||
- Use the status filter to include acknowledged findings.
|
||||
|
||||
## Notes
|
||||
|
||||
- UI must remain DB-only for label resolution (no render-time Graph calls).
|
||||
- Findings store minimal, sanitized evidence only.
|
||||
@ -1,72 +0,0 @@
|
||||
# Phase 0 Output: Research (044)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1) `scope_key` reuse
|
||||
|
||||
- Decision: Use the existing Inventory selection hash as `scope_key`.
|
||||
- Concretely: `scope_key = InventorySyncRun.selection_hash`.
|
||||
- Rationale:
|
||||
- Inventory already normalizes + hashes selection payload deterministically (via `InventorySelectionHasher`).
|
||||
- It is already used for concurrency/deduping inventory runs, so it’s the right stable scope identifier.
|
||||
- Alternatives considered:
|
||||
- Compute a second hash (duplicate of selection_hash) → adds drift without benefit.
|
||||
- Store the raw selection payload as the primary key → not stable without strict normalization.
|
||||
|
||||
### 2) Baseline selection (MVP)
|
||||
|
||||
- Decision: Baseline run = previous successful inventory sync run for the same `scope_key`; comparison run = latest successful inventory sync run for the same `scope_key`.
|
||||
- Rationale:
|
||||
- Matches “run at least twice” scenario.
|
||||
- Deterministic and explainable.
|
||||
- Alternatives considered:
|
||||
- User-pinned baselines → valuable, but deferred (design must allow later via `scope_key`).
|
||||
|
||||
### 3) Persisted generic Findings
|
||||
|
||||
- Decision: Persist Findings in a generic `findings` table.
|
||||
- Rationale:
|
||||
- Enables stable triage (`acknowledged`) without recomputation drift.
|
||||
- Reusable pipeline for Drift now, Audit/Compare later.
|
||||
- Alternatives considered:
|
||||
- Compute-on-demand and store only acknowledgements by fingerprint → harder operationally and can surprise users when diff rules evolve.
|
||||
|
||||
### 4) Generation trigger (MVP)
|
||||
|
||||
- Decision: On opening Drift, if findings for (tenant, `scope_key`, baseline_run_id, current_run_id) do not exist, dispatch an async job to generate them.
|
||||
- Rationale:
|
||||
- Avoids long request times.
|
||||
- Avoids scheduled complexity in MVP.
|
||||
- Alternatives considered:
|
||||
- Generate after every inventory run → may be expensive; can be added later.
|
||||
- Nightly schedule → hides immediacy and complicates operations.
|
||||
|
||||
### 5) Fingerprint and state hashing
|
||||
|
||||
- Decision: Use a deterministic fingerprint that changes when the underlying state changes.
|
||||
- Fingerprint = `sha256(tenant_id + scope_key + subject_type + subject_external_id + change_type + baseline_hash + current_hash)`.
|
||||
- baseline_hash/current_hash are computed over normalized, sanitized comparison data (exclude volatile fields like timestamps).
|
||||
- Rationale:
|
||||
- Stable identity for triage and audit.
|
||||
- Supports future generators (audit/compare) using same semantics.
|
||||
- Alternatives considered:
|
||||
- Fingerprint without baseline/current hash → cannot distinguish changed vs unchanged findings.
|
||||
|
||||
### 6) Evidence minimization
|
||||
|
||||
- Decision: Store small, sanitized `evidence_jsonb` with an allowlist shape; no raw payload dumps.
|
||||
- Rationale:
|
||||
- Aligns with data minimization + safe logging.
|
||||
- Avoids storing secrets/tokens.
|
||||
|
||||
### 7) Name resolution and Graph safety
|
||||
|
||||
- Decision: UI resolves human-readable labels using DB-backed Inventory + Foundations (047) + Groups Cache (051). No render-time Graph calls.
|
||||
- Rationale:
|
||||
- Works offline / when tokens are broken.
|
||||
- Keeps UI safe and predictable.
|
||||
|
||||
## Notes / Follow-ups for Phase 1
|
||||
|
||||
- Define the `findings` table indexes carefully for tenant-scoped filtering (status, type, scope_key, run_ids).
|
||||
- Consider using existing observable run patterns (BulkOperationRun + AuditLogger) for drift generation jobs.
|
||||
@ -1,81 +1,55 @@
|
||||
# Feature Specification: Drift MVP (044)
|
||||
# Feature Specification: Drift MVP
|
||||
|
||||
**Feature Branch**: `feat/044-drift-mvp`
|
||||
**Created**: 2026-01-07
|
||||
**Status**: Draft
|
||||
|
||||
## Purpose
|
||||
|
||||
Help admins quickly spot and triage configuration “drift”: what changed between two inventory snapshots.
|
||||
Detect and report drift between expected and observed states using inventory and run metadata.
|
||||
|
||||
This MVP is about visibility and acknowledgement (triage), not automatic fixes.
|
||||
This MVP focuses on reporting and triage, not automatic remediation.
|
||||
|
||||
## User Scenarios
|
||||
## User Scenarios & Testing
|
||||
|
||||
### Scenario 1: View drift summary
|
||||
|
||||
- Given the system has at least two successful inventory snapshots for the same selection/scope
|
||||
- When an admin opens Drift
|
||||
- Then they see a summary of what was added, removed, or changed since the previous snapshot
|
||||
- Given inventory sync has run at least twice
|
||||
- When the admin opens Drift
|
||||
- Then they see a summary of changes since the last baseline
|
||||
|
||||
### Scenario 2: Drill into a drift finding
|
||||
|
||||
- Given drift findings exist for a comparison
|
||||
- When an admin opens a specific finding
|
||||
- Then they can see what changed and which two snapshots were compared
|
||||
|
||||
### Scenario 3: Acknowledge / triage
|
||||
|
||||
- Given a drift finding exists
|
||||
- When an admin acknowledges it
|
||||
- Then it no longer appears in “new” views, but remains available for audit/history
|
||||
- When the admin opens the finding
|
||||
- Then they see what changed, when, and which run observed it
|
||||
|
||||
## Rules
|
||||
### Scenario 3: Acknowledge/triage
|
||||
- Given a drift finding exists
|
||||
- When the admin marks it acknowledged
|
||||
- Then it is hidden from “new” lists but remains auditable
|
||||
|
||||
### Coverage (MVP)
|
||||
## Functional Requirements
|
||||
|
||||
- Drift findings cover **policies, their assignments, and scope tags** for the selected scope.
|
||||
- FR1: Define a baseline concept (e.g., last completed run for a selection scope).
|
||||
- FR2: Produce drift findings for adds/removals/metadata changes based on inventory/run state.
|
||||
- FR3: Provide drift UI with summary and details.
|
||||
- FR4: Allow acknowledgement/triage states.
|
||||
|
||||
### Baseline and comparison selection
|
||||
## Non-Functional Requirements
|
||||
|
||||
- Drift always compares two successful inventory snapshots for the same selection/scope.
|
||||
- The “current” snapshot is the latest successful snapshot for that scope.
|
||||
- The “baseline” snapshot is the previous successful snapshot for that scope.
|
||||
- NFR1: Drift generation must be deterministic for the same baseline and scope.
|
||||
- NFR2: Drift must remain tenant-scoped and safe to display.
|
||||
|
||||
### Change types
|
||||
## Success Criteria
|
||||
|
||||
Each drift finding must be categorized as one of:
|
||||
- SC1: Admins can identify drift across supported types in under 3 minutes.
|
||||
- SC2: Drift results are consistent across repeated generation for the same baseline.
|
||||
|
||||
- **added**: the item exists in current but not in baseline
|
||||
- **removed**: the item exists in baseline but not in current
|
||||
- **modified**: the item exists in both but differs (including assignment target and/or intent changes)
|
||||
## Out of Scope
|
||||
|
||||
### Acknowledgement
|
||||
- Automatic revert/promotion.
|
||||
|
||||
- Acknowledgement is **per comparison** (baseline + current within a scope).
|
||||
- Acknowledgement does **not** carry forward to later comparisons.
|
||||
## Related Specs
|
||||
|
||||
### UI states
|
||||
|
||||
- **blocked**: If fewer than two successful snapshots exist for the same scope, Drift shows a clear blocked state and does not attempt generation.
|
||||
- **error**: If drift generation fails for a comparison, Drift shows a clear error state with safe information and reference identifiers to the recorded run.
|
||||
|
||||
### Default views
|
||||
|
||||
- Default Drift summary and default finding lists show **new** findings only.
|
||||
- Acknowledged findings are accessible via an explicit filter.
|
||||
|
||||
### Run tracking (status, errors, idempotency)
|
||||
|
||||
- Drift generation status and errors must be recorded in a **persisted run record** so that progress/failure survives refresh and can be inspected later.
|
||||
- Re-opening Drift for the same comparison must be idempotent (it should not create duplicate work for the same comparison).
|
||||
|
||||
### Determinism and stable identity
|
||||
|
||||
- For the same scope + baseline + current, Drift must produce the same set of findings.
|
||||
- Each finding must have a stable identifier (“fingerprint”) so triage actions can reliably reference the same drift item within a comparison.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- With two successful snapshots for the same scope, Drift shows a summary of **added/removed/modified** items for that comparison.
|
||||
- With fewer than two successful snapshots for the same scope, Drift shows **blocked** and does not start generation.
|
||||
- If generation fails, Drift shows **error** and provides reference identifiers to the persisted run record.
|
||||
- Default views exclude acknowledged findings, and acknowledged findings remain available via filter.
|
||||
- Acknowledging a finding records who/when acknowledged and hides it from “new” views.
|
||||
- Re-running generation for the same comparison does not create duplicate work and produces consistent results.
|
||||
- Program: `specs/039-inventory-program/spec.md`
|
||||
- Core: `specs/040-inventory-core/spec.md`
|
||||
- Compare: `specs/043-cross-tenant-compare-and-promotion/spec.md`
|
||||
|
||||
@ -1,185 +1,7 @@
|
||||
---
|
||||
# Tasks: Drift MVP
|
||||
|
||||
description: "Task list for feature 044 drift MVP"
|
||||
|
||||
---
|
||||
|
||||
# Tasks: Drift MVP (044)
|
||||
|
||||
**Input**: Design documents from `specs/044-drift-mvp/`
|
||||
**Prerequisites**: `plan.md` (required), `spec.md` (required), plus `research.md`, `data-model.md`, `contracts/`, `quickstart.md`
|
||||
|
||||
**Tests**: REQUIRED (Pest) - feature introduces runtime behavior + new persistence.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (Scenario 1/2/3 in spec).
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
|
||||
- Include exact file paths in descriptions
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Project wiring for Drift MVP.
|
||||
|
||||
- [x] T001 Create/update constitution gate checklist in `specs/044-drift-mvp/checklists/requirements.md`
|
||||
- [x] T002 Confirm spec/plan artifacts are current in `specs/044-drift-mvp/{plan.md,spec.md,research.md,data-model.md,quickstart.md,contracts/admin-findings.openapi.yaml}`
|
||||
- [x] T003 Add Drift landing page shell in `app/Filament/Pages/DriftLanding.php`
|
||||
- [x] T004 [P] Add Finding resource shells in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Persistence + authorization + deterministic IDs that all stories depend on.
|
||||
|
||||
**Checkpoint**: DB schema exists, tenant scoping enforced, and tests can create Finding rows.
|
||||
|
||||
- [x] T005 Create `findings` migration in `database/migrations/*_create_findings_table.php` with Finding fields aligned to `specs/044-drift-mvp/spec.md`:
|
||||
(tenant_id, finding_type, scope_key, baseline_run_id, current_run_id, subject_type, subject_external_id, severity, status, fingerprint unique, evidence_jsonb, acknowledged_at, acknowledged_by_user_id)
|
||||
- [x] T006 Create `Finding` model in `app/Models/Finding.php` (casts for `evidence_jsonb`, enums/constants for `finding_type`/`severity`/`status`, `acknowledged_at` handling)
|
||||
- [x] T007 [P] Add `FindingFactory` in `database/factories/FindingFactory.php`
|
||||
- [x] T008 Ensure `InventorySyncRunFactory` exists (or add it) in `database/factories/InventorySyncRunFactory.php`
|
||||
- [x] T009 Add authorization policy in `app/Policies/FindingPolicy.php` and wire it in `app/Providers/AuthServiceProvider.php` (or project equivalent)
|
||||
- [x] T010 Add Drift permissions in `config/intune_permissions.php` (view + acknowledge) and wire them into Filament navigation/actions
|
||||
- [x] T011 Enforce tenant scoping for Finding queries in `app/Models/Finding.php` and/or `app/Filament/Resources/FindingResource.php`
|
||||
- [x] T012 Implement fingerprint helper in `app/Services/Drift/DriftHasher.php` (sha256 per spec)
|
||||
- [x] T013 Pin `scope_key = InventorySyncRun.selection_hash` in `app/Services/Drift/DriftScopeKey.php` (single canonical definition)
|
||||
- [x] T014 Implement evidence allowlist builder in `app/Services/Drift/DriftEvidence.php` (new file)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - View drift summary (Priority: P1) MVP
|
||||
|
||||
**Goal**: Opening Drift generates (async) and displays a summary of new drift findings for the latest scope.
|
||||
|
||||
**Independent Test**: With 2 successful inventory runs for the same selection hash, opening Drift dispatches generation if missing and then shows summary counts.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T015 [P] [US1] Baseline selection tests in `tests/Feature/Drift/DriftBaselineSelectionTest.php`
|
||||
- [x] T016 [P] [US1] Generation dispatch tests in `tests/Feature/Drift/DriftGenerationDispatchTest.php`
|
||||
- [x] T017 [P] [US1] Tenant isolation tests in `tests/Feature/Drift/DriftTenantIsolationTest.php`
|
||||
- [x] T018 [P] [US1] Assignment drift detection test (targets + intent changes per FR2b) in `tests/Feature/Drift/DriftAssignmentDriftDetectionTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T019 [US1] Implement run selection service in `app/Services/Drift/DriftRunSelector.php`
|
||||
- [x] T021 [US1] Implement generator service in `app/Services/Drift/DriftFindingGenerator.php` (idempotent)
|
||||
- [x] T020 [US1] Implement generator job in `app/Jobs/GenerateDriftFindingsJob.php` (dedupe/lock by tenant+scope+baseline+current)
|
||||
- [x] T022 [US1] Implement landing behavior (dispatch + status UI) in `app/Filament/Pages/DriftLanding.php`
|
||||
- [x] T023 [US1] Implement summary queries/widgets in `app/Filament/Pages/DriftLanding.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Drill into a drift finding (Priority: P2)
|
||||
|
||||
**Goal**: Admin can view a finding and see sanitized evidence + run references (DB-only label resolution).
|
||||
|
||||
**Independent Test**: A persisted finding renders details without Graph calls.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T024 [P] [US2] Finding detail test in `tests/Feature/Drift/DriftFindingDetailTest.php`
|
||||
- [x] T025 [P] [US2] Evidence minimization test in `tests/Feature/Drift/DriftEvidenceMinimizationTest.php`
|
||||
- [x] T041 [P] [US2] Finding detail shows normalized settings diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsSettingsDiffTest.php`
|
||||
- [x] T042 [P] [US2] Finding detail shows assignments diff + cached group labels (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T026 [US2] Implement list UI in `app/Filament/Resources/FindingResource.php` (filters: status, scope_key, run)
|
||||
- [x] T027 [US2] Implement detail UI in `app/Filament/Resources/FindingResource/Pages/ViewFinding.php`
|
||||
- [x] T028 [US2] Implement DB-only name resolution in `app/Filament/Resources/FindingResource.php` (inventory/foundations caches)
|
||||
- [x] T043 [US2] Add real diffs to Finding detail (settings + assignments) in `app/Filament/Resources/FindingResource.php`, `app/Services/Drift/*`, and `resources/views/filament/infolists/entries/assignments-diff.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Acknowledge/triage (Priority: P3)
|
||||
|
||||
**Goal**: Admin can acknowledge findings; new lists hide acknowledged but records remain auditable.
|
||||
|
||||
**Independent Test**: Acknowledging sets `acknowledged_at` + `acknowledged_by_user_id` and flips status.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T029 [P] [US3] Acknowledge action test in `tests/Feature/Drift/DriftAcknowledgeTest.php`
|
||||
- [x] T030 [P] [US3] Acknowledge authorization test in `tests/Feature/Drift/DriftAcknowledgeAuthorizationTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T031 [US3] Add acknowledge actions in `app/Filament/Resources/FindingResource.php`
|
||||
- [x] T032 [US3] Implement `acknowledge()` domain method in `app/Models/Finding.php`
|
||||
- [x] T033 [US3] Ensure Drift summary excludes acknowledged by default in `app/Filament/Pages/DriftLanding.php`
|
||||
|
||||
### Bulk triage (post-MVP UX)
|
||||
|
||||
**Goal**: Admin can acknowledge many findings safely and quickly.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T048 [P] [US3] Bulk acknowledge selected test in `tests/Feature/Drift/DriftBulkAcknowledgeTest.php`
|
||||
- [x] T049 [P] [US3] Acknowledge all matching current filters test in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingTest.php`
|
||||
- [x] T050 [P] [US3] Acknowledge all matching requires type-to-confirm when >100 in `tests/Feature/Drift/DriftBulkAcknowledgeAllMatchingConfirmationTest.php`
|
||||
- [x] T051 [P] [US3] Bulk acknowledge authorization test in `tests/Feature/Drift/DriftBulkAcknowledgeAuthorizationTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T052 [US3] Add bulk triage actions to Findings list in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/FindingResource/Pages/ListFindings.php`
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [x] T034 Add DB indexes in `database/migrations/*_create_findings_table.php` (tenant_id+status, tenant_id+scope_key, tenant_id+baseline_run_id, tenant_id+current_run_id)
|
||||
- [x] T035 [P] Add determinism test in `tests/Feature/Drift/DriftGenerationDeterminismTest.php` (same baseline/current => same fingerprints)
|
||||
- [x] T036 Add job observability logs in `app/Jobs/GenerateDriftFindingsJob.php` (no secrets)
|
||||
- [x] T037 Add idempotency/upsert strategy in `app/Services/Drift/DriftFindingGenerator.php`
|
||||
- [x] T038 Ensure volatile fields excluded from hashing in `app/Services/Drift/DriftHasher.php` and cover in `tests/Feature/Drift/DriftHasherTest.php`
|
||||
- [x] T039 Validate and update `specs/044-drift-mvp/quickstart.md` after implementation
|
||||
- [x] T040 Fix drift evidence summary shape in `app/Services/Drift/DriftFindingGenerator.php` and cover in `tests/Feature/Drift/DriftPolicySnapshotDriftDetectionTest.php` (snapshot_hash vs assignments_hash)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Scope Tags Drift (Post-MVP Fix)
|
||||
|
||||
**Goal**: Detect and display drift caused by `roleScopeTagIds` changes on a policy version.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [x] T044 [P] [US1] Scope-tag drift detection test in `tests/Feature/Drift/DriftScopeTagDriftDetectionTest.php`
|
||||
- [x] T045 [P] [US2] Finding detail shows scope-tags diff (DB-only) in `tests/Feature/Drift/DriftFindingDetailShowsScopeTagsDiffTest.php`
|
||||
|
||||
### Implementation
|
||||
|
||||
- [x] T046 [US1] Implement scope-tag drift detection in `app/Services/Drift/DriftFindingGenerator.php` (deterministic hashing; kind=`policy_scope_tags`)
|
||||
- [x] T047 [US2] Implement scope-tags diff builder + UI in `app/Services/Drift/DriftFindingDiffBuilder.php`, `app/Filament/Resources/FindingResource.php`, and `resources/views/filament/infolists/entries/scope-tags-diff.blade.php`
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
- Setup (Phase 1) -> Foundational (Phase 2) -> US1 -> US2 -> US3 -> Polish
|
||||
|
||||
### Parallel execution examples
|
||||
|
||||
- Foundational: T007, T008, T012, T013, T014
|
||||
- US1 tests: T015, T016, T017, T018
|
||||
- US2 tests: T024, T025
|
||||
- US3 tests: T029, T030
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP scope
|
||||
|
||||
- MVP = Phase 1 + Phase 2 + US1.
|
||||
|
||||
### Format validation
|
||||
|
||||
- All tasks use `- [ ] T###` format
|
||||
- Story tasks include `[US1]`/`[US2]`/`[US3]`
|
||||
- All tasks include file paths
|
||||
- [ ] T001 Define baseline and scope rules
|
||||
- [ ] T002 Drift finding generation (deterministic)
|
||||
- [ ] T003 Drift summary + detail UI
|
||||
- [ ] T004 Acknowledge/triage state
|
||||
- [ ] T005 Tests for determinism and tenant scoping
|
||||
|
||||
@ -1,79 +0,0 @@
|
||||
# PR Gate Checklist: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
**Purpose**: PR review gate to validate that the *requirements* in `spec.md` are complete, clear, consistent, measurable, and cover key scenarios for Groups v1.
|
||||
**Created**: 2026-01-11
|
||||
**Feature**: specs/051-entra-group-directory-cache/spec.md
|
||||
|
||||
**Note**: This checklist is generated by the `/speckit.checklist` command based on feature context and requirements.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] CHK001 Are manual and scheduled start modes explicitly specified as in-scope (and not deferred)? [OK, Evidence: Spec §FR-004b, Spec §Pinned Decisions (v1 defaults)]
|
||||
- [x] CHK002 Are the cached data fields (metadata-only) explicitly enumerated (e.g., displayName, groupTypes, securityEnabled/mailEnabled) rather than described generically? [OK, Evidence: Spec §FR-001a]
|
||||
- [x] CHK003 Is the full-tenant scope of enumeration explicitly stated as the v1 source of truth (and not “referenced-only”)? [OK, Evidence: Spec §Clarifications, Spec §FR-004a]
|
||||
- [x] CHK004 Are retention and purge requirements fully specified, including the retention start point and purge trigger? [OK, Evidence: Spec §FR-005a]
|
||||
- [x] CHK005 Are audit trail requirements defined beyond “auditable” (what is recorded, where it is visible, minimum fields)? [OK, Evidence: Spec §FR-009a]
|
||||
- [x] CHK006 Are authorization requirements defined for who may (a) browse groups, (b) trigger sync, (c) view run details? [OK, Evidence: Spec §Authorization & Access (Groups v1)]
|
||||
|
||||
## Requirement Clarity
|
||||
|
||||
- [x] CHK007 Is “periodic schedule” quantified with an explicit default cadence and configuration surface (per-tenant vs global)? [OK, Evidence: Spec §FR-004b (configurable per environment), Spec §Pinned Decisions (v1 defaults)]
|
||||
- [x] CHK008 Is “deterministic selection identifier” defined with an unambiguous string/format and stability rules (per tenant + scope versioning)? [OK, Evidence: Spec §FR-004]
|
||||
- [x] CHK009 Is “deduplicated” behavior specified (e.g., reject/skip when an active run exists; idempotency window), rather than implied? [OK, Evidence: Spec §User Story 1 Acceptance #6, Plan §Idempotency & Concurrency]
|
||||
- [x] CHK010 Is “stale” defined with a specific comparison rule (e.g., $lastSeenAt < now - N days) and timezone/clock assumptions? [OK, Evidence: Spec §Clarifications (UTC everywhere), Spec §FR-005]
|
||||
- [x] CHK011 Is the “unresolved fallback” label format and truncation rule specified consistently (e.g., last 8 chars, ellipsis placement)? [OK, Evidence: Spec §User Story 3 Acceptance #1–#2]
|
||||
- [x] CHK012 Is “no live directory calls during render” defined operationally (what counts as render-time, allowed background refresh vs forbidden UI-triggered calls)? [OK, Evidence: Spec §FR-006a]
|
||||
|
||||
## Requirement Consistency
|
||||
|
||||
- [x] CHK013 Do the Clarifications (app-only auth, metadata-only, full scope) align with the Functional Requirements without contradiction? [OK, Evidence: Spec §Clarifications, Spec §FR-001a, Spec §FR-004a, Spec §FR-011a]
|
||||
- [x] CHK014 Are the acceptance scenarios for US1 aligned with FR-003 (async only) and FR-002 (run record), without missing a required attribute? [OK, Evidence: Spec §User Story 1 Acceptance, Spec §FR-002, Spec §FR-003]
|
||||
- [x] CHK015 Do US2 filters (stale) align with FR-005 and FR-007 (what filters are mandatory vs optional)? [OK, Evidence: Spec §User Story 2 Acceptance, Spec §FR-005, Spec §FR-007]
|
||||
- [x] CHK016 Is the “read-only to Entra” rule consistent across spec (no implied writes or membership reads)? [OK, Evidence: Spec §FR-001, Spec §FR-001a]
|
||||
- [ ] CHK017 Are success criteria thresholds consistent with scope and constraints (e.g., SC-003 95% resolution vs metadata-only cache + tenant scope)? [Consistency, Spec §SC-003, Spec §FR-001a, Spec §FR-004a]
|
||||
|
||||
## Acceptance Criteria Quality
|
||||
|
||||
- [ ] CHK018 Are acceptance scenarios written with objective outcomes (observable run record fields, counts, timestamps), not subjective terms? [Measurability, Spec §User Story 1 Acceptance]
|
||||
- [x] CHK019 Do acceptance scenarios specify what constitutes “partial” completion vs “failed” (and expected operator-visible fields)? [OK, Evidence: Spec §FR-002a, Spec §FR-011]
|
||||
- [x] CHK020 Are the success criteria (SC-001..SC-004) measurable with defined measurement method and scope (which pages, which tenants, sampling window)? [OK, Evidence: Spec §Success Criteria → Measurable Outcomes (SC-001..SC-004)]
|
||||
- [ ] CHK021 Is the staleness default (N=30) defined as a requirement and stated as configurable (including configuration boundary: env/tenant)? [Measurability, Spec §FR-005]
|
||||
|
||||
## Scenario Coverage
|
||||
|
||||
- [x] CHK022 Are the primary flows covered end-to-end: start sync → run record → cache updated → browse/search → resolve label? [OK, Evidence: Spec §User Story 1–3 Acceptance]
|
||||
- [x] CHK023 Are scheduled-run scenarios fully defined beyond cadence (initiator identity, operator visibility, and dedupe rules), rather than only mentioned? [OK, Evidence: Spec §Scheduled Sync Semantics (SS-001..SS-003), Spec §User Story 1 Acceptance #5–#7]
|
||||
- [x] CHK024 Are multi-tenant isolation scenarios explicitly addressed (prevent cross-tenant reads in browse and label resolution)? [OK, Evidence: Spec §FR-010, Spec §Authorization & Access (Groups v1)]
|
||||
- [x] CHK025 Are “no prior sync” scenarios specified (empty cache UI/labels, guidance to run sync)? [OK, Evidence: Spec §User Story 2 Acceptance #3, Spec §User Story 3 Acceptance #4]
|
||||
|
||||
## Edge Case Coverage
|
||||
|
||||
- [x] CHK026 Are throttling/partial results requirements specified with concrete behavior (retry/backoff, max duration, run status outcome)? [OK, Evidence: Spec §FR-011b, Spec §Edge Cases, Spec §CR-002a, Spec §FR-002a]
|
||||
- [x] CHK027 Are permission-missing scenarios specified with operator-facing guidance (what the UI shows, what operators should do) beyond a stable error categorization? [OK, Evidence: Spec §FR-011c, Spec §Edge Cases]
|
||||
- [x] CHK028 Is the behavior specified when a group disappears and later reappears within the 90-day window (last_seen update vs new record)? [OK, Evidence: Spec §FR-005b, Spec §FR-005a]
|
||||
- [x] CHK029 Is paging/large-tenant behavior specified with bounding rules (max pages, safety stop, counters semantics)? [OK, Evidence: Spec §CR-002a, Spec §FR-002b]
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- [ ] CHK030 Are performance requirements quantified for large tenants (sync duration, DB query latency, UI list responsiveness)? [Gap, Spec §Edge Cases, Spec §Technical Context]
|
||||
- [ ] CHK031 Are logging/observability requirements defined (what to log, redaction rules, correlation IDs) for sync runs? [Gap, Spec §FR-009, Spec §FR-011]
|
||||
- [x] CHK032 Are data minimization and privacy constraints explicitly stated for cached group metadata (e.g., no PII beyond displayName)? [OK, Evidence: Spec §FR-001a, Spec §Pinned Decisions (v1 defaults) Scope boundary]
|
||||
- [ ] CHK033 Are accessibility requirements specified for the “Directory → Groups” UI (keyboard, table filtering, screen reader labels) if UI is in scope? [Gap, Spec §US2]
|
||||
|
||||
## Dependencies & Assumptions
|
||||
|
||||
- [x] CHK034 Are external dependencies explicitly pinned: required Graph permissions (e.g., Group.Read.All), endpoint family, and version (v1.0/beta)? [OK, Evidence: Spec §Pinned Decisions (v1 defaults), Spec §FR-011a, Spec §Contract Requirements]
|
||||
- [ ] CHK035 Are Graph contract registry requirements sufficiently specific (contract key/name, allowed selects, expected fields) to be implementable without guesswork? [Completeness, Spec §Contract Requirements]
|
||||
- [ ] CHK036 Are operational prerequisites specified (queue worker, scheduler/cron, retention job timing) including what happens if they’re missing? [Gap, Spec §Assumptions, Spec §US1 Acceptance]
|
||||
|
||||
## Ambiguities & Conflicts
|
||||
|
||||
- [ ] CHK037 Is the scope of “name resolution across the suite” explicitly bounded (which modules/pages are in v1 vs future)? [Completeness, Spec §Out of Scope (Groups v1)]
|
||||
- [ ] CHK038 Is the relationship between cached group data and the existing live Graph-based resolution behavior explicitly defined (migration/transition expectations)? [Gap, Spec §FR-006, Spec §FR-008]
|
||||
- [x] CHK039 Are error categories defined as an explicit controlled vocabulary (permission/throttling/transient/unknown) with mapping criteria? [OK, Evidence: Spec §FR-011]
|
||||
|
||||
## Notes
|
||||
|
||||
- Mark items as complete: `[x]`
|
||||
- Use inline notes to capture identified gaps and propose spec edits
|
||||
- This PR gate validates requirements quality (not implementation)
|
||||
@ -1,56 +0,0 @@
|
||||
# Requirements Checklist (Evidence-Based): Entra Group Directory Cache (Groups v1)
|
||||
|
||||
**Purpose**: Implementation gate for Feature 051. Only mark items `[x]` when there is explicit evidence in spec/plan/tasks (or existing repo conventions). Any remaining `[ ]` items must include a concrete follow-up reference (task ID).
|
||||
**Created**: 2026-01-11
|
||||
**Feature**: specs/051-entra-group-directory-cache/spec.md
|
||||
|
||||
## Evidence Sources
|
||||
|
||||
- Spec: specs/051-entra-group-directory-cache/spec.md
|
||||
- Plan: specs/051-entra-group-directory-cache/plan.md
|
||||
- Tasks: specs/051-entra-group-directory-cache/tasks.md
|
||||
- Contracts: specs/051-entra-group-directory-cache/contracts/
|
||||
- PR gate (requirements quality): specs/051-entra-group-directory-cache/checklists/pr-gate.md
|
||||
|
||||
## Spec Hardened (Prereq for planning)
|
||||
|
||||
- [x] CHK001 Pinned defaults exist (cadence, auth mode, required permission, paging strategy, staleness/retention). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §FR-004b, spec.md §Contract Requirements
|
||||
- [x] CHK002 Scope boundaries are explicit (no membership/owners, no cross-tenant compare, no delegated tokens required for UI). Evidence: spec.md §Pinned Decisions (v1 defaults), spec.md §Out of Scope (Groups v1), spec.md §FR-001a
|
||||
- [x] CHK003 Acceptance scenarios include dedupe and “no render-time Graph calls” guard requirement. Evidence: spec.md §User Story 1 Acceptance #6, spec.md §FR-006 + §FR-006a, spec.md §User Story 3 Acceptance #3
|
||||
|
||||
## Planning Readiness
|
||||
|
||||
- [x] CHK004 Plan removes placeholder “ACTION REQUIRED” sections and contains concrete file paths and sequencing notes. Evidence: plan.md §Project Structure, plan.md §Definition of Done (per phase)
|
||||
- [x] CHK005 Plan specifies run lifecycle fields + status semantics (pending/running/succeeded/failed/partial) and how counters are computed. Evidence: plan.md §Execution Model → Sync Run Lifecycle
|
||||
- [x] CHK006 Plan specifies idempotency rule (one active run per tenant+selection) and dedupe window behavior. Evidence: plan.md §Execution Model → Idempotency & Concurrency
|
||||
|
||||
## Contracts & Permissions
|
||||
|
||||
- [x] CHK007 OpenAPI admin surfaces exist for list/detail/sync/runs. Evidence: contracts/admin-directory-groups.openapi.yaml
|
||||
- [x] CHK008 Graph contract registry entry for groups exists (endpoint + allowed selects). Evidence: config/graph_contracts.php (directoryGroups)
|
||||
- [x] CHK009 Tenant permission catalog mentions directory-groups feature tagging for Group.Read.All. Evidence: config/intune_permissions.php (Group.Read.All features)
|
||||
|
||||
## Data Model & Retention
|
||||
|
||||
- [x] CHK010 Data model defines EntraGroup + EntraGroupSyncRun, key fields, indexes, and retention rules. Evidence: data-model.md
|
||||
- [x] CHK011 Migrations exist for groups + runs with tenant scoping and unique constraints. Evidence: database/migrations/2026_01_11_120003_create_entra_groups_table.php + database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php
|
||||
|
||||
## Implementation Tasks Defined
|
||||
|
||||
- [x] CHK012 Task breakdown exists and is grouped by user story with dependencies. Evidence: tasks.md
|
||||
- [x] CHK013 Feature config exists for staleness/retention/schedule/page_size. Evidence: config/directory_groups.php
|
||||
|
||||
## Test Gate (Pest)
|
||||
|
||||
- [x] CHK014 Tests are explicitly required and enumerated per story (including no-Graph-on-render test). Evidence: tasks.md T010–T013, tasks.md T022, tasks.md T026–T027
|
||||
- [x] CHK015 Guard test is implemented to fail hard on Graph client invocation during render. Evidence: tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php
|
||||
|
||||
## Operational Readiness
|
||||
|
||||
- [x] CHK016 Operator workflow documented (manual + scheduled + verification bullets). Evidence: quickstart.md
|
||||
- [x] CHK017 Scheduled dispatcher command exists and is wired in routes/console.php. Evidence: app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php + routes/console.php
|
||||
|
||||
## Notes
|
||||
|
||||
- This checklist is the implementation gate. Use specs/051-entra-group-directory-cache/checklists/pr-gate.md during PR review to validate *requirements quality*.
|
||||
- For any unchecked item, keep the follow-up task reference current.
|
||||
@ -1,135 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Admin - Directory Groups (v1)
|
||||
version: 0.1.0
|
||||
description: |
|
||||
Admin surfaces for tenant-scoped Entra groups cache (metadata only).
|
||||
Rendering uses cached data only; sync runs read the directory via app-only auth.
|
||||
|
||||
paths:
|
||||
/admin/tenants/{tenantId}/directory/groups:
|
||||
get:
|
||||
summary: List cached groups
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- in: query
|
||||
name: q
|
||||
schema: { type: string }
|
||||
description: Search by display name or ID
|
||||
- in: query
|
||||
name: stale
|
||||
schema: { type: boolean }
|
||||
description: Filter to stale groups only
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EntraGroup'
|
||||
|
||||
/admin/tenants/{tenantId}/directory/groups/{entraGroupId}:
|
||||
get:
|
||||
summary: Get group details
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- in: path
|
||||
name: entraGroupId
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EntraGroup'
|
||||
|
||||
/admin/tenants/{tenantId}/directory/groups/sync:
|
||||
post:
|
||||
summary: Start a manual groups sync
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'202':
|
||||
description: Accepted (run created)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EntraGroupSyncRun'
|
||||
|
||||
/admin/tenants/{tenantId}/directory/groups/runs:
|
||||
get:
|
||||
summary: List group sync runs
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EntraGroupSyncRun'
|
||||
|
||||
/admin/tenants/{tenantId}/directory/groups/runs/{runId}:
|
||||
get:
|
||||
summary: Get group sync run details
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/TenantId'
|
||||
- in: path
|
||||
name: runId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/EntraGroupSyncRun'
|
||||
|
||||
components:
|
||||
parameters:
|
||||
TenantId:
|
||||
in: path
|
||||
name: tenantId
|
||||
required: true
|
||||
schema: { type: integer }
|
||||
|
||||
schemas:
|
||||
EntraGroup:
|
||||
type: object
|
||||
required: [tenant_id, entra_group_id, display_name, last_seen_at]
|
||||
properties:
|
||||
tenant_id: { type: integer }
|
||||
entra_group_id: { type: string }
|
||||
display_name: { type: string }
|
||||
group_type: { type: string }
|
||||
security_enabled: { type: boolean, nullable: true }
|
||||
mail_enabled: { type: boolean, nullable: true }
|
||||
group_types:
|
||||
type: array
|
||||
items: { type: string }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
|
||||
EntraGroupSyncRun:
|
||||
type: object
|
||||
required: [tenant_id, selection_key, status, created_at]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
tenant_id: { type: integer }
|
||||
initiated_by_user_id: { type: integer, nullable: true }
|
||||
selection_key: { type: string }
|
||||
status: { type: string }
|
||||
started_at: { type: string, format: date-time, nullable: true }
|
||||
finished_at: { type: string, format: date-time, nullable: true }
|
||||
observed_count: { type: integer, nullable: true }
|
||||
upserted_count: { type: integer, nullable: true }
|
||||
error_count: { type: integer, nullable: true }
|
||||
error_category: { type: string, nullable: true }
|
||||
error_summary: { type: string, nullable: true }
|
||||
created_at: { type: string, format: date-time }
|
||||
@ -1,56 +0,0 @@
|
||||
# Data Model: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
## Entities
|
||||
|
||||
### EntraGroup
|
||||
Represents a cached directory group for a single tenant (metadata only).
|
||||
|
||||
**Fields (conceptual)**
|
||||
- `id` (internal)
|
||||
- `tenant_id` (FK)
|
||||
- `entra_group_id` (GUID/string; external stable identifier)
|
||||
- `display_name` (string)
|
||||
- `group_type` (enum/string, derived; e.g., `Security`, `Microsoft365`, `Unknown`)
|
||||
- `security_enabled` (bool, nullable)
|
||||
- `mail_enabled` (bool, nullable)
|
||||
- `group_types` (array/JSONB, nullable)
|
||||
- `last_seen_at` (timestamp)
|
||||
- `last_seen_run_id` (FK to sync run, optional)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Constraints & indexes**
|
||||
- Unique: (`tenant_id`, `entra_group_id`)
|
||||
- Index: (`tenant_id`, `display_name`) for search
|
||||
- Index: (`tenant_id`, `last_seen_at`) for stale/retention filtering
|
||||
|
||||
### EntraGroupSyncRun
|
||||
Append-only record for one sync attempt.
|
||||
|
||||
**Fields (conceptual)**
|
||||
- `id`
|
||||
- `tenant_id` (FK)
|
||||
- `initiated_by_user_id` (FK nullable for scheduled)
|
||||
- `selection_key` (string; deterministic for Groups v1 per tenant)
|
||||
- `status` (enum: `pending`, `running`, `succeeded`, `failed`)
|
||||
- `started_at`, `finished_at`
|
||||
- counters: `observed_count`, `upserted_count`, `error_count`
|
||||
- `error_category` (string/enum; e.g., `permission`, `throttling`, `transient`, `unknown`)
|
||||
- `error_summary` (safe string)
|
||||
- `created_at`, `updated_at`
|
||||
|
||||
**Constraints & indexes**
|
||||
- Index: (`tenant_id`, `created_at`)
|
||||
- Index: (`tenant_id`, `status`)
|
||||
- Optional unique/idempotency: (`tenant_id`, `selection_key`, `status in active`) in code using locks.
|
||||
|
||||
## Relationships
|
||||
- `Tenant` 1 → N `EntraGroup`
|
||||
- `Tenant` 1 → N `EntraGroupSyncRun`
|
||||
- `EntraGroupSyncRun` 1 → N `EntraGroup` (via `last_seen_run_id`, optional)
|
||||
|
||||
## State transitions
|
||||
- `pending` → `running` → `succeeded` | `failed`
|
||||
|
||||
## Retention
|
||||
- Purge rule: delete `EntraGroup` where `last_seen_at` < now - 90 days.
|
||||
- Stale classification: stale if `last_seen_at` < now - N days (default N = 30).
|
||||
@ -1,141 +0,0 @@
|
||||
# Implementation Plan: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
**Branch**: `051-entra-group-directory-cache` | **Date**: 2026-01-11 | **Spec**: [specs/051-entra-group-directory-cache/spec.md](spec.md)
|
||||
**Input**: Feature specification from `/specs/051-entra-group-directory-cache/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||
|
||||
## Summary
|
||||
|
||||
Provide a tenant-scoped Entra ID Groups metadata cache (no membership/owners) populated by queued sync runs (manual + scheduled), so the admin UI and other modules can resolve group IDs to friendly names without making live directory calls during page render.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4 (Laravel 12)
|
||||
**Primary Dependencies**: Filament v4, Livewire v3, Microsoft Graph integration via internal `GraphClientInterface`
|
||||
**Storage**: PostgreSQL
|
||||
**Testing**: Pest v4
|
||||
**Target Platform**: Web application (Sail-first locally, Dokploy-first deploy)
|
||||
**Project Type**: web
|
||||
**Performance Goals**: background sync handles large tenants via paging; UI list/search remains responsive with DB indexes
|
||||
**Constraints**: no live directory calls during page render; app-only Graph auth; safe error summaries; strict tenant isolation; idempotent runs; retention purge after 90 days last-seen
|
||||
**Scale/Scope**: full-tenant group enumeration; plan for up to ~100k groups/tenant for v1
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Inventory-first: cache is “last observed” directory state; no snapshot payloads.
|
||||
- Read/write separation: read-only to the directory; only internal DB writes.
|
||||
- Graph contract path: directory group list endpoint/fields/permissions must be added to `config/graph_contracts.php` and accessed via `GraphClientInterface`.
|
||||
- Deterministic capabilities: selection identifier for groups sync is deterministic (stable per tenant + groups-v1).
|
||||
- Tenant isolation: all cached groups + run records tenant-scoped; no cross-tenant access paths.
|
||||
- Automation: manual + scheduled sync runs use locks/idempotency, run records, safe error codes; handle 429/503 with backoff+jitter.
|
||||
- Data minimization: store group metadata only (no membership/owners); logs contain no secrets/tokens.
|
||||
|
||||
**Gate status (pre-Phase 0)**: PASS (no violations). Re-check after Phase 1 design.
|
||||
|
||||
**Gate status (post-Phase 1)**: PASS (design artifacts present: research.md, data-model.md, contracts/*, quickstart.md).
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/051-entra-group-directory-cache/
|
||||
├── plan.md # This file (/speckit.plan command output)
|
||||
├── research.md # Phase 0 output (/speckit.plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
app/
|
||||
├── Filament/
|
||||
│ ├── Pages/
|
||||
│ └── Resources/
|
||||
├── Jobs/
|
||||
├── Models/
|
||||
├── Services/
|
||||
│ ├── Graph/
|
||||
│ └── Directory/
|
||||
└── Support/
|
||||
|
||||
config/
|
||||
├── graph.php
|
||||
└── graph_contracts.php
|
||||
|
||||
database/
|
||||
└── migrations/
|
||||
|
||||
routes/
|
||||
└── web.php
|
||||
|
||||
tests/
|
||||
├── Feature/
|
||||
└── Unit/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel web application. Implement directory group sync + cache as tenant-scoped Models, Services, Jobs, Filament pages/resources, migrations, and Pest tests.
|
||||
|
||||
## Key Implementation Decisions (Pinned)
|
||||
|
||||
- **Schedule default**: daily at **02:00 UTC** (environment default) + manual sync always available.
|
||||
- **Auth mode**: app-only (service principal). UI must not require delegated tokens.
|
||||
- **Permission**: `Group.Read.All` (application).
|
||||
- **Graph strategy (v1)**: `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` + paging via `@odata.nextLink`.
|
||||
- **Retention**: stale threshold default 30 days; purge after 90 days since `last_seen_at`.
|
||||
- **Render safety**: fail-hard test ensuring no Graph client calls during render.
|
||||
|
||||
## Execution Model
|
||||
|
||||
### Sync Run Lifecycle
|
||||
|
||||
- Run statuses: `pending` → `running` → `succeeded` | `failed` (optionally `partial` if we intentionally record partial completion).
|
||||
- Each run records: initiator (nullable for scheduled), timestamps, observed/upserted/error counters, and a safe error summary + category.
|
||||
|
||||
### Idempotency & Concurrency
|
||||
|
||||
- Deterministic selection key for v1: `groups-v1:all`.
|
||||
- Deduplication rule: at most one active run per tenant + selection key.
|
||||
- Scheduled dispatcher must be idempotent per tenant and schedule slot (no duplicate run creation).
|
||||
|
||||
### Error Categorization
|
||||
|
||||
- Supported categories: `permission`, `throttling`, `transient`, `unknown`.
|
||||
- Store only safe-to-display summaries (no secrets/tokens).
|
||||
|
||||
## Definition of Done (per phase)
|
||||
|
||||
### Phase 2 (Foundational)
|
||||
|
||||
- Migrations created for EntraGroup and EntraGroupSyncRun.
|
||||
- Models + factories exist and are tenant-scoped.
|
||||
- Graph contract registry has a groups directory read contract.
|
||||
|
||||
### Phase 3 (US1)
|
||||
|
||||
- Manual sync creates a run and dispatches a job.
|
||||
- Job pages through Graph, upserts groups, updates counters, and enforces retention purge.
|
||||
- Filament run list/detail exists; scheduler dispatch is wired.
|
||||
- Pest tests for run creation, upsert behavior, retention purge, and scheduled run dispatch are green.
|
||||
|
||||
### Phase 4 (US2)
|
||||
|
||||
- Directory → Groups list/detail exists with search + stale filter + type filter.
|
||||
- All browsing renders from DB cache only.
|
||||
|
||||
### Phase 5 (US3)
|
||||
|
||||
- Shared label resolver resolves from DB cache and provides consistent fallback formatting.
|
||||
- Key pages render friendly labels without Graph calls.
|
||||
- Fail-hard render guard test is green.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
N/A (no constitution violations anticipated)
|
||||
@ -1,42 +0,0 @@
|
||||
# Quickstart: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
## Goal
|
||||
Populate a tenant-scoped cache of Entra ID groups (metadata only) via background sync runs, enabling safe UI rendering without live directory calls.
|
||||
|
||||
## Prerequisites
|
||||
- Graph integration configured (client credentials / app-only)
|
||||
- Database migrated (feature migrations applied)
|
||||
- Queue worker running for jobs (required for sync)
|
||||
- Scheduler/cron running for periodic sync (optional, if enabled)
|
||||
|
||||
### Local (Sail) quick commands
|
||||
|
||||
- Bring containers up: `./vendor/bin/sail up -d`
|
||||
- Run migrations: `./vendor/bin/sail artisan migrate`
|
||||
- Run the queue worker (separate terminal): `./vendor/bin/sail artisan queue:work`
|
||||
- Run the scheduler loop (separate terminal, optional): `./vendor/bin/sail artisan schedule:work`
|
||||
|
||||
## Operator workflow (manual)
|
||||
1. Switch into a tenant workspace.
|
||||
2. Go to **Directory → Group Sync Runs**.
|
||||
3. Click **Sync Groups** (creates/reuses a run and dispatches a background job).
|
||||
4. Open the run and wait for status to complete.
|
||||
5. Go to **Directory → Groups** and confirm list/search/detail are populated from the cache.
|
||||
|
||||
Notes
|
||||
- Search is cached-only and typically requires at least 2 characters.
|
||||
- If the cache is empty, the Groups list will be empty until a sync completes.
|
||||
|
||||
## Scheduled sync
|
||||
- Ensure the scheduler is running (cron or `schedule:work`).
|
||||
- Verify **Directory → Group Sync Runs** shows runs with empty Initiator (scheduled).
|
||||
- The dispatcher command is `tenantpilot:directory-groups:dispatch` (configured to run every minute).
|
||||
|
||||
## Verification
|
||||
- UI pages that show group IDs render using cached data only.
|
||||
- Unresolved IDs show a clear fallback.
|
||||
- Groups not seen for >90 days are eventually purged.
|
||||
|
||||
Suggested validation checks
|
||||
- `./vendor/bin/sail artisan migrate:status | grep entra_group`
|
||||
- `./vendor/bin/sail artisan schedule:list | grep directory-groups`
|
||||
@ -1,42 +0,0 @@
|
||||
# Research: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Full tenant scope sync (all groups)
|
||||
- Rationale: Ensures browse/search is complete and name-resolution has the best chance of resolving IDs across modules.
|
||||
- Alternatives considered:
|
||||
- Only sync referenced groups (creates gaps, hard to debug, non-deterministic coverage).
|
||||
|
||||
### Decision: Start modes = manual + scheduled
|
||||
- Rationale: Manual sync supports operator workflows (pre-restore/triage), while scheduled sync keeps cache fresh without relying on user action.
|
||||
- Alternatives considered:
|
||||
- Manual only (cache freshness depends on operator discipline).
|
||||
- Scheduled only (harder to react quickly during ops).
|
||||
|
||||
### Decision: App-only (service principal) directory reads
|
||||
- Rationale: Scheduled runs must not depend on an interactive user session; app-only is simpler to audit/lock down.
|
||||
- Alternatives considered:
|
||||
- Delegated tokens (breaks scheduled runs, unpredictable auth state).
|
||||
|
||||
### Decision: Cache metadata only (no membership/owners)
|
||||
- Rationale: Solves the UX problem (name resolution + browse) with minimal data/PII exposure and lower API volume.
|
||||
- Alternatives considered:
|
||||
- Cache membership/owners (larger data surface, higher sensitivity, more Graph calls).
|
||||
|
||||
### Decision: Missing groups retention and purge
|
||||
- Decision: Retain groups for 90 days after `last_seen_at`, then purge.
|
||||
- Rationale: Preserves investigatory value and audit context while bounding DB growth.
|
||||
- Alternatives considered:
|
||||
- Immediate delete (breaks historical triage; creates sudden “unresolved” labels).
|
||||
- Retain forever (unbounded growth).
|
||||
|
||||
### Decision: Graph access patterns (paging + retry)
|
||||
- Decision: Use Graph list endpoint with paging; apply retry/backoff for 429/503; persist stable error categories in run record.
|
||||
- Rationale: Full-tenant enumeration can be large; throttling is expected.
|
||||
- Alternatives considered:
|
||||
- Best-effort without retry (unreliable coverage).
|
||||
- UI-time lookups (explicitly disallowed by spec).
|
||||
|
||||
## Open Questions (resolved for planning)
|
||||
|
||||
- Which specific Graph endpoint and permission names to use will be documented in the contract registry during implementation.
|
||||
@ -1,236 +0,0 @@
|
||||
# Feature Specification: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
**Feature Branch**: `051-entra-group-directory-cache`
|
||||
**Created**: 2026-01-11
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Tenant-scoped Entra ID Groups cache (read-only), populated by queued sync runs, used for name-resolution across the suite; UI must render from DB-only (no live directory calls)."
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-01-11
|
||||
|
||||
- Q: What is the scope of the Groups v1 sync source? → A: All groups in the tenant.
|
||||
- Q: How is the Groups sync started (MVP)? → A: Manual + scheduled/periodic.
|
||||
- Q: What happens in the cache when a group is not returned on the next full sync? → A: Retain for 90 days after last seen, then purge.
|
||||
- Q: In which auth mode does TenantAtlas read groups for sync runs? → A: App-only / service principal.
|
||||
- Q: What data is cached in Groups v1? → A: Group metadata only (no membership/owners).
|
||||
- Q: What timezone/clock semantics apply for staleness/retention comparisons? → A: UTC everywhere.
|
||||
- Q: How are sync run statuses defined (partial vs failed)? → A: Partial only if some pages processed and at least one upsert occurred; otherwise failed.
|
||||
- Q: What paging “safety stop” bounds apply to full-tenant group enumeration (v1)? → A: Max 200 pages, max 10 minutes, abort on retry exhaustion; record safety-stop reason + counters.
|
||||
|
||||
## Pinned Decisions (v1 defaults)
|
||||
|
||||
These defaults are intentionally “hard” for Groups v1 to avoid interpretability during planning/implementation.
|
||||
|
||||
- **Schedule cadence default**: Scheduled sync runs daily at **02:00 UTC** (environment default). Manual sync is always available.
|
||||
- **Auth mode (required)**: App-only (service principal). UI MUST NOT require delegated tokens.
|
||||
- **Required Graph permission (application)**: `Group.Read.All`.
|
||||
- **Graph API family**: Must work with Microsoft Graph **v1.0** semantics (no beta-only features required).
|
||||
- **Paging strategy (v1)**: Full listing with `@odata.nextLink` paging. Delta sync is explicitly deferred to a future version.
|
||||
- **Staleness default**: A group is “stale” if `last_seen_at < now() - 30 days` (computed in **UTC**, configurable per environment).
|
||||
- **Retention/purge default**: Retain unseen groups for **90 days after last_seen_at** (computed in **UTC**), then purge.
|
||||
- **Scope boundary**: No group membership/owners caching; no cross-tenant compare/promotion work inside this feature.
|
||||
|
||||
## Authorization & Access (Groups v1)
|
||||
|
||||
TenantPilot roles are tenant-scoped. Unless stated otherwise, all access below is limited to the active tenant context.
|
||||
|
||||
- **Browse cached groups (Directory → Groups)**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`.
|
||||
- **View group sync runs**: allowed for roles `Owner`, `Manager`, `Operator`, `Readonly`.
|
||||
- **Start manual sync**: allowed for roles `Owner`, `Manager`, `Operator`.
|
||||
- **Cross-tenant access**: forbidden (no browsing or resolution of another tenant’s cached groups).
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1)
|
||||
|
||||
As a tenant admin, I can trigger a background sync that stores the latest observed Entra groups into a tenant-scoped cache, so the suite can display stable, human-friendly group names without relying on live directory lookups.
|
||||
|
||||
**Why this priority**: Without a cache, most assignment-heavy workflows become hard to use and hard to troubleshoot (only group IDs).
|
||||
|
||||
**Independent Test**: Trigger a sync for a tenant and verify that a run record exists and that group rows become available for that tenant.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** I am in a tenant workspace, **When** I start a “Sync Groups” operation, **Then** a run is created and the sync executes asynchronously (request returns without waiting).
|
||||
2. **Given** I start a groups sync for a tenant, **When** the sync completes successfully, **Then** the cache reflects the full set of groups in that tenant as of that run.
|
||||
3. **Given** a sync run completes successfully, **When** I browse groups for that tenant, **Then** I see group entries with display names and stable identifiers.
|
||||
4. **Given** a sync run completes partially or fails, **When** I view the run record, **Then** I can see status and a safe error summary that helps triage.
|
||||
5. **Given** scheduled sync is enabled, **When** the schedule triggers, **Then** a run is created and executed without manual intervention and is visible to operators.
|
||||
6. **Given** a groups sync is already running for the same tenant and selection, **When** I start another sync, **Then** the request is deduplicated (no second concurrent run is created) and I can identify the already-active run.
|
||||
7. **Given** a scheduled sync run is created, **When** I view the run record, **Then** it is clearly identified as “scheduled/system-initiated” (no interactive user session required).
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Browse groups (Priority: P2)
|
||||
|
||||
As a tenant admin, I can browse, search, and filter cached groups so I can quickly resolve group IDs to names and validate whether expected groups exist in the tenant.
|
||||
|
||||
**Why this priority**: Operators need a direct “source of truth (as last seen)” surface to debug restore mapping, dependencies, and drift findings.
|
||||
|
||||
**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** groups have been synced, **When** I open “Directory → Groups”, **Then** I can search by display name and open a group detail view.
|
||||
2. **Given** some groups were not observed recently, **When** I filter for “stale” groups, **Then** I see only groups whose last-seen timestamp is older than the staleness threshold.
|
||||
3. **Given** no groups have been synced yet, **When** I open “Directory → Groups”, **Then** I see a clear empty-state that explains the cache is empty and offers a “Sync Groups” action.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Name resolution across the suite (Priority: P3)
|
||||
|
||||
As an operator, when other pages reference group IDs (dependencies, restore mapping, drift, compare), the UI shows a friendly label if the group exists in the cache; otherwise it shows a clear “unresolved” fallback.
|
||||
|
||||
**Why this priority**: This turns the cache into a foundational building block used across modules while keeping rendering safe and predictable.
|
||||
|
||||
**Independent Test**: Load a page that includes a group GUID reference and verify it renders with a name if present, and with a fallback if not present—without making any live directory calls during rendering.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a page references a group GUID that exists in the cache, **When** I view the page, **Then** I see `Group: <display name> (…last8)` (or equivalent) derived from cached data.
|
||||
2. **Given** a page references a group GUID that does not exist in the cache, **When** I view the page, **Then** I see `Group (unresolved): …last8` (or equivalent) and the page still renders.
|
||||
3. **Given** I view any page that renders group labels, **When** the page renders, **Then** it MUST NOT make any live directory calls (no Graph requests during render-time), and automated tests MUST fail hard if a Graph client is invoked.
|
||||
4. **Given** no groups have been synced yet, **When** a page renders a group GUID reference, **Then** it still renders with the unresolved fallback (and does not attempt live directory lookup during render).
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- **Throttling / transient failures (retry policy)**:
|
||||
- Retryable conditions for Graph reads: HTTP `429`, `503`, and network timeouts.
|
||||
- Non-retryable (fail-fast): HTTP `403` (permission), and other non-2xx responses unless explicitly categorized as retryable.
|
||||
- Backoff strategy: exponential backoff with jitter (full jitter), capped.
|
||||
- Max retries (total per run): 8 (aligned with safety stop `retry_exhausted`).
|
||||
- If retries are exhausted: run MUST abort with `safety_stop_triggered=true`, `safety_stop_reason=retry_exhausted`, and status MUST follow FR-002a / CR-002a (partial if any upserts, else failed).
|
||||
- Operator-visible: run record MUST show `error_category=throttling` (or `transient` for timeouts), and a safe summary including retry count and last HTTP status (if available).
|
||||
|
||||
- **Permission missing (forbidden)**:
|
||||
- If Graph returns HTTP `403` on group list, the run MUST stop immediately (no retries), with `error_category=permission`, and status MUST be `failed`.
|
||||
- Operator-visible guidance MUST be explicit: `Group.Read.All` (application permission) missing and/or admin consent missing.
|
||||
|
||||
- **Groups disappear / reappear**:
|
||||
- If a group is not observed, it is retained per FR-005a and may still resolve labels until purged.
|
||||
- If the same group ID reappears within the retention window, the record MUST be updated and `last_seen_at` refreshed.
|
||||
|
||||
- **Large tenants**:
|
||||
- Runs MUST respect CR-002a safety-stop bounds (max pages / max runtime) to prevent runaway cost and load.
|
||||
- UI browse/search MUST operate on cached DB data only and rely on indexes (no live Graph during render-time).
|
||||
|
||||
## Out of Scope (Groups v1)
|
||||
|
||||
- Caching group **membership** or **owners**.
|
||||
- Any UI behavior that requires delegated Graph tokens for group name resolution.
|
||||
- Cross-tenant compare/promotion of groups.
|
||||
- Delta-sync based directory change tracking.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** If this feature introduces any external directory calls or any write/change behavior,
|
||||
the spec MUST describe contract registry updates, safety gates (preview/confirmation/audit), tenant isolation, and tests.
|
||||
|
||||
### Assumptions & Dependencies
|
||||
|
||||
- The application already has a tenant context concept; this cache is scoped strictly to the active tenant.
|
||||
- Background processing infrastructure exists (queue worker) so sync can run asynchronously.
|
||||
- The tenant has (or can be granted) sufficient directory read permissions to list groups.
|
||||
- Directory reads for group sync run using app-only (service principal) permissions.
|
||||
- Required Graph permission for Groups v1 is `Group.Read.All` (application permission).
|
||||
- Groups v1 does not require Graph beta-only capabilities.
|
||||
- Other modules that display group references can integrate via a shared “group name resolution” capability.
|
||||
- Groups v1 sync scope is all groups in the tenant (not only groups already referenced by TenantAtlas).
|
||||
- The system can execute scheduled background work (e.g., cron/scheduler) to run periodic group sync.
|
||||
- The system can enforce retention/purge for cached groups that have not been observed for a configured period.
|
||||
- Groups v1 cache stores group metadata only and does not store group membership or owners.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001 (Tenant-scoped cache)**: System MUST store a tenant-scoped cache of Entra groups in the application, and it MUST be read-only with respect to Entra.
|
||||
- **FR-001a (Cached fields; no membership caching)**: Groups v1 caching MUST store only group metadata needed for name resolution and browsing, and MUST NOT store group membership or owners. Cached fields for v1 MUST include: `id`, `displayName`, `groupTypes`, `securityEnabled`, `mailEnabled`, and `last_seen_at`.
|
||||
- **FR-002 (Sync runs)**: System MUST create an append-only “sync run” record for each sync attempt, including lifecycle status and basic counters (observed/upserted/errors).
|
||||
- **FR-002a (Status semantics: partial vs failed)**: The run MUST use explicit statuses: `pending`, `running`, `succeeded`, `failed`, `partial`. Criteria:
|
||||
- `succeeded`: all pages processed to completion.
|
||||
- `partial`: at least one page was processed AND `upserted_count > 0`, but the run did not complete successfully (e.g., aborted due to repeated throttling, transient faults, or other non-fatal error conditions).
|
||||
- `failed`: zero progress (no processed pages) OR `upserted_count = 0` due to a fatal condition (e.g., missing permission) or immediate abort.
|
||||
The run record MUST include `error_category` (see FR-011) and a safe `error_summary` when status is `failed` or `partial`.
|
||||
- **FR-002b (Run record: paging + safety stop fields)**: The run record MUST include `pages_fetched`, `items_observed_count`, `items_upserted_count`, and (when applicable) `safety_stop_triggered` plus `safety_stop_reason`.
|
||||
- **FR-003 (Async only)**: Starting a groups sync MUST dispatch background work and MUST NOT perform full sync work in the initiating HTTP request.
|
||||
- **FR-004 (Idempotent selection)**: System MUST support a deterministic “selection identifier” for the v1 groups sync scope so repeated requests with the same selection can be recognized and deduplicated. For Groups v1, the selection identifier MUST be the stable string `groups-v1:all`.
|
||||
- **FR-004a (Full tenant scope)**: For Groups v1, the sync MUST attempt to enumerate all groups visible in the tenant scope (subject to permissions and provider limits).
|
||||
- **FR-004b (Start modes + cadence default)**: The system MUST support starting groups sync both manually (operator-initiated) and on a periodic schedule. The default scheduled cadence for Groups v1 MUST be daily at 02:00 UTC (configurable per environment).
|
||||
- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment.
|
||||
- **FR-005 (Staleness)**: System MUST track when a group was last observed and MUST allow identifying “stale” groups as “not observed for N days”; for v1, default N = 30 and configurable per environment. All comparisons MUST be computed in **UTC**.
|
||||
- **FR-005a (Retention & purge)**: If a group is not observed in subsequent full-tenant syncs, the system MUST retain the cached record for 90 days after its last observed timestamp (`last_seen_at`), and it MUST purge the record after that retention window. All comparisons MUST be computed in **UTC**.
|
||||
- **FR-005b (Disappear / reappear semantics)**: The cache MUST behave deterministically when a group disappears and later reappears:
|
||||
- If a group is not observed in a run, its cached row MUST remain unchanged (including `last_seen_at`) until either it is observed again or it is purged per FR-005a.
|
||||
- If the same group `id` is observed again **within** the retention window, the system MUST update the existing row (refresh `displayName`, flags/types as returned, and set `last_seen_at` to the run’s observation time).
|
||||
- If the group was already purged (retention window elapsed) and later reappears, the system MUST create a new cached row for that group `id` on observation.
|
||||
- **FR-006 (UI safety + guard test)**: UI rendering for directory groups and for name resolution MUST use cached data only (no live directory calls at render time). The feature MUST include a test that fails hard if the Graph client is invoked during render.
|
||||
- **FR-006a (Definition: render-time)**: “Render-time” means the synchronous request lifecycle that produces UI output (Filament pages, Livewire component renders, and any server-side code executed to build the response). During render-time, the system MUST NOT call Microsoft Graph for group data. Background work (queued jobs, scheduled commands) MAY call Graph.
|
||||
- **FR-007 (Search & filters)**: Users MUST be able to search cached groups by name and filter by at least “stale vs. fresh” and “group type” (security vs. M365 group) when that info is available.
|
||||
- **FR-008 (Cross-module resolution)**: When other modules reference a group ID, the system MUST resolve it to a friendly label from the cache when available, and MUST show a clear unresolved fallback when not available.
|
||||
- **FR-009 (Audit & observability)**: Starting a sync and completing/failing a sync MUST be auditable (who initiated, when, status), and the operator MUST be able to view the run record.
|
||||
- **FR-009a (Audit minimum fields + visibility)**: Each sync attempt MUST produce (1) a sync run record visible in the admin UI, and (2) audit entries for start and finish/failure visible in the tenant audit log. Minimum audit metadata for these entries: `tenant`, `action`, `status`, `run_id`, `selection_key`, initiator identity (user id or “system”), and on completion: `observed_count`, `upserted_count`, `error_count`, `error_category` (if any).
|
||||
- **FR-010 (Tenant isolation)**: All group cache data and sync run data MUST be strictly tenant-scoped; cross-tenant access MUST be prevented by authorization.
|
||||
- **FR-011 (Error hygiene + categories)**: Failure details stored for runs MUST be safe to display (no secrets), and SHOULD be summarized into stable categories. For v1, the supported categories are: `permission`, `throttling`, `transient`, `unknown`.
|
||||
- **FR-011b (Retry & backoff policy; v1)**: The sync implementation MUST apply a consistent retry policy for Graph group listing:
|
||||
- Retryable: HTTP `429`, `503`, and network timeouts.
|
||||
- Backoff: exponential backoff with jitter (full jitter), capped at 60 seconds per delay.
|
||||
- Max retries: 8 total per run.
|
||||
- On retry exhaustion: abort per CR-002a (`retry_exhausted`) and set status per FR-002a.
|
||||
- **FR-011c (Permission-missing operator UX)**: When a run fails due to missing permissions (HTTP `403`), the operator-facing UI MUST display a stable error code and guidance.
|
||||
- Error code: `graph_forbidden`.
|
||||
- Guidance: “Grant `Group.Read.All` (application permission) and admin consent for the tenant, then retry.”
|
||||
- **FR-011d (Throttling/transient operator UX)**: When a run aborts due to throttling/transient retry exhaustion, the operator-facing UI MUST display a stable warning/error code and a safe summary.
|
||||
- Error/warning code: `graph_throttled` for repeated `429/503`; `graph_timeout` for timeouts.
|
||||
- Summary MUST include: retry count (up to 8) and whether the safety stop was triggered.
|
||||
- **FR-011a (Auth mode + required permission)**: Directory reads for groups sync MUST use app-only (service principal) authorization and MUST NOT depend on an interactive user session. The required Graph permission is `Group.Read.All` (application).
|
||||
- **FR-012 (Tests)**: The feature MUST include automated tests covering tenant isolation, basic sync-run lifecycle persistence, and “UI pages render without live directory calls,” including a fail-hard guard test that asserts the Graph client is not invoked during render.
|
||||
|
||||
### Scheduled Sync Semantics
|
||||
|
||||
- **SS-001 (Initiator identity)**: Scheduled sync runs MUST be recorded as system-initiated (no user initiator).
|
||||
- **SS-002 (Visibility)**: Operators MUST be able to distinguish scheduled vs manual runs when viewing run records.
|
||||
- **SS-003 (Schedule dedupe)**: Scheduled dispatch MUST NOT create duplicate runs for the same tenant + selection in the same schedule slot. If a run for the current slot already exists (or an active run is in progress), the dispatcher MUST skip creating a second run.
|
||||
|
||||
### Contract Requirements
|
||||
|
||||
- **CR-001 (Graph contract registry)**: The feature MUST register the Groups v1 directory read contract in the Graph contract registry.
|
||||
- **CR-002 (List endpoint + select fields)**: Sync MUST read groups via `GET /groups` with `$select=id,displayName,groupTypes,securityEnabled,mailEnabled` and MUST page via `@odata.nextLink` until completion (or a documented safety stop).
|
||||
- **CR-002a (Safety stop: bounds + abort criteria)**: Sync MUST enforce safety-stop bounds for Groups v1 to prevent runaway runs:
|
||||
- **Max pages**: 200 pages per run.
|
||||
- **Max runtime**: 10 minutes per run.
|
||||
- **Abort criteria (immediate)**: stop the run if runtime exceeds max runtime, pages exceed max pages, or the Graph client exceeds 8 total retries for retryable throttling/transient conditions (e.g., repeated 429/503) (“retry exhausted”).
|
||||
- **Abort status**: if `items_upserted_count > 0`, mark run as `partial`; otherwise mark run as `failed`.
|
||||
- **Run record**: set `safety_stop_triggered=true` and set `safety_stop_reason` to one of: `max_pages`, `max_runtime`, `retry_exhausted`.
|
||||
- **CR-003 (Delta strategy deferred)**: Delta endpoints/strategies MUST NOT be used in Groups v1.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **EntraGroup**: A tenant-scoped cached record representing an Entra group (external ID, display name, group type/flags when available, last observed timestamp). Groups v1 stores metadata only.
|
||||
- **EntraGroupSyncRun**: A tenant-scoped run record representing one attempt to sync groups (status, timestamps, counters, safe error summary, initiator).
|
||||
- **GroupReference**: A cross-module reference to a group by ID that can be resolved (or not) via the cache.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001 (Resolve time)**: For a tenant with cached groups, operators can resolve a group ID to a human-friendly label in under 30 seconds.
|
||||
- **Measured from**: UI workflow time (manual stopwatch) for either (a) Directory → Groups search + open detail OR (b) in-context label rendering.
|
||||
- **Scope/window**: 95th percentile over 20 attempts on a representative tenant with cached groups.
|
||||
- **Pass/fail reporting**: recorded as a QA note for the release gate.
|
||||
|
||||
- **SC-002 (Render resilience)**: Directory/Groups pages render successfully even when the external directory API is unavailable.
|
||||
- **Measured from**: server-side request completion (HTTP 200) while the sync job’s Graph calls are failing/blocked (e.g., simulated outage), demonstrating the UI does not depend on live Graph during render-time.
|
||||
- **Scope/window**: 20 page loads across Directory → Groups list and detail.
|
||||
- **Pass/fail reporting**: QA note; must remain true for all future pages integrating the label resolver.
|
||||
|
||||
- **SC-003 (Label resolution rate)**: After a successful sync, at least 95% of group GUID references on supported pages resolve to a friendly label.
|
||||
- **Measured from**: page output inspection against the cached DB state (resolved label present vs unresolved fallback) for a representative tenant.
|
||||
- **Scope/window**: sample of supported pages + a set of group GUID references observed in the last successful run; target is 95% resolved.
|
||||
- **Pass/fail reporting**: QA note with sample size and tenant used.
|
||||
|
||||
- **SC-004 (End-to-end operator workflow time)**: Operators can complete “Sync Groups → Verify group exists → Use group in mapping” in under 3 minutes.
|
||||
- **Measured from**: UI workflow time (manual stopwatch) plus sync run duration from DB.
|
||||
- **Scope/window**: 95th percentile over the last 20 runs per tenant + selection key (`groups-v1:all`).
|
||||
- **Reporting requirement**: run detail UI MUST show `started_at`, `finished_at`, computed `duration_seconds`, and counters (`items_observed_count`, `items_upserted_count`, `error_count`).
|
||||
@ -1,184 +0,0 @@
|
||||
---
|
||||
|
||||
description: "Task list for implementing Entra Group Directory Cache (Groups v1)"
|
||||
---
|
||||
|
||||
# Tasks: Entra Group Directory Cache (Groups v1)
|
||||
|
||||
**Input**: Design documents from `/specs/051-entra-group-directory-cache/`
|
||||
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
|
||||
|
||||
**Tests**: Required (Pest), per FR-012 in `spec.md`.
|
||||
|
||||
**Organization**: Tasks are grouped by user story so each story can be implemented and tested independently.
|
||||
|
||||
## Format: `- [ ] T### [P?] [US#?] Description (with file path)`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[US#]**: User story mapping (US1/US2/US3)
|
||||
|
||||
## Path Conventions (Laravel)
|
||||
|
||||
- App code: `app/`
|
||||
- Config: `config/`
|
||||
- DB: `database/migrations/`, `database/factories/`
|
||||
- Filament admin: `app/Filament/Resources/`
|
||||
- Console + scheduler wiring: `app/Console/Commands/`, `routes/console.php`
|
||||
- Tests (Pest): `tests/Feature/`, `tests/Unit/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Introduce feature configuration and permission metadata.
|
||||
|
||||
- [x] T001 Create feature config defaults in config/directory_groups.php (staleness_days=30, retention_days=90, schedule enabled/interval, page_size)
|
||||
- [x] T002 Update Group.Read.All feature tagging in config/intune_permissions.php (include directory-groups / group-directory-cache)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Database schema + core domain objects required by all user stories.
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [x] T003 Create migrations for Entra groups + sync runs in database/migrations/*_create_entra_groups_table.php and database/migrations/*_create_entra_group_sync_runs_table.php
|
||||
- [x] T004 [P] Create EntraGroup model in app/Models/EntraGroup.php (tenant-scoped, casts for group_types)
|
||||
- [x] T005 [P] Create EntraGroupSyncRun model in app/Models/EntraGroupSyncRun.php (status constants, counters, safe error fields)
|
||||
- [x] T006 [P] Create EntraGroup factory in database/factories/EntraGroupFactory.php
|
||||
- [x] T007 [P] Create EntraGroupSyncRun factory in database/factories/EntraGroupSyncRunFactory.php
|
||||
- [x] T008 Add tenant relationships in app/Models/Tenant.php (entraGroups(), entraGroupSyncRuns())
|
||||
- [x] T009 Add directory-groups contract metadata in config/graph_contracts.php and accessor(s) in app/Services/Graph/GraphContractRegistry.php (select fields + base path for /groups)
|
||||
|
||||
**Checkpoint**: Foundation ready — user stories can now proceed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 - Sync groups into a tenant-scoped cache (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: Manual + scheduled async sync writes tenant-scoped group cache and run records (including safe error summaries).
|
||||
|
||||
**Independent Test**: Trigger a sync for a tenant and verify a run record exists and group rows are populated for that tenant.
|
||||
|
||||
### Tests for User Story 1 (REQUIRED) ⚠️
|
||||
|
||||
> Write these tests FIRST and ensure they fail before implementation.
|
||||
|
||||
- [x] T010 [P] [US1] Add test for manual sync creating a run + dispatching a job in tests/Feature/DirectoryGroups/StartSyncTest.php
|
||||
- [x] T011 [P] [US1] Add test that sync job upserts groups + updates counters in tests/Feature/DirectoryGroups/SyncJobUpsertsGroupsTest.php
|
||||
- [x] T012 [P] [US1] Add test that retention purge deletes groups older than retention window in tests/Feature/DirectoryGroups/SyncRetentionPurgeTest.php
|
||||
- [x] T013 [P] [US1] Add test that scheduled dispatcher creates a run without a user initiator in tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php
|
||||
|
||||
- [x] T014 [P] [US1] Implement deterministic selection key helper in app/Services/Directory/EntraGroupSelection.php
|
||||
- [x] T015 [US1] Implement sync service in app/Services/Directory/EntraGroupSyncService.php (Graph paging via GraphClientInterface, upsert, counters, retention purge)
|
||||
- [x] T016 [US1] Implement queued job in app/Jobs/EntraGroupSyncJob.php (run lifecycle: pending→running→succeeded/failed; safe error_category + error_summary)
|
||||
- [x] T017 [US1] Add audit logging for sync start/completion in app/Jobs/EntraGroupSyncJob.php using app/Services/Intune/AuditLogger.php
|
||||
- [x] T018 [P] [US1] Create Filament run resource in app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/{ListEntraGroupSyncRuns,ViewEntraGroupSyncRun}.php
|
||||
- [x] T019 [US1] Add “Sync Groups” action on List page in app/Filament/Resources/EntraGroupSyncRunResource/Pages/ListEntraGroupSyncRuns.php (creates run, dispatches job, shows notification)
|
||||
- [x] T020 [US1] Implement scheduled dispatcher command in app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php (idempotent per tenant + minute-slot, respects config/directory_groups.php)
|
||||
- [x] T021 [US1] Register scheduler entry in routes/console.php for tenantpilot:directory-groups:dispatch (every minute)
|
||||
|
||||
**Checkpoint**: US1 complete — cache population works, runs are visible, and scheduled runs can be observed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 - Browse groups (Priority: P2)
|
||||
|
||||
**Goal**: Provide a cached-only “Directory → Groups” browse/search/filter/detail UI.
|
||||
|
||||
**Independent Test**: After a sync run, open the groups list and verify search/filter/detail views work using only cached data.
|
||||
|
||||
### Tests for User Story 2 (REQUIRED) ⚠️
|
||||
|
||||
- [x] T022 [P] [US2] Add test for listing/searching/filtering cached groups in tests/Feature/DirectoryGroups/BrowseGroupsTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [x] T023 [P] [US2] Create Filament groups resource in app/Filament/Resources/EntraGroupResource.php and app/Filament/Resources/EntraGroupResource/Pages/{ListEntraGroups,ViewEntraGroup}.php
|
||||
- [x] T024 [US2] Implement list query + filters (q search, stale filter, group type filter) in app/Filament/Resources/EntraGroupResource.php
|
||||
- [x] T025 [US2] Add “Sync Groups” header action that links to run list or starts a sync in app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php
|
||||
|
||||
**Checkpoint**: US2 complete — operators can browse/search cached groups and triage staleness.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 - Name resolution across the suite (Priority: P3)
|
||||
|
||||
**Goal**: Any UI that displays group IDs shows a friendly cached label (or unresolved fallback) without live directory calls during render.
|
||||
|
||||
**Independent Test**: Load a page that includes group GUID references and verify it renders with names from DB cache (and fallbacks when missing) without calling Graph.
|
||||
|
||||
### Tests for User Story 3 (REQUIRED) ⚠️
|
||||
|
||||
- [x] T026 [P] [US3] Add unit test for label resolver formatting + fallbacks in tests/Unit/DirectoryGroups/EntraGroupLabelResolverTest.php
|
||||
- [x] T027 [P] [US3] Add feature test ensuring Tenant/Restore/PolicyVersion UI renders without Graph calls during render in tests/Feature/DirectoryGroups/NoLiveGraphOnRenderTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [x] T028 [US3] Implement DB-backed label resolver in app/Services/Directory/EntraGroupLabelResolver.php (resolveOne/resolveMany, tenant-scoped, stable formatting)
|
||||
- [x] T029 [US3] Refactor group label rendering in app/Filament/Resources/TenantResource.php to use EntraGroupLabelResolver instead of Graph lookups
|
||||
- [x] T030 [US3] Refactor Livewire assignments widget to use cached labels: app/Livewire/PolicyVersionAssignmentsWidget.php and resources/views/livewire/policy-version-assignments-widget.blade.php
|
||||
- [x] T031 [US3] Refactor restore results to show cached labels where possible: resources/views/filament/infolists/entries/restore-results.blade.php
|
||||
- [x] T032 [US3] Refactor restore group-mapping inputs/labels to prefer cached labels: app/Filament/Resources/RestoreRunResource.php
|
||||
|
||||
**Checkpoint**: US3 complete — name resolution is consistent and render-safe across key pages.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Validation, cleanup, and operational readiness.
|
||||
|
||||
- [x] T033 [P] Run formatting on changed files with vendor/bin/pint --dirty
|
||||
- [x] T034 Run targeted Pest suite for this feature (e.g., php artisan test tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups)
|
||||
- [x] T035 Validate operator workflow against specs/051-entra-group-directory-cache/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)** → blocks nothing, but should be done first.
|
||||
- **Foundational (Phase 2)** → BLOCKS all user stories.
|
||||
- **User Stories (Phase 3–5)** → depend on Foundational; proceed in priority order (US1 → US2 → US3).
|
||||
- **Polish (Phase 6)** → depends on all desired user stories.
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
- **US1 (P1)**: Depends on Phase 2 only.
|
||||
- **US2 (P2)**: Depends on US1 (needs data to browse).
|
||||
- **US3 (P3)**: Depends on US1 (needs cache populated); can begin in parallel with US2 once US1 is stable.
|
||||
|
||||
---
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
### US1
|
||||
|
||||
- Parallel tests: T010–T013
|
||||
- Parallel foundational code: T014 and T018 can be developed alongside T015–T016 once schema/models exist.
|
||||
|
||||
### US2
|
||||
|
||||
- T023 can start while T022 is being written (different files).
|
||||
|
||||
### US3
|
||||
|
||||
- T028 can start while T026/T027 are being written; integration tasks T029–T032 can be split across different files.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP (US1 only)
|
||||
|
||||
1. Phase 1 → Phase 2
|
||||
2. Phase 3 (US1): tests → sync service/job → run UI → scheduler
|
||||
3. Stop and validate via quickstart workflow
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
- Add US2 (browse) after US1 is stable.
|
||||
- Add US3 (name resolution) after US1, then refactor page-by-page.
|
||||
@ -1,114 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EntraGroupResource\Pages\ListEntraGroups;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('cached groups can be listed, searched, and filtered (DB-only)', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$otherTenant = Tenant::factory()->create();
|
||||
|
||||
$stalenessDays = (int) config('directory_groups.staleness_days', 30);
|
||||
|
||||
EntraGroup::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'entra_id' => '00000000-0000-0000-0000-000000000001',
|
||||
'display_name' => 'Alpha Team',
|
||||
'group_types' => null,
|
||||
'security_enabled' => true,
|
||||
'mail_enabled' => false,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
EntraGroup::query()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'entra_id' => '00000000-0000-0000-0000-000000000002',
|
||||
'display_name' => 'Beta Unified',
|
||||
'group_types' => ['Unified'],
|
||||
'security_enabled' => false,
|
||||
'mail_enabled' => true,
|
||||
'last_seen_at' => now()->subDays(max(1, $stalenessDays) + 1),
|
||||
]);
|
||||
|
||||
EntraGroup::query()->create([
|
||||
'tenant_id' => $otherTenant->getKey(),
|
||||
'entra_id' => '00000000-0000-0000-0000-000000000003',
|
||||
'display_name' => 'Other Tenant Group',
|
||||
'group_types' => null,
|
||||
'security_enabled' => true,
|
||||
'mail_enabled' => false,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$extractNames = function ($livewire): Collection {
|
||||
$records = $livewire->instance()->getTableRecords();
|
||||
|
||||
$items = method_exists($records, 'items') ? collect($records->items()) : collect($records);
|
||||
|
||||
return $items->pluck('display_name');
|
||||
};
|
||||
|
||||
$names = $extractNames(Livewire::test(ListEntraGroups::class));
|
||||
expect($names)->toContain('Alpha Team');
|
||||
expect($names)->toContain('Beta Unified');
|
||||
expect($names)->not->toContain('Other Tenant Group');
|
||||
|
||||
$names = $extractNames(
|
||||
Livewire::test(ListEntraGroups::class)
|
||||
->set('tableSearch', 'Beta')
|
||||
);
|
||||
expect($names)->toContain('Beta Unified');
|
||||
expect($names)->not->toContain('Alpha Team');
|
||||
|
||||
$names = $extractNames(
|
||||
Livewire::test(ListEntraGroups::class)
|
||||
->set('tableFilters.stale.value', 1)
|
||||
);
|
||||
expect($names)->toContain('Beta Unified');
|
||||
expect($names)->not->toContain('Alpha Team');
|
||||
|
||||
$names = $extractNames(
|
||||
Livewire::test(ListEntraGroups::class)
|
||||
->set('tableFilters.group_type.value', 'security')
|
||||
);
|
||||
expect($names)->toContain('Alpha Team');
|
||||
expect($names)->not->toContain('Beta Unified');
|
||||
});
|
||||
|
||||
test('group detail is tenant-scoped and cross-tenant access is forbidden (403)', function () {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
$groupB = EntraGroup::query()->create([
|
||||
'tenant_id' => $tenantB->getKey(),
|
||||
'entra_id' => '00000000-0000-0000-0000-000000000099',
|
||||
'display_name' => 'Tenant B Group',
|
||||
'group_types' => null,
|
||||
'security_enabled' => true,
|
||||
'mail_enabled' => false,
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$user->tenants()->syncWithoutDetaching([
|
||||
$tenantA->getKey() => ['role' => 'owner'],
|
||||
$tenantB->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(EntraGroupResource::getUrl('view', ['record' => $groupB], tenant: $tenantA))
|
||||
->assertForbidden();
|
||||
});
|
||||
@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
|
||||
use function Pest\Laravel\mock;
|
||||
|
||||
beforeEach(function () {
|
||||
putenv('INTUNE_TENANT_ID');
|
||||
unset($_ENV['INTUNE_TENANT_ID'], $_SERVER['INTUNE_TENANT_ID']);
|
||||
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
$this->policy = Policy::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
]);
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->user->tenants()->syncWithoutDetaching([
|
||||
$this->tenant->getKey() => ['role' => 'owner'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders policy version view without any Graph calls during render', function () {
|
||||
mock(GraphClientInterface::class)
|
||||
->shouldNotReceive('listPolicies')
|
||||
->shouldNotReceive('getPolicy')
|
||||
->shouldNotReceive('getOrganization')
|
||||
->shouldNotReceive('applyPolicy')
|
||||
->shouldNotReceive('getServicePrincipalPermissions')
|
||||
->shouldNotReceive('request');
|
||||
|
||||
$version = PolicyVersion::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'policy_id' => $this->policy->id,
|
||||
'version_number' => 1,
|
||||
'assignments' => [
|
||||
[
|
||||
'id' => 'assignment-1',
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-123',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge(
|
||||
filamentTenantRouteParams($this->tenant),
|
||||
['record' => $version],
|
||||
)));
|
||||
|
||||
$response->assertOk();
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('scheduled dispatcher creates a run without a user initiator', function () {
|
||||
Queue::fake();
|
||||
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
|
||||
Config::set('directory_groups.schedule.enabled', true);
|
||||
Config::set('directory_groups.schedule.time_utc', '02:00');
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC'));
|
||||
|
||||
Artisan::call('tenantpilot:directory-groups:dispatch', [
|
||||
'--tenant' => [$tenant->tenant_id],
|
||||
]);
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
$run = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', 'groups-v1:all')
|
||||
->where('slot_key', $slotKey)
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull()
|
||||
->and($run->initiator_user_id)->toBeNull();
|
||||
|
||||
Queue::assertPushed(EntraGroupSyncJob::class);
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('starts a manual group sync by creating a run and dispatching a job', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(EntraGroupSyncService::class);
|
||||
|
||||
$run = $service->startManualSync($tenant, $user);
|
||||
|
||||
expect($run)->toBeInstanceOf(EntraGroupSyncRun::class)
|
||||
->and($run->tenant_id)->toBe($tenant->getKey())
|
||||
->and($run->initiator_user_id)->toBe($user->getKey())
|
||||
->and($run->selection_key)->toBe('groups-v1:all')
|
||||
->and($run->status)->toBe(EntraGroupSyncRun::STATUS_PENDING);
|
||||
|
||||
Queue::assertPushed(EntraGroupSyncJob::class);
|
||||
});
|
||||
@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
|
||||
it('sync job upserts groups and updates run counters', function () {
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = EntraGroupSyncRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => 'groups-v1:all',
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
EntraGroup::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'entra_id' => '11111111-1111-1111-1111-111111111111',
|
||||
'display_name' => 'Old Name',
|
||||
'last_seen_at' => now('UTC')->subDays(10),
|
||||
]);
|
||||
|
||||
$mock = \Mockery::mock(GraphClientInterface::class);
|
||||
$mock->shouldReceive('request')
|
||||
->twice()
|
||||
->andReturn(
|
||||
new GraphResponse(success: true, data: [
|
||||
'value' => [
|
||||
[
|
||||
'id' => '11111111-1111-1111-1111-111111111111',
|
||||
'displayName' => 'New Name',
|
||||
'groupTypes' => ['Unified'],
|
||||
'securityEnabled' => false,
|
||||
'mailEnabled' => true,
|
||||
],
|
||||
],
|
||||
'@odata.nextLink' => 'https://graph.microsoft.com/v1.0/groups?$skiptoken=abc',
|
||||
], status: 200),
|
||||
new GraphResponse(success: true, data: [
|
||||
'value' => [
|
||||
[
|
||||
'id' => '22222222-2222-2222-2222-222222222222',
|
||||
'displayName' => 'Second Group',
|
||||
'groupTypes' => [],
|
||||
'securityEnabled' => true,
|
||||
'mailEnabled' => false,
|
||||
],
|
||||
],
|
||||
], status: 200),
|
||||
);
|
||||
|
||||
app()->instance(GraphClientInterface::class, $mock);
|
||||
|
||||
$job = new EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: 'groups-v1:all',
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
);
|
||||
|
||||
$job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class));
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(EntraGroupSyncRun::STATUS_SUCCEEDED)
|
||||
->and($run->pages_fetched)->toBe(2)
|
||||
->and($run->items_observed_count)->toBe(2)
|
||||
->and($run->items_upserted_count)->toBe(2)
|
||||
->and($run->error_count)->toBe(0)
|
||||
->and($run->finished_at)->not->toBeNull();
|
||||
|
||||
expect(EntraGroup::query()->where('tenant_id', $tenant->getKey())->count())->toBe(2);
|
||||
|
||||
$updated = EntraGroup::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('entra_id', '11111111-1111-1111-1111-111111111111')
|
||||
->first();
|
||||
|
||||
expect($updated)->not->toBeNull()
|
||||
->and($updated->display_name)->toBe('New Name')
|
||||
->and($updated->mail_enabled)->toBeTrue();
|
||||
});
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
it('purges cached groups older than the retention window', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Config::set('directory_groups.retention_days', 90);
|
||||
|
||||
$oldGroup = EntraGroup::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'last_seen_at' => now('UTC')->subDays(91),
|
||||
]);
|
||||
|
||||
$newGroup = EntraGroup::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'last_seen_at' => now('UTC')->subDays(10),
|
||||
]);
|
||||
|
||||
$run = EntraGroupSyncRun::factory()->create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'selection_key' => 'groups-v1:all',
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => $user->getKey(),
|
||||
]);
|
||||
|
||||
$mock = \Mockery::mock(GraphClientInterface::class);
|
||||
$mock->shouldReceive('request')
|
||||
->once()
|
||||
->andReturn(new GraphResponse(success: true, data: ['value' => []], status: 200));
|
||||
app()->instance(GraphClientInterface::class, $mock);
|
||||
|
||||
$job = new EntraGroupSyncJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
selectionKey: 'groups-v1:all',
|
||||
slotKey: null,
|
||||
runId: (int) $run->getKey(),
|
||||
);
|
||||
|
||||
$job->handle(app(EntraGroupSyncService::class), app(AuditLogger::class));
|
||||
|
||||
expect(EntraGroup::query()->whereKey($oldGroup->getKey())->exists())->toBeFalse();
|
||||
expect(EntraGroup::query()->whereKey($newGroup->getKey())->exists())->toBeTrue();
|
||||
});
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('readonly users cannot acknowledge findings', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$thrown = null;
|
||||
|
||||
try {
|
||||
Livewire::test(ListFindings::class)
|
||||
->callTableAction('acknowledge', $finding);
|
||||
} catch (Throwable $exception) {
|
||||
$thrown = $exception;
|
||||
}
|
||||
|
||||
expect($thrown)->not->toBeNull();
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_NEW);
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('a finding can be acknowledged via table action', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->callTableAction('acknowledge', $finding);
|
||||
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
expect($finding->acknowledged_at)->not->toBeNull();
|
||||
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||
});
|
||||
@ -1,74 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
|
||||
test('it creates a drift finding when policy assignment targets change', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-assignments');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$baselineAssignments = [
|
||||
[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-a',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$currentAssignments = [
|
||||
[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => 'group-b',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||
'assignments' => $baselineAssignments,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||
'assignments' => $currentAssignments,
|
||||
]);
|
||||
|
||||
$generator = app(DriftFindingGenerator::class);
|
||||
$created = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||
|
||||
expect($created)->toBe(1);
|
||||
|
||||
$finding = Finding::query()->where('tenant_id', $tenant->getKey())->first();
|
||||
expect($finding)->not->toBeNull();
|
||||
expect($finding->subject_type)->toBe('assignment');
|
||||
expect($finding->subject_external_id)->toBe($policy->external_id);
|
||||
expect($finding->evidence_jsonb)->toHaveKey('change_type', 'modified');
|
||||
});
|
||||
@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
|
||||
test('it selects the previous and latest successful runs for the same scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-a');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
$selected = $selector->selectBaselineAndCurrent($tenant, $scopeKey);
|
||||
|
||||
expect($selected)->not->toBeNull();
|
||||
expect($selected['baseline']->getKey())->toBe($baseline->getKey());
|
||||
expect($selected['current']->getKey())->toBe($current->getKey());
|
||||
});
|
||||
|
||||
test('it returns null when fewer than two successful runs exist for scope', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-b');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
|
||||
expect($selector->selectBaselineAndCurrent($tenant, $scopeKey))->toBeNull();
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('acknowledge all matching requires confirmation when acknowledging more than 100 findings', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$findings = Finding::factory()
|
||||
->count(101)
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->mountAction('acknowledge_all_matching')
|
||||
->callMountedAction();
|
||||
|
||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->mountAction('acknowledge_all_matching')
|
||||
->setActionData(['confirmation' => 'ACKNOWLEDGE'])
|
||||
->callMountedAction()
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_ACKNOWLEDGED));
|
||||
});
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('acknowledge all matching respects scope key filter', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeA = 'scope-a';
|
||||
$scopeB = 'scope-b';
|
||||
|
||||
$matching = Finding::factory()
|
||||
->count(2)
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'scope_key' => $scopeA,
|
||||
]);
|
||||
|
||||
$nonMatching = Finding::factory()
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'scope_key' => $scopeB,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->set('tableFilters', [
|
||||
'status' => ['value' => Finding::STATUS_NEW],
|
||||
'finding_type' => ['value' => Finding::FINDING_TYPE_DRIFT],
|
||||
'scope_key' => ['scope_key' => $scopeA],
|
||||
])
|
||||
->callAction('acknowledge_all_matching');
|
||||
|
||||
$matching->each(function (Finding $finding) use ($user): void {
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||
});
|
||||
|
||||
$nonMatching->refresh();
|
||||
expect($nonMatching->status)->toBe(Finding::STATUS_NEW);
|
||||
expect($nonMatching->acknowledged_at)->toBeNull();
|
||||
});
|
||||
@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('readonly users cannot bulk acknowledge selected findings', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$findings = Finding::factory()
|
||||
->count(2)
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$thrown = null;
|
||||
|
||||
try {
|
||||
Livewire::test(ListFindings::class)
|
||||
->callTableBulkAction('acknowledge_selected', $findings);
|
||||
} catch (Throwable $exception) {
|
||||
$thrown = $exception;
|
||||
}
|
||||
|
||||
expect($thrown)->not->toBeNull();
|
||||
|
||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||
});
|
||||
|
||||
test('readonly users cannot acknowledge all matching findings', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$findings = Finding::factory()
|
||||
->count(2)
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
$thrown = null;
|
||||
|
||||
try {
|
||||
Livewire::test(ListFindings::class)
|
||||
->callAction('acknowledge_all_matching');
|
||||
} catch (Throwable $exception) {
|
||||
$thrown = $exception;
|
||||
}
|
||||
|
||||
expect($thrown)->not->toBeNull();
|
||||
|
||||
$findings->each(fn (Finding $finding) => expect($finding->refresh()->status)->toBe(Finding::STATUS_NEW));
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('findings can be acknowledged via table bulk action', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$findings = Finding::factory()
|
||||
->count(3)
|
||||
->for($tenant)
|
||||
->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'status' => Finding::STATUS_NEW,
|
||||
'acknowledged_at' => null,
|
||||
'acknowledged_by_user_id' => null,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->callTableBulkAction('acknowledge_selected', $findings)
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$findings->each(function (Finding $finding) use ($user): void {
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
expect($finding->acknowledged_at)->not->toBeNull();
|
||||
expect((int) $finding->acknowledged_by_user_id)->toBe((int) $user->getKey());
|
||||
});
|
||||
});
|
||||
@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Support\RunIdempotency;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening Drift does not re-dispatch when the last run completed with zero findings', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-zero-findings');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
BulkOperationRun::factory()->for($tenant)->for($user)->create([
|
||||
'resource' => 'drift',
|
||||
'action' => 'generate',
|
||||
'status' => 'completed',
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'item_ids' => [$scopeKey],
|
||||
'total_items' => 1,
|
||||
'processed_items' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSet('state', 'ready')
|
||||
->assertSet('scopeKey', $scopeKey);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Drift\DriftEvidence;
|
||||
|
||||
test('drift evidence sanitizer keeps only allowlisted keys', function () {
|
||||
$payload = [
|
||||
'change_type' => 'modified',
|
||||
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||
'baseline' => ['hash' => 'a'],
|
||||
'current' => ['hash' => 'b'],
|
||||
'diff' => ['a' => 'b'],
|
||||
'notes' => 'ok',
|
||||
'access_token' => 'should-not-leak',
|
||||
'client_secret' => 'should-not-leak',
|
||||
'raw_payload' => ['big' => 'blob'],
|
||||
];
|
||||
|
||||
$safe = app(DriftEvidence::class)->sanitize($payload);
|
||||
|
||||
expect($safe)->toHaveKeys(['change_type', 'summary', 'baseline', 'current', 'diff', 'notes']);
|
||||
expect($safe)->not->toHaveKey('access_token');
|
||||
expect($safe)->not->toHaveKey('client_secret');
|
||||
expect($safe)->not->toHaveKey('raw_payload');
|
||||
});
|
||||
@ -1,151 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
|
||||
test('finding detail shows an assignments diff with DB-only group label resolution', function () {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => hash('sha256', 'scope-assignments-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-456',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$group1 = '76b787af-cae9-4a8e-89e9-b8cc67f81779';
|
||||
$group2 = '6b0bc3d7-91f3-4e4b-8181-8236d908d2dd';
|
||||
$group3 = 'cbd8d685-0d95-4de0-8fce-140a5cad8ddc';
|
||||
|
||||
EntraGroup::factory()->for($tenant)->create([
|
||||
'entra_id' => strtolower($group1),
|
||||
'display_name' => 'Group One',
|
||||
]);
|
||||
|
||||
EntraGroup::factory()->for($tenant)->create([
|
||||
'entra_id' => strtolower($group3),
|
||||
'display_name' => 'Group Three',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||
'assignments' => [
|
||||
[
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => $group1,
|
||||
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||
],
|
||||
],
|
||||
[
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget',
|
||||
'groupId' => $group2,
|
||||
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||
'assignments' => [
|
||||
[
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => $group1,
|
||||
'deviceAndAppManagementAssignmentFilterId' => '62fb77d0-8f85-4ba0-a1c7-fd71d418521d',
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'include',
|
||||
],
|
||||
],
|
||||
[
|
||||
'intent' => 'apply',
|
||||
'target' => [
|
||||
'@odata.type' => '#microsoft.graph.groupAssignmentTarget',
|
||||
'groupId' => $group3,
|
||||
'deviceAndAppManagementAssignmentFilterId' => null,
|
||||
'deviceAndAppManagementAssignmentFilterType' => 'none',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_assignments',
|
||||
'changed_fields' => ['assignments_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'assignments_hash' => 'baseline-assignments-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'assignments_hash' => 'current-assignments-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy 456',
|
||||
]);
|
||||
|
||||
$expectedGroup1 = EntraGroupLabelResolver::formatLabel('Group One', $group1);
|
||||
$expectedGroup2 = EntraGroupLabelResolver::formatLabel(null, $group2);
|
||||
$expectedGroup3 = EntraGroupLabelResolver::formatLabel('Group Three', $group3);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Assignments diff')
|
||||
->assertSee('1 added')
|
||||
->assertSee('1 removed')
|
||||
->assertSee('1 changed')
|
||||
->assertSee($expectedGroup1)
|
||||
->assertSee($expectedGroup2)
|
||||
->assertSee($expectedGroup3)
|
||||
->assertSee('include')
|
||||
->assertSee('none');
|
||||
});
|
||||
@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
|
||||
test('finding detail shows a scope tags diff without Graph calls', function () {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => hash('sha256', 'scope-scope-tags-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-scope-tags-1',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 17,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [
|
||||
'ids' => ['0'],
|
||||
'names' => ['Default'],
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 18,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||
'snapshot' => ['customSettingFoo' => 'Same value'],
|
||||
'assignments' => [],
|
||||
'scope_tags' => [
|
||||
'ids' => ['0', 'a1b2c3'],
|
||||
'names' => ['Default', 'Verbund-1'],
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'scope_tag',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_scope_tags',
|
||||
'changed_fields' => ['scope_tags_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'scope_tags_hash' => 'baseline-scope-tags-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'scope_tags_hash' => 'current-scope-tags-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy Scope Tags',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Scope tags diff')
|
||||
->assertSee('1 added')
|
||||
->assertSee('0 removed')
|
||||
->assertSee('Verbund-1')
|
||||
->assertSee('Default');
|
||||
});
|
||||
@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
|
||||
test('finding detail shows a normalized settings diff for policy_snapshot evidence', function () {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => hash('sha256', 'scope-settings-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'external_id' => 'policy-123',
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10',
|
||||
]);
|
||||
|
||||
$baselineVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $baseline->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'description' => 'Old description',
|
||||
'customSettingFoo' => 'Old value',
|
||||
],
|
||||
]);
|
||||
|
||||
$currentVersion = PolicyVersion::factory()->for($tenant)->create([
|
||||
'policy_id' => $policy->getKey(),
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'platform' => $policy->platform,
|
||||
'captured_at' => $current->finished_at->copy()->subHour(),
|
||||
'snapshot' => [
|
||||
'displayName' => 'My Policy',
|
||||
'description' => 'New description',
|
||||
'customSettingFoo' => 'New value',
|
||||
],
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => [
|
||||
'kind' => 'policy_snapshot',
|
||||
'changed_fields' => ['snapshot_hash'],
|
||||
],
|
||||
'baseline' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $baselineVersion->getKey(),
|
||||
'snapshot_hash' => 'baseline-hash',
|
||||
],
|
||||
'current' => [
|
||||
'policy_id' => $policy->external_id,
|
||||
'policy_version_id' => $currentVersion->getKey(),
|
||||
'snapshot_hash' => 'current-hash',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Normalized diff')
|
||||
->assertSee('1 changed')
|
||||
->assertSee('Custom Setting Foo')
|
||||
->assertSee('From')
|
||||
->assertSee('To')
|
||||
->assertSee('Old value')
|
||||
->assertSee('New value');
|
||||
});
|
||||
@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
test('finding detail renders without Graph calls', function () {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => hash('sha256', 'scope-detail'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'subject_type' => 'deviceConfiguration',
|
||||
'subject_external_id' => 'policy-123',
|
||||
'evidence_jsonb' => [
|
||||
'change_type' => 'modified',
|
||||
'summary' => ['changed_fields' => ['assignments_hash']],
|
||||
],
|
||||
]);
|
||||
|
||||
$inventoryItem = InventoryItem::factory()->for($tenant)->create([
|
||||
'external_id' => $finding->subject_external_id,
|
||||
'display_name' => 'My Policy 123',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee($finding->fingerprint)
|
||||
->assertSee($inventoryItem->display_name);
|
||||
});
|
||||
@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
|
||||
test('drift generation is deterministic for the same baseline/current', function () {
|
||||
[, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-determinism');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$policy = Policy::factory()->for($tenant)->create([
|
||||
'policy_type' => 'settingsCatalogPolicy',
|
||||
]);
|
||||
|
||||
$baselineAssignments = [
|
||||
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-a']],
|
||||
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-b']],
|
||||
];
|
||||
|
||||
$currentAssignments = [
|
||||
['target' => ['@odata.type' => '#microsoft.graph.groupAssignmentTarget', 'groupId' => 'group-c']],
|
||||
];
|
||||
|
||||
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||
'version_number' => 1,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'captured_at' => $baseline->finished_at->copy()->subMinute(),
|
||||
'assignments' => $baselineAssignments,
|
||||
]);
|
||||
|
||||
PolicyVersion::factory()->for($tenant)->for($policy)->create([
|
||||
'version_number' => 2,
|
||||
'policy_type' => $policy->policy_type,
|
||||
'captured_at' => $current->finished_at->copy()->subMinute(),
|
||||
'assignments' => $currentAssignments,
|
||||
]);
|
||||
|
||||
$generator = app(DriftFindingGenerator::class);
|
||||
|
||||
$created1 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||
$fingerprints1 = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->pluck('fingerprint')
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$created2 = $generator->generate($tenant, $baseline, $current, $scopeKey);
|
||||
$fingerprints2 = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->pluck('fingerprint')
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
expect($created1)->toBeGreaterThan(0);
|
||||
expect($created2)->toBe(0);
|
||||
expect($fingerprints2)->toBe($fingerprints1);
|
||||
});
|
||||
@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Support\RunIdempotency;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('opening Drift dispatches generation when findings are missing', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-dispatch');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->resource)->toBe('drift');
|
||||
expect($bulkRun->action)->toBe('generate');
|
||||
expect($bulkRun->status)->toBe('pending');
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun): bool {
|
||||
return $job->tenantId === (int) $tenant->getKey()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||
&& $job->currentRunId === (int) $current->getKey()
|
||||
&& $job->scopeKey === $scopeKey
|
||||
&& $job->bulkOperationRunId === (int) $bulkRun->getKey();
|
||||
});
|
||||
});
|
||||
|
||||
test('opening Drift is idempotent while a run is pending', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-idempotent');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('opening Drift does not dispatch generation when fewer than two successful runs exist', function () {
|
||||
Queue::fake();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-blocked');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Drift\DriftHasher;
|
||||
|
||||
test('normalized hashing ignores volatile timestamps', function () {
|
||||
$hasher = app(DriftHasher::class);
|
||||
|
||||
$a = [
|
||||
'id' => 'abc',
|
||||
'createdDateTime' => '2020-01-01T00:00:00Z',
|
||||
'lastModifiedDateTime' => '2020-01-02T00:00:00Z',
|
||||
'target' => ['groupId' => 'group-a'],
|
||||
];
|
||||
|
||||
$b = [
|
||||
'id' => 'abc',
|
||||
'createdDateTime' => '2025-01-01T00:00:00Z',
|
||||
'lastModifiedDateTime' => '2026-01-02T00:00:00Z',
|
||||
'target' => ['groupId' => 'group-a'],
|
||||
];
|
||||
|
||||
expect($hasher->hashNormalized($a))->toBe($hasher->hashNormalized($b));
|
||||
});
|
||||
|
||||
test('normalized hashing is order-insensitive for lists', function () {
|
||||
$hasher = app(DriftHasher::class);
|
||||
|
||||
$listA = [
|
||||
['target' => ['groupId' => 'group-a']],
|
||||
['target' => ['groupId' => 'group-b']],
|
||||
];
|
||||
|
||||
$listB = [
|
||||
['target' => ['groupId' => 'group-b']],
|
||||
['target' => ['groupId' => 'group-a']],
|
||||
];
|
||||
|
||||
expect($hasher->hashNormalized($listA))->toBe($hasher->hashNormalized($listB));
|
||||
});
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('drift landing exposes baseline/current run ids and timestamps', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-landing-comparison-info');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
->assertSet('scopeKey', $scopeKey)
|
||||
->assertSet('baselineRunId', (int) $baseline->getKey())
|
||||
->assertSet('currentRunId', (int) $current->getKey())
|
||||
->assertSet('baselineFinishedAt', $baseline->finished_at->toDateTimeString())
|
||||
->assertSet('currentFinishedAt', $current->finished_at->toDateTimeString());
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user