TenantAtlas/app/Services/Directory/EntraGroupSyncService.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

263 lines
8.5 KiB
PHP

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