Added a resolver/validation flow that fetches endpoint security template definitions and enforces them before CREATE/PATCH so we don’t call Graph with invalid settings. Hardened restore endpoint resolution (built-in fallback to deviceManagement/configurationPolicies, clearer error metadata, preview-only fallback when metadata is missing) and exposed Graph path/method in restore UI details. Stripped read-only fields when PATCHing endpointSecurityIntent so the request no longer fails with “properties not patchable”. Added regression tests covering endpoint security restore, intent sanitization, unknown type safety, Graph error metadata, and endpoint resolution behavior. Testing GraphClientEndpointResolutionTest.php ./vendor/bin/pint --dirty Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #25
907 lines
33 KiB
PHP
907 lines
33 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Graph;
|
|
|
|
use Illuminate\Http\Client\ConnectionException;
|
|
use Illuminate\Http\Client\RequestException;
|
|
use Illuminate\Http\Client\Response;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use Throwable;
|
|
|
|
class MicrosoftGraphClient implements GraphClientInterface
|
|
{
|
|
private const DEFAULT_SCOPE = 'https://graph.microsoft.com/.default';
|
|
|
|
private const MAX_LIST_PAGES = 50;
|
|
|
|
private string $baseUrl;
|
|
|
|
private string $tokenUrlTemplate;
|
|
|
|
private string $tenantId;
|
|
|
|
private string $clientId;
|
|
|
|
private string $clientSecret;
|
|
|
|
private array $defaultScopes = [self::DEFAULT_SCOPE];
|
|
|
|
private int $timeout;
|
|
|
|
private int $retryTimes;
|
|
|
|
private int $retrySleepMs;
|
|
|
|
public function __construct(
|
|
private readonly GraphLogger $logger,
|
|
private readonly GraphContractRegistry $contracts,
|
|
) {
|
|
$this->baseUrl = rtrim(config('graph.base_url', 'https://graph.microsoft.com'), '/')
|
|
.'/'.trim(config('graph.version', 'beta'), '/');
|
|
$this->tokenUrlTemplate = config('graph.token_url', 'https://login.microsoftonline.com/%s/oauth2/v2.0/token');
|
|
$this->tenantId = config('graph.tenant_id', '');
|
|
$this->clientId = config('graph.client_id', '');
|
|
$this->clientSecret = config('graph.client_secret', '');
|
|
$this->defaultScopes = $this->normalizeScopes(config('graph.scope', self::DEFAULT_SCOPE));
|
|
$this->timeout = (int) config('graph.timeout', 10);
|
|
$this->retryTimes = (int) config('graph.retry.times', 2);
|
|
$this->retrySleepMs = (int) config('graph.retry.sleep', 200);
|
|
}
|
|
|
|
public function listPolicies(string $policyType, array $options = []): GraphResponse
|
|
{
|
|
$endpoint = $this->endpointFor($policyType);
|
|
$contract = $this->contracts->get($policyType);
|
|
$allowedSelect = is_array($contract['allowed_select'] ?? null) ? $contract['allowed_select'] : [];
|
|
$defaultSelect = $options['select'] ?? ($allowedSelect !== [] ? implode(',', $allowedSelect) : null);
|
|
|
|
$queryInput = array_filter([
|
|
'$top' => $options['top'] ?? null,
|
|
'$filter' => $options['filter'] ?? null,
|
|
'$select' => $defaultSelect,
|
|
'platform' => $options['platform'] ?? null,
|
|
], fn ($value) => $value !== null && $value !== '');
|
|
|
|
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
|
|
$query = $sanitized['query'];
|
|
$warnings = $sanitized['warnings'];
|
|
|
|
$context = $this->resolveContext($options);
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
$fullPath = $this->buildFullPath($endpoint, $query);
|
|
|
|
$this->logger->logRequest('list_policies', [
|
|
'endpoint' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'policy_type' => $policyType,
|
|
'tenant' => $context['tenant'],
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$sendOptions = ['query' => $query, 'client_request_id' => $clientRequestId];
|
|
|
|
if (isset($options['access_token'])) {
|
|
$sendOptions['access_token'] = $options['access_token'];
|
|
}
|
|
|
|
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
|
|
|
if ($response->failed()) {
|
|
$graphResponse = $this->toGraphResponse(
|
|
action: 'list_policies',
|
|
response: $response,
|
|
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
],
|
|
warnings: $warnings,
|
|
);
|
|
|
|
if (! $this->shouldApplySelectFallback($graphResponse, $query)) {
|
|
return $graphResponse;
|
|
}
|
|
|
|
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select', ARRAY_FILTER_USE_BOTH);
|
|
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
|
|
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
|
|
|
|
if (isset($options['access_token'])) {
|
|
$fallbackSendOptions['access_token'] = $options['access_token'];
|
|
}
|
|
|
|
$this->logger->logRequest('list_policies_fallback', [
|
|
'endpoint' => $endpoint,
|
|
'full_path' => $fallbackPath,
|
|
'method' => 'GET',
|
|
'policy_type' => $policyType,
|
|
'tenant' => $context['tenant'],
|
|
'query' => $fallbackQuery ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
|
|
|
|
if ($fallbackResponse->failed()) {
|
|
return $this->toGraphResponse(
|
|
action: 'list_policies',
|
|
response: $fallbackResponse,
|
|
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fallbackPath,
|
|
'method' => 'GET',
|
|
'query' => $fallbackQuery ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
],
|
|
warnings: array_values(array_unique(array_merge(
|
|
$warnings,
|
|
['Capability fallback applied: removed $select for compatibility.']
|
|
))),
|
|
);
|
|
}
|
|
|
|
$response = $fallbackResponse;
|
|
$query = $fallbackQuery;
|
|
$fullPath = $fallbackPath;
|
|
$warnings = array_values(array_unique(array_merge(
|
|
$warnings,
|
|
['Capability fallback applied: removed $select for compatibility.']
|
|
)));
|
|
}
|
|
|
|
$json = $response->json() ?? [];
|
|
$policies = $json['value'] ?? (is_array($json) ? $json : []);
|
|
$nextLink = $json['@odata.nextLink'] ?? null;
|
|
$pages = 1;
|
|
|
|
while (is_string($nextLink) && $nextLink !== '') {
|
|
if ($pages >= self::MAX_LIST_PAGES) {
|
|
$graphResponse = new GraphResponse(
|
|
success: false,
|
|
data: [],
|
|
status: 500,
|
|
errors: [[
|
|
'message' => 'Graph pagination exceeded maximum page limit.',
|
|
'max_pages' => self::MAX_LIST_PAGES,
|
|
]],
|
|
warnings: $warnings,
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
'pages_fetched' => $pages,
|
|
],
|
|
);
|
|
|
|
$this->logger->logResponse('list_policies', $graphResponse, $graphResponse->meta);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
$pageOptions = ['client_request_id' => $clientRequestId];
|
|
|
|
if (isset($options['access_token'])) {
|
|
$pageOptions['access_token'] = $options['access_token'];
|
|
}
|
|
|
|
$pageResponse = $this->send('GET', $nextLink, $pageOptions, $context);
|
|
|
|
if ($pageResponse->failed()) {
|
|
$graphResponse = $this->toGraphResponse(
|
|
action: 'list_policies',
|
|
response: $pageResponse,
|
|
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
'pages_fetched' => $pages,
|
|
],
|
|
warnings: array_values(array_unique(array_merge(
|
|
$warnings,
|
|
['Pagination failed while listing policies.']
|
|
))),
|
|
);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
$pageJson = $pageResponse->json() ?? [];
|
|
$pageValue = $pageJson['value'] ?? [];
|
|
|
|
if (is_array($pageValue) && $pageValue !== []) {
|
|
$policies = array_merge($policies, $pageValue);
|
|
}
|
|
|
|
$nextLink = $pageJson['@odata.nextLink'] ?? null;
|
|
$pages++;
|
|
}
|
|
|
|
$meta = $this->responseMeta($response, [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$meta['pages_fetched'] = $pages;
|
|
$meta['item_count'] = count($policies);
|
|
|
|
if ($pages > 1) {
|
|
$warnings = array_values(array_unique(array_merge($warnings, [
|
|
sprintf('Pagination applied: fetched %d pages.', $pages),
|
|
])));
|
|
}
|
|
|
|
$graphResponse = new GraphResponse(
|
|
success: true,
|
|
data: $policies,
|
|
status: $response->status(),
|
|
warnings: $warnings,
|
|
meta: $meta,
|
|
);
|
|
|
|
$this->logger->logResponse('list_policies', $graphResponse, $meta);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse
|
|
{
|
|
$endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
|
|
$queryInput = array_filter([
|
|
'$select' => $options['select'] ?? null,
|
|
'$expand' => $options['expand'] ?? null,
|
|
], fn ($value) => $value !== null && $value !== '');
|
|
|
|
$sanitized = $this->contracts->sanitizeQuery($policyType, $queryInput);
|
|
$query = $sanitized['query'];
|
|
$warnings = $sanitized['warnings'];
|
|
|
|
$context = $this->resolveContext($options);
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
$fullPath = $this->buildFullPath($endpoint, $query);
|
|
|
|
$this->logger->logRequest('get_policy', [
|
|
'endpoint' => $endpoint,
|
|
'policy_type' => $policyType,
|
|
'policy_id' => $policyId,
|
|
'tenant' => $context['tenant'],
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$response = $this->send('GET', $endpoint, ['query' => $query, 'client_request_id' => $clientRequestId], $context);
|
|
|
|
$graphResponse = $this->toGraphResponse(
|
|
action: 'get_policy',
|
|
response: $response,
|
|
transform: fn (array $json) => ['payload' => $json],
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
],
|
|
warnings: $warnings,
|
|
);
|
|
|
|
if ($graphResponse->failed() && ! empty($query)) {
|
|
$fallbackQuery = array_filter($query, fn ($value, $key) => $key !== '$select' && $key !== '$expand', ARRAY_FILTER_USE_BOTH);
|
|
$fallbackPath = $this->buildFullPath($endpoint, $fallbackQuery);
|
|
$fallbackSendOptions = ['query' => $fallbackQuery, 'client_request_id' => $clientRequestId];
|
|
|
|
if (isset($options['access_token'])) {
|
|
$fallbackSendOptions['access_token'] = $options['access_token'];
|
|
}
|
|
|
|
$this->logger->logRequest('get_policy_fallback', [
|
|
'endpoint' => $endpoint,
|
|
'policy_type' => $policyType,
|
|
'policy_id' => $policyId,
|
|
'tenant' => $context['tenant'],
|
|
'full_path' => $fallbackPath,
|
|
'method' => 'GET',
|
|
'query' => $fallbackQuery ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$fallbackResponse = $this->send('GET', $endpoint, $fallbackSendOptions, $context);
|
|
|
|
$graphResponse = $this->toGraphResponse(
|
|
action: 'get_policy',
|
|
response: $fallbackResponse,
|
|
transform: fn (array $json) => ['payload' => $json],
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fallbackPath,
|
|
'method' => 'GET',
|
|
'query' => $fallbackQuery ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
],
|
|
warnings: array_values(array_unique(array_merge(
|
|
$warnings,
|
|
['Capability fallback applied: removed $select/$expand for compatibility.']
|
|
))),
|
|
);
|
|
}
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
private function shouldApplySelectFallback(GraphResponse $graphResponse, array $query): bool
|
|
{
|
|
if (! $graphResponse->failed()) {
|
|
return false;
|
|
}
|
|
|
|
if (($graphResponse->status ?? null) !== 400) {
|
|
return false;
|
|
}
|
|
|
|
if (! array_key_exists('$select', $query)) {
|
|
return false;
|
|
}
|
|
|
|
$errorMessage = $graphResponse->meta['error_message'] ?? null;
|
|
|
|
if (! is_string($errorMessage) || $errorMessage === '') {
|
|
return false;
|
|
}
|
|
|
|
if (stripos($errorMessage, 'Parsing OData Select and Expand failed') !== false) {
|
|
return true;
|
|
}
|
|
|
|
if (stripos($errorMessage, 'Could not find a property named') !== false) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function getOrganization(array $options = []): GraphResponse
|
|
{
|
|
$context = $this->resolveContext($options);
|
|
$endpoint = 'organization';
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
$fullPath = $this->buildFullPath($endpoint);
|
|
|
|
$this->logger->logRequest('get_organization', [
|
|
'endpoint' => $endpoint,
|
|
'tenant' => $context['tenant'],
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
|
|
|
|
return $this->toGraphResponse(
|
|
action: 'get_organization',
|
|
response: $response,
|
|
transform: fn (array $json) => $json['value'][0] ?? $json,
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => 'GET',
|
|
'client_request_id' => $clientRequestId,
|
|
]
|
|
);
|
|
}
|
|
|
|
public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse
|
|
{
|
|
$endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId);
|
|
$method = strtoupper($options['method'] ?? 'PATCH');
|
|
|
|
$context = $this->resolveContext($options);
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
$fullPath = $this->buildFullPath($endpoint);
|
|
|
|
$this->logger->logRequest('apply_policy', [
|
|
'endpoint' => $endpoint,
|
|
'policy_type' => $policyType,
|
|
'policy_id' => $policyId,
|
|
'tenant' => $context['tenant'],
|
|
'method' => $method,
|
|
'full_path' => $fullPath,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$response = $this->send($method, $endpoint, ['json' => $payload, 'client_request_id' => $clientRequestId], $context);
|
|
|
|
return $this->toGraphResponse(
|
|
action: 'apply_policy',
|
|
response: $response,
|
|
transform: fn (array $json) => $json,
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $fullPath,
|
|
'method' => $method,
|
|
'client_request_id' => $clientRequestId,
|
|
]
|
|
);
|
|
}
|
|
|
|
public function getServicePrincipalPermissions(array $options = []): GraphResponse
|
|
{
|
|
$context = $this->resolveContext($options);
|
|
$clientId = $context['client_id'];
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
|
|
// First, get the service principal object by clientId (appId)
|
|
$endpoint = "servicePrincipals?\$filter=appId eq '{$clientId}'";
|
|
|
|
$this->logger->logRequest('get_service_principal', [
|
|
'endpoint' => $endpoint,
|
|
'client_id' => $clientId,
|
|
'tenant' => $context['tenant'],
|
|
'method' => 'GET',
|
|
'full_path' => $endpoint,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$response = $this->send('GET', $endpoint, ['client_request_id' => $clientRequestId], $context);
|
|
|
|
if ($response->failed()) {
|
|
return $this->toGraphResponse(
|
|
action: 'get_service_principal',
|
|
response: $response,
|
|
transform: fn (array $json) => [],
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $endpoint,
|
|
'full_path' => $endpoint,
|
|
'method' => 'GET',
|
|
'client_request_id' => $clientRequestId,
|
|
]
|
|
);
|
|
}
|
|
|
|
$servicePrincipals = $response->json('value', []);
|
|
if (empty($servicePrincipals)) {
|
|
return new GraphResponse(
|
|
success: false,
|
|
data: [],
|
|
status: 404,
|
|
errors: [['message' => 'Service principal not found']],
|
|
);
|
|
}
|
|
|
|
$servicePrincipalId = $servicePrincipals[0]['id'] ?? null;
|
|
if (! $servicePrincipalId) {
|
|
return new GraphResponse(
|
|
success: false,
|
|
data: [],
|
|
status: 500,
|
|
errors: [['message' => 'Service principal ID missing']],
|
|
);
|
|
}
|
|
|
|
// Now get the app role assignments (application permissions)
|
|
$assignmentsEndpoint = "servicePrincipals/{$servicePrincipalId}/appRoleAssignments";
|
|
|
|
$this->logger->logRequest('get_app_role_assignments', [
|
|
'endpoint' => $assignmentsEndpoint,
|
|
'service_principal_id' => $servicePrincipalId,
|
|
'tenant' => $context['tenant'],
|
|
'method' => 'GET',
|
|
'full_path' => $assignmentsEndpoint,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$assignmentsResponse = $this->send('GET', $assignmentsEndpoint, ['client_request_id' => $clientRequestId], $context);
|
|
|
|
return $this->toGraphResponse(
|
|
action: 'get_service_principal_permissions',
|
|
response: $assignmentsResponse,
|
|
transform: function (array $json) use ($context) {
|
|
$assignments = $json['value'] ?? [];
|
|
$permissions = [];
|
|
|
|
// Get Microsoft Graph service principal to map role IDs to permission names
|
|
$graphSpEndpoint = "servicePrincipals?\$filter=appId eq '00000003-0000-0000-c000-000000000000'";
|
|
$graphSpResponse = $this->send('GET', $graphSpEndpoint, [], $context);
|
|
$graphSps = $graphSpResponse->json('value', []);
|
|
$appRoles = $graphSps[0]['appRoles'] ?? [];
|
|
|
|
// Map role IDs to permission names
|
|
$roleMap = [];
|
|
foreach ($appRoles as $role) {
|
|
$roleMap[$role['id']] = $role['value'];
|
|
}
|
|
|
|
foreach ($assignments as $assignment) {
|
|
$roleId = $assignment['appRoleId'] ?? null;
|
|
if ($roleId && isset($roleMap[$roleId])) {
|
|
$permissions[] = $roleMap[$roleId];
|
|
}
|
|
}
|
|
|
|
return ['permissions' => $permissions];
|
|
},
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $assignmentsEndpoint,
|
|
'full_path' => $assignmentsEndpoint,
|
|
'method' => 'GET',
|
|
'client_request_id' => $clientRequestId,
|
|
]
|
|
);
|
|
}
|
|
|
|
public function request(string $method, string $path, array $options = []): GraphResponse
|
|
{
|
|
$context = $this->resolveContext($options);
|
|
$method = strtoupper($method);
|
|
$query = $options['query'] ?? [];
|
|
$fullPath = $this->buildFullPath($path, $query);
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
$action = strtolower($method).' '.$fullPath;
|
|
|
|
$this->logger->logRequest($action, [
|
|
'endpoint' => $path,
|
|
'full_path' => $fullPath,
|
|
'method' => $method,
|
|
'tenant' => $context['tenant'],
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]);
|
|
|
|
$options['client_request_id'] = $clientRequestId;
|
|
|
|
try {
|
|
$response = $this->send($method, $path, $options, $context);
|
|
} catch (RequestException|GraphException $exception) {
|
|
$graphResponse = $this->graphResponseFromException($exception, [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $path,
|
|
'full_path' => $fullPath,
|
|
'method' => $method,
|
|
'client_request_id' => $clientRequestId,
|
|
'query' => $query ?: null,
|
|
]);
|
|
|
|
$this->logger->logResponse($action, $graphResponse, [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'endpoint' => $path,
|
|
'full_path' => $fullPath,
|
|
'method' => $method,
|
|
]);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
return $this->toGraphResponse(
|
|
action: $action,
|
|
response: $response,
|
|
transform: fn (array $json) => $json,
|
|
meta: [
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $path,
|
|
'full_path' => $fullPath,
|
|
'method' => $method,
|
|
'query' => $query ?: null,
|
|
'client_request_id' => $clientRequestId,
|
|
]
|
|
);
|
|
}
|
|
|
|
private function send(string $method, string $path, array $options = [], array $context = []): Response
|
|
{
|
|
$context = $context ?: $this->resolveContext([]);
|
|
$token = $options['access_token'] ?? $context['access_token'] ?? null;
|
|
$clientRequestId = $options['client_request_id'] ?? (string) Str::uuid();
|
|
|
|
if (! $token) {
|
|
$token = $this->getAccessToken($context);
|
|
}
|
|
|
|
$pending = Http::baseUrl($this->baseUrl)
|
|
->acceptJson()
|
|
->timeout($this->timeout)
|
|
->retry($this->retryTimes, $this->retrySleepMs)
|
|
->withToken($token)
|
|
->withHeaders([
|
|
'client-request-id' => $clientRequestId,
|
|
]);
|
|
|
|
if (! empty($options['query'])) {
|
|
$pending = $pending->withQueryParameters($options['query']);
|
|
}
|
|
|
|
try {
|
|
$response = $pending->send(
|
|
$method,
|
|
ltrim($path, '/'),
|
|
isset($options['json']) ? ['json' => $options['json']] : []
|
|
);
|
|
} catch (ConnectionException $exception) {
|
|
throw new GraphException(
|
|
'Graph connection failed: '.$exception->getMessage(),
|
|
null,
|
|
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
|
|
);
|
|
} catch (RequestException $exception) {
|
|
if ($exception->response) {
|
|
$response = $exception->response;
|
|
} else {
|
|
throw GraphErrorMapper::fromThrowable(
|
|
$exception,
|
|
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
|
|
);
|
|
}
|
|
} catch (Throwable $throwable) {
|
|
throw GraphErrorMapper::fromThrowable(
|
|
$throwable,
|
|
['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null]
|
|
);
|
|
}
|
|
|
|
return $response;
|
|
}
|
|
|
|
private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
|
|
{
|
|
$json = $response->json() ?? [];
|
|
$meta = $this->responseMeta($response, $meta);
|
|
|
|
if ($response->failed()) {
|
|
$error = $response->json('error') ?? $json ?? $response->body();
|
|
|
|
$graphResponse = new GraphResponse(
|
|
success: false,
|
|
data: is_array($json) ? $json : [],
|
|
status: $response->status(),
|
|
errors: is_array($error) ? [$error] : [$error],
|
|
warnings: $warnings,
|
|
meta: $meta,
|
|
);
|
|
|
|
$this->logger->logResponse($action, $graphResponse, $meta);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
$graphResponse = new GraphResponse(
|
|
success: true,
|
|
data: $transform(is_array($json) ? $json : []),
|
|
status: $response->status(),
|
|
warnings: $warnings,
|
|
meta: $meta,
|
|
);
|
|
|
|
$this->logger->logResponse($action, $graphResponse, $meta);
|
|
|
|
return $graphResponse;
|
|
}
|
|
|
|
private function graphResponseFromException(RequestException|GraphException $exception, array $context = []): GraphResponse
|
|
{
|
|
if ($exception instanceof RequestException && $exception->response) {
|
|
$response = $exception->response;
|
|
$json = $response->json() ?? [];
|
|
$error = $response->json('error') ?? $json ?? $response->body();
|
|
|
|
return new GraphResponse(
|
|
success: false,
|
|
data: is_array($json) ? $json : [],
|
|
status: $response->status(),
|
|
errors: is_array($error) ? [$error] : [$error],
|
|
meta: $this->responseMeta($response, $context),
|
|
);
|
|
}
|
|
|
|
$contextualError = [];
|
|
|
|
if ($exception instanceof GraphException && ! empty($exception->context)) {
|
|
$contextualError = $exception->context + ['message' => $exception->getMessage()];
|
|
}
|
|
|
|
return new GraphResponse(
|
|
success: false,
|
|
data: [],
|
|
status: $exception instanceof GraphException ? $exception->status : null,
|
|
errors: [$contextualError ?: $exception->getMessage()],
|
|
meta: $context,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array{tenant:string,client_id:string,client_secret:string|null,scope:array<int,string>,token_url:string}
|
|
*/
|
|
private function resolveContext(array $options): array
|
|
{
|
|
$tenant = $options['tenant'] ?? $this->tenantId;
|
|
$clientId = $options['client_id'] ?? $this->clientId;
|
|
$clientSecret = $options['client_secret'] ?? $this->clientSecret;
|
|
$tokenUrlTemplate = $options['token_url'] ?? $this->tokenUrlTemplate;
|
|
$scopes = $this->normalizeScopes($options['scope'] ?? null);
|
|
|
|
return [
|
|
'tenant' => $tenant,
|
|
'client_id' => $clientId,
|
|
'client_secret' => $clientSecret,
|
|
'scope' => $scopes,
|
|
'token_url' => sprintf($tokenUrlTemplate, $tenant),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string>|string|null $scope
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeScopes(array|string|null $scope): array
|
|
{
|
|
if ($scope === null) {
|
|
return $this->defaultScopes;
|
|
}
|
|
|
|
if (is_string($scope)) {
|
|
$scope = array_values(array_filter(explode(' ', $scope)));
|
|
}
|
|
|
|
if (is_array($scope)) {
|
|
$scope = array_values(array_filter($scope, static fn ($value) => ! empty($value)));
|
|
}
|
|
|
|
return $scope ?: $this->defaultScopes;
|
|
}
|
|
|
|
private function endpointFor(string $policyType): string
|
|
{
|
|
$contractResource = $this->contracts->resourcePath($policyType);
|
|
if (is_string($contractResource) && $contractResource !== '') {
|
|
return $contractResource;
|
|
}
|
|
|
|
$builtinEndpoint = $this->builtinEndpointFor($policyType);
|
|
if ($builtinEndpoint !== null) {
|
|
return $builtinEndpoint;
|
|
}
|
|
|
|
$types = array_merge(
|
|
config('tenantpilot.supported_policy_types', []),
|
|
config('tenantpilot.foundation_types', []),
|
|
);
|
|
|
|
foreach ($types as $type) {
|
|
if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) {
|
|
return $type['endpoint'];
|
|
}
|
|
}
|
|
|
|
return 'deviceManagement/'.$policyType;
|
|
}
|
|
|
|
private function builtinEndpointFor(string $policyType): ?string
|
|
{
|
|
return match ($policyType) {
|
|
'settingsCatalogPolicy',
|
|
'endpointSecurityPolicy',
|
|
'securityBaselinePolicy' => 'deviceManagement/configurationPolicies',
|
|
default => null,
|
|
};
|
|
}
|
|
|
|
private function getAccessToken(array $context): string
|
|
{
|
|
$tenant = $context['tenant'] ?? $this->tenantId;
|
|
$clientId = $context['client_id'] ?? $this->clientId;
|
|
$scopes = $context['scope'] ?? $this->defaultScopes;
|
|
|
|
$cacheKey = sprintf('graph_token_%s_%s_%s', $tenant, $clientId, md5(implode('|', $scopes)));
|
|
|
|
if ($token = Cache::get($cacheKey)) {
|
|
return $token;
|
|
}
|
|
|
|
[$token, $ttl] = $this->requestAccessToken($context);
|
|
|
|
Cache::put($cacheKey, $token, now()->addSeconds($ttl));
|
|
|
|
return $token;
|
|
}
|
|
|
|
/**
|
|
* @return array{0:string,1:int} [token, ttlSeconds]
|
|
*/
|
|
private function requestAccessToken(array $context): array
|
|
{
|
|
$tenant = $context['tenant'] ?? $this->tenantId;
|
|
$clientId = $context['client_id'] ?? $this->clientId;
|
|
$clientSecret = $context['client_secret'] ?? $this->clientSecret;
|
|
$scopes = $context['scope'] ?? $this->defaultScopes;
|
|
$tokenUrl = $context['token_url'] ?? sprintf($this->tokenUrlTemplate, $tenant);
|
|
|
|
$response = Http::asForm()
|
|
->timeout($this->timeout)
|
|
->retry($this->retryTimes, $this->retrySleepMs)
|
|
->post($tokenUrl, [
|
|
'client_id' => $clientId,
|
|
'scope' => implode(' ', $scopes),
|
|
'client_secret' => $clientSecret,
|
|
'grant_type' => 'client_credentials',
|
|
]);
|
|
|
|
if ($response->failed()) {
|
|
$error = $response->json('error') ?? $response->json() ?? $response->body();
|
|
|
|
throw new GraphException(
|
|
'Unable to fetch Graph token',
|
|
$response->status(),
|
|
['error' => $error]
|
|
);
|
|
}
|
|
|
|
$data = $response->json();
|
|
$expiresIn = (int) ($data['expires_in'] ?? 300);
|
|
$ttl = max(60, $expiresIn - 60);
|
|
|
|
return [(string) $data['access_token'], $ttl];
|
|
}
|
|
|
|
private function buildFullPath(string $path, array $query = []): string
|
|
{
|
|
$path = ltrim($path, '/');
|
|
|
|
if (empty($query)) {
|
|
return $path;
|
|
}
|
|
|
|
return sprintf('%s?%s', $path, http_build_query($query));
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function responseMeta(Response $response, array $context = []): array
|
|
{
|
|
$requestId = $response->header('request-id') ?? $response->header('x-ms-request-id');
|
|
$clientRequestId = $response->header('client-request-id') ?? $context['client_request_id'] ?? null;
|
|
$bodyExcerpt = Str::limit((string) $response->body(), 2000);
|
|
$jsonError = $response->json('error');
|
|
$errorCode = is_array($jsonError) ? ($jsonError['code'] ?? null) : null;
|
|
$errorMessage = is_array($jsonError) ? ($jsonError['message'] ?? null) : (is_string($jsonError) ? $jsonError : null);
|
|
|
|
return array_filter([
|
|
'tenant' => $context['tenant'] ?? null,
|
|
'path' => $context['path'] ?? null,
|
|
'full_path' => $context['full_path'] ?? null,
|
|
'method' => $context['method'] ?? null,
|
|
'query' => $context['query'] ?? null,
|
|
'request_id' => $requestId,
|
|
'client_request_id' => $clientRequestId,
|
|
'body_excerpt' => $bodyExcerpt,
|
|
'error_code' => $errorCode,
|
|
'error_message' => $errorMessage,
|
|
], static fn ($value) => $value !== null && $value !== '');
|
|
}
|
|
}
|