TenantAtlas/app/Services/Directory/RoleDefinitionsSyncService.php

260 lines
8.5 KiB
PHP

<?php
namespace App\Services\Directory;
use App\Jobs\SyncRoleDefinitionsJob;
use App\Models\EntraRoleDefinition;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Graph\GraphClientInterface;
use App\Services\Graph\GraphContractRegistry;
use App\Services\Graph\GraphResponse;
use App\Services\OperationRunService;
use App\Services\Providers\MicrosoftGraphOptionsResolver;
use Carbon\CarbonImmutable;
class RoleDefinitionsSyncService
{
public function __construct(
private readonly GraphClientInterface $graph,
private readonly GraphContractRegistry $contracts,
private readonly MicrosoftGraphOptionsResolver $graphOptionsResolver,
) {}
public function startManualSync(Tenant $tenant, User $user): OperationRun
{
$selectionKey = 'role_definitions_v1';
/** @var OperationRunService $opService */
$opService = app(OperationRunService::class);
$opRun = $opService->ensureRunWithIdentity(
tenant: $tenant,
type: 'directory_role_definitions.sync',
identityInputs: ['selection_key' => $selectionKey],
context: [
'selection_key' => $selectionKey,
'trigger' => 'manual',
],
initiator: $user,
);
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
return $opRun;
}
dispatch(new SyncRoleDefinitionsJob(
tenantId: (int) $tenant->getKey(),
operationRun: $opRun,
));
return $opRun;
}
/**
* @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): array
{
$nowUtc = CarbonImmutable::now('UTC');
$policyType = $this->contracts->directoryRoleDefinitionsPolicyType();
$path = $this->contracts->directoryRoleDefinitionsListPath();
$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_role_definitions.page_size', 200);
if ($pageSize > 0) {
$query['$top'] = $pageSize;
}
$sanitized = $this->contracts->sanitizeQuery($policyType, $query);
$query = $sanitized['query'];
$maxPages = (int) config('directory_role_definitions.safety_stop.max_pages', 50);
$maxRuntimeSeconds = (int) config('directory_role_definitions.safety_stop.max_runtime_seconds', 120);
$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 = $this->graphOptionsResolver->resolveForTenant($tenant);
$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;
$isBuiltIn = (bool) ($item['isBuiltIn'] ?? false);
$values = [
'display_name' => is_string($displayName) ? $displayName : $entraId,
'is_built_in' => $isBuiltIn,
'last_seen_at' => $nowUtc,
];
EntraRoleDefinition::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_role_definitions.retention_days', 90);
if ($retentionDays > 0) {
$cutoff = $nowUtc->subDays($retentionDays);
EntraRoleDefinition::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_role_definitions.safety_stop.max_retries', 6);
$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 role definitions listing.'];
}
if ($status === 429) {
return ['throttled', 'throttling', 'Graph throttled the role definitions 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, '/');
}
}