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); $query = array_filter([ '$top' => $options['top'] ?? null, '$filter' => $options['filter'] ?? null, 'platform' => $options['platform'] ?? null, ], fn ($value) => $value !== null && $value !== ''); $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); return $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, ] ); } 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; } 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,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|string|null $scope * @return array */ 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 { $supported = config('tenantpilot.supported_policy_types', []); foreach ($supported as $type) { if (($type['type'] ?? null) === $policyType && ! empty($type['endpoint'])) { return $type['endpoint']; } } return 'deviceManagement/'.$policyType; } 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 */ 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 !== ''); } }