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); $this->logger->logRequest('list_policies', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'tenant' => $context['tenant'], ]); $response = $this->send('GET', $endpoint, ['query' => $query], $context); return $this->toGraphResponse( action: 'list_policies', response: $response, transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []) ); } public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse { $endpoint = $this->endpointFor($policyType).'/'.urlencode($policyId); $query = array_filter([ '$select' => $options['select'] ?? null, ], fn ($value) => $value !== null && $value !== ''); $context = $this->resolveContext($options); $this->logger->logRequest('get_policy', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'policy_id' => $policyId, 'tenant' => $context['tenant'], ]); $response = $this->send('GET', $endpoint, ['query' => $query], $context); return $this->toGraphResponse( action: 'get_policy', response: $response, transform: fn (array $json) => ['payload' => $json] ); } public function getOrganization(array $options = []): GraphResponse { $context = $this->resolveContext($options); $endpoint = 'organization'; $this->logger->logRequest('get_organization', [ 'endpoint' => $endpoint, 'tenant' => $context['tenant'], ]); $response = $this->send('GET', $endpoint, [], $context); return $this->toGraphResponse( action: 'get_organization', response: $response, transform: fn (array $json) => $json['value'][0] ?? $json ); } 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); $this->logger->logRequest('apply_policy', [ 'endpoint' => $endpoint, 'policy_type' => $policyType, 'policy_id' => $policyId, 'tenant' => $context['tenant'], ]); $response = $this->send($method, $endpoint, ['json' => $payload], $context); return $this->toGraphResponse( action: 'apply_policy', response: $response, transform: fn (array $json) => $json ); } private function send(string $method, string $path, array $options = [], array $context = []): Response { $context = $context ?: $this->resolveContext([]); $token = $this->getAccessToken($context); $pending = Http::baseUrl($this->baseUrl) ->acceptJson() ->timeout($this->timeout) ->retry($this->retryTimes, $this->retrySleepMs) ->withToken($token); 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 (Throwable $throwable) { throw GraphErrorMapper::fromThrowable( $throwable, ['path' => $path, 'method' => $method, 'tenant' => $context['tenant'] ?? null] ); } $this->logger->logResponse($method.' '.$path, new GraphResponse( success: $response->successful(), data: [], status: $response->status(), errors: $response->json('error') ? [$response->json('error')] : [], ), ['tenant' => $context['tenant'] ?? null]); return $response; } private function toGraphResponse(string $action, Response $response, callable $transform): GraphResponse { if ($response->failed()) { $error = $response->json('error') ?? $response->json() ?? $response->body(); return new GraphResponse( success: false, data: [], status: $response->status(), errors: is_array($error) ? [$error] : [$error], ); } $json = $response->json() ?? []; return new GraphResponse( success: true, data: $transform(is_array($json) ? $json : []), status: $response->status(), ); } /** * @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]; } }