}> */ public function getRequiredPermissions(): array { return config('intune_permissions.permissions', []); } /** * @return array|null,last_checked_at:?\Illuminate\Support\Carbon}> */ public function getGrantedPermissions(Tenant $tenant): array { return TenantPermission::query() ->where('tenant_id', $tenant->id) ->get() ->keyBy('permission_key') ->map(fn (TenantPermission $permission) => [ 'status' => $permission->status, 'details' => $permission->details, 'last_checked_at' => $permission->last_checked_at, ]) ->all(); } /** * @param array|null}|string>|null $grantedStatuses * @param bool $persist Persist comparison results to tenant_permissions * @param bool $liveCheck If true, fetch actual permissions from Graph API * @param bool $useConfiguredStub Include configured stub permissions when no live check is used * @param array{tenant?:string|null,client_id?:string|null,client_secret?:string|null,client_request_id?:string|null}|null $graphOptions * @return array{ * overall_status:string, * permissions:array,status:string,details:array|null}>, * live_check?: array{attempted:bool,succeeded:bool,http_status:?int,reason_code:?string} * } */ public function compare( Tenant $tenant, ?array $grantedStatuses = null, bool $persist = true, bool $liveCheck = false, bool $useConfiguredStub = true, ?array $graphOptions = null, ): array { $required = $this->getRequiredPermissions(); $liveCheckMeta = [ 'attempted' => false, 'succeeded' => false, 'http_status' => null, 'reason_code' => null, ]; $liveCheckFailed = false; $liveCheckDetails = null; // If liveCheck is requested, fetch actual permissions from Graph if ($liveCheck && $grantedStatuses === null) { $liveCheckMeta['attempted'] = true; $appId = null; if (is_array($graphOptions) && is_string($graphOptions['client_id'] ?? null) && $graphOptions['client_id'] !== '') { $appId = (string) $graphOptions['client_id']; } elseif (is_string($tenant->graphOptions()['client_id'] ?? null) && $tenant->graphOptions()['client_id'] !== '') { $appId = (string) $tenant->graphOptions()['client_id']; } if ($appId !== null) { $liveCheckMeta['app_id'] = $appId; } $grantedStatuses = $this->fetchLivePermissions($tenant, $graphOptions); if (isset($grantedStatuses['__error'])) { $liveCheckFailed = true; $liveCheckError = is_array($grantedStatuses['__error'] ?? null) ? $grantedStatuses['__error'] : null; $liveCheckDetails = is_array($liveCheckError['details'] ?? null) ? $liveCheckError['details'] : (is_array($liveCheckError) ? $liveCheckError : null); $httpStatus = $liveCheckDetails['status'] ?? null; $liveCheckMeta['http_status'] = is_int($httpStatus) ? $httpStatus : null; $liveCheckMeta['reason_code'] = $this->deriveLiveCheckReasonCode( $liveCheckMeta['http_status'], is_array($liveCheckDetails) ? $liveCheckDetails : null, ); unset($grantedStatuses['__error']); $grantedStatuses = null; } else { $observedCount = is_array($grantedStatuses) ? count($grantedStatuses) : 0; $liveCheckMeta['observed_permissions_count'] = $observedCount; if ($observedCount === 0) { // Enterprise-safe: if the live refresh produced an empty inventory, treat it as non-fresh. // This prevents false "missing" findings due to partial/misconfigured verification context. $liveCheckMeta['succeeded'] = false; $liveCheckMeta['reason_code'] = 'permissions_inventory_empty'; $grantedStatuses = null; } else { $liveCheckMeta['succeeded'] = true; $liveCheckMeta['reason_code'] = 'ok'; } } } $storedStatuses = $this->getGrantedPermissions($tenant); if (! $useConfiguredStub) { $storedStatuses = $this->dropConfiguredStatuses($storedStatuses); } $granted = $this->normalizeGrantedStatuses( $grantedStatuses ?? array_replace_recursive( $useConfiguredStub && ! $liveCheck ? $this->configuredGrantedStatuses() : [], $storedStatuses ) ); $results = []; $hasMissing = false; $hasErrors = false; $checkedAt = now(); $canPersist = $persist; if ($liveCheckMeta['attempted'] === true && $liveCheckMeta['succeeded'] === false) { // Enterprise-safe: never overwrite stored inventory when we could not refresh it. $canPersist = false; } foreach ($required as $permission) { $key = $permission['key']; $status = $liveCheckFailed ? 'error' : ($granted[$key]['status'] ?? 'missing'); $details = $liveCheckFailed ? array_filter([ 'source' => 'graph_api', 'status' => $liveCheckMeta['http_status'], 'reason_code' => $liveCheckMeta['reason_code'], 'message' => is_array($liveCheckDetails) ? ($liveCheckDetails['message'] ?? null) : null, ], fn (mixed $value): bool => $value !== null) : ($granted[$key]['details'] ?? null); if ($canPersist) { TenantPermission::updateOrCreate( [ 'tenant_id' => $tenant->id, 'permission_key' => $key, ], [ 'status' => $status, 'details' => $details, 'last_checked_at' => $checkedAt, ] ); } $results[] = [ 'key' => $key, 'type' => $permission['type'] ?? 'application', 'description' => $permission['description'] ?? null, 'features' => $permission['features'] ?? [], 'status' => $status, 'details' => $details, ]; $hasMissing = $hasMissing || $status === 'missing'; $hasErrors = $hasErrors || $status === 'error'; } $overall = match (true) { $hasErrors => 'error', $hasMissing => 'missing', default => 'granted', }; $payload = [ 'overall_status' => $overall, 'permissions' => $results, ]; if ($liveCheckMeta['attempted'] === true) { $payload['live_check'] = $liveCheckMeta; } return $payload; } /** * @param array|null $details */ private function deriveLiveCheckReasonCode(?int $httpStatus, ?array $details = null): string { if (is_array($details) && is_string($details['reason_code'] ?? null)) { return (string) $details['reason_code']; } return match (true) { $httpStatus === 401 => 'authentication_failed', $httpStatus === 403 => 'permission_denied', $httpStatus === 408 => 'dependency_unreachable', $httpStatus === 429 => 'throttled', is_int($httpStatus) && $httpStatus >= 500 => 'dependency_unreachable', is_int($httpStatus) && $httpStatus >= 400 => 'unknown_error', default => is_array($details) && is_string($details['message'] ?? null) ? 'dependency_unreachable' : 'unknown_error', }; } /** * @param array|null}|string> $granted * @return array|null}> */ private function normalizeGrantedStatuses(array $granted): array { $normalized = []; foreach ($granted as $key => $value) { if (is_string($value)) { $normalized[$key] = ['status' => $value, 'details' => null]; continue; } $normalized[$key] = [ 'status' => $value['status'] ?? 'missing', 'details' => $value['details'] ?? null, ]; } return $normalized; } /** * @param array|null,last_checked_at:?\Illuminate\Support\Carbon}> $granted * @return array|null,last_checked_at:?\Illuminate\Support\Carbon}> */ private function dropConfiguredStatuses(array $granted): array { foreach ($granted as $key => $value) { $source = $value['details']['source'] ?? null; if ($source === 'configured') { unset($granted[$key]); } } return $granted; } /** * @return array|null}> */ public function configuredGrantedStatuses(): array { $configured = $this->configuredGrantedKeys(); $normalized = []; foreach ($configured as $key) { $normalized[$key] = [ 'status' => 'granted', 'details' => ['source' => 'configured'], ]; } return $normalized; } /** * @return array */ private function configuredGrantedKeys(): array { $env = env('INTUNE_GRANTED_PERMISSIONS'); if (is_string($env) && filled($env)) { return collect(explode(',', $env)) ->map(fn (string $key) => trim($key)) ->filter() ->values() ->all(); } return config('intune_permissions.granted_stub', []); } /** * Fetch actual granted permissions from Graph API. * * @return array|null}> */ private function fetchLivePermissions(Tenant $tenant, ?array $graphOptions = null): array { try { $response = $this->graphClient->getServicePrincipalPermissions( $graphOptions ?? $tenant->graphOptions() ); if (! $response->success) { return [ '__error' => [ 'status' => 'error', 'details' => [ 'source' => 'graph_api', 'status' => $response->status, 'errors' => $response->errors, ], ], ]; } $grantedPermissions = $response->data['permissions'] ?? []; $diagnostics = is_array($response->data['diagnostics'] ?? null) ? $response->data['diagnostics'] : null; $assignmentsTotal = is_array($diagnostics) ? (int) ($diagnostics['assignments_total'] ?? 0) : 0; $mappedTotal = is_array($diagnostics) ? (int) ($diagnostics['mapped_total'] ?? 0) : null; if ($assignmentsTotal > 0 && $mappedTotal === 0) { return [ '__error' => [ 'status' => 'error', 'details' => [ 'source' => 'graph_api', 'status' => $response->status, 'reason_code' => 'permission_mapping_failed', 'message' => 'Graph returned app role assignments, but the system could not map them to permission values.', 'diagnostics' => $diagnostics, ], ], ]; } $normalized = []; foreach ($grantedPermissions as $permission) { $normalized[$permission] = [ 'status' => 'granted', 'details' => ['source' => 'graph_api', 'checked_at' => now()->toIso8601String()], ]; } return $normalized; } catch (\Throwable $e) { // Log error but don't fail - fall back to config \Log::warning('Failed to fetch live permissions from Graph', [ 'tenant_id' => $tenant->id, 'error' => $e->getMessage(), ]); return [ '__error' => [ 'status' => 'error', 'details' => [ 'source' => 'graph_api', 'message' => $e->getMessage(), ], ], ]; } } }