TenantAtlas/app/Console/Commands/TenantpilotDispatchDirectoryGroupsSync.php
ahmido bc846d7c5c 051-entra-group-directory-cache (#57)
Summary

Adds a tenant-scoped Entra Groups “Directory Cache” to enable DB-only group name resolution across the app (no render-time Graph calls), plus sync runs + observability.

What’s included
	•	Entra Groups cache
	•	New entra_groups storage (tenant-scoped) for group metadata (no memberships).
	•	Retention semantics: groups become stale / retained per spec (no hard delete on first miss).
	•	Group Sync Runs
	•	New “Group Sync Runs” UI (list + detail) with tenant isolation (403 on cross-tenant access).
	•	Manual “Sync Groups” action: creates/reuses a run, dispatches job, DB notification with “View run” link.
	•	Scheduled dispatcher command wired in console.php.
	•	DB-only label resolution (US3)
	•	Shared EntraGroupLabelResolver with safe fallback Unresolved (…last8) and UUID guarding.
	•	Refactors to prefer cached names (no typeahead / no live Graph) in:
	•	Tenant RBAC group selects
	•	Policy version assignments widget
	•	Restore results + restore wizard group mapping labels

Safety / Guardrails
	•	No render-time Graph calls: fail-hard guard test verifies UI paths don’t call GraphClientInterface during page render.
	•	Tenant isolation & authorization: policies + scoped queries enforced (cross-tenant access returns 403, not 404).
	•	Data minimization: only group metadata is cached (no membership/owners).

Tests / Verification
	•	Added/updated tests under tests/Feature/DirectoryGroups and tests/Unit/DirectoryGroups:
	•	Start sync → run record + job dispatch + upserts
	•	Retention purge semantics
	•	Scheduled dispatch wiring
	•	Render-time Graph guard
	•	UI/resource access isolation
	•	Ran:
	•	./vendor/bin/pint --dirty
	•	./vendor/bin/sail artisan test tests/Feature/DirectoryGroups
	•	./vendor/bin/sail artisan test tests/Unit/DirectoryGroups

Notes / Follow-ups
	•	UI polish remains (picker/lookup UX, consistent progress widget/toasts across modules, navigation grouping).
	•	pr-gate checklist still has non-blocking open items (mostly UX/ops polish); requirements gate is green.

Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local>
Reviewed-on: #57
2026-01-11 23:24:12 +00:00

117 lines
3.5 KiB
PHP

<?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');
}
}