1
|
|
|
<?php |
2
|
|
|
|
3
|
|
|
declare(strict_types=1); |
4
|
|
|
|
5
|
|
|
/** |
6
|
|
|
* tubee |
7
|
|
|
* |
8
|
|
|
* @copyright Copryright (c) 2017-2019 gyselroth GmbH (https://gyselroth.com) |
9
|
|
|
* @license GPL-3.0 https://opensource.org/licenses/GPL-3.0 |
10
|
|
|
*/ |
11
|
|
|
|
12
|
|
|
namespace Tubee\Endpoint; |
13
|
|
|
|
14
|
|
|
use Generator; |
15
|
|
|
use GuzzleHttp\Client; |
16
|
|
|
use GuzzleHttp\Exception\RequestException; |
17
|
|
|
use Psr\Log\LoggerInterface; |
18
|
|
|
use Tubee\AttributeMap\AttributeMapInterface; |
19
|
|
|
use Tubee\Collection\CollectionInterface; |
20
|
|
|
use Tubee\Endpoint\MicrosoftGraph\Exception as GraphException; |
21
|
|
|
use Tubee\Endpoint\OdataRest\QueryTransformer; |
22
|
|
|
use Tubee\EndpointObject\EndpointObjectInterface; |
23
|
|
|
use Tubee\Workflow\Factory as WorkflowFactory; |
24
|
|
|
|
25
|
|
|
class MicrosoftGraph extends OdataRest |
26
|
|
|
{ |
27
|
|
|
/** |
28
|
|
|
* Kind. |
29
|
|
|
*/ |
30
|
|
|
public const KIND = 'MicrosoftGraphEndpoint'; |
31
|
|
|
|
32
|
|
|
/** |
33
|
|
|
* API Root Endpoint. |
34
|
|
|
*/ |
35
|
|
|
public const API_ENDPOINT = 'https://graph.microsoft.com/v1.0'; |
36
|
|
|
|
37
|
|
|
/** |
38
|
|
|
* Batch Endpoint. |
39
|
|
|
*/ |
40
|
|
|
public const BATCH_ENDPOINT = 'https://graph.microsoft.com/v1.0/$batch'; |
41
|
|
|
|
42
|
|
|
/** |
43
|
|
|
* Graph API batch size limit. |
44
|
|
|
*/ |
45
|
|
|
public const BATCH_SIZE = 20; |
46
|
|
|
|
47
|
|
|
/** |
48
|
|
|
* Init endpoint. |
49
|
|
|
*/ |
50
|
|
|
public function __construct(string $name, string $type, Client $client, CollectionInterface $collection, WorkflowFactory $workflow, LoggerInterface $logger, array $resource = []) |
51
|
|
|
{ |
52
|
|
|
$this->container = 'value'; |
53
|
|
|
parent::__construct($name, $type, $client, $collection, $workflow, $logger, $resource); |
54
|
|
|
} |
55
|
|
|
|
56
|
|
|
/** |
57
|
|
|
* {@inheritdoc} |
58
|
|
|
*/ |
59
|
|
|
public function transformQuery(?array $query = null) |
60
|
|
|
{ |
61
|
|
|
if ($this->filter_all !== null) { |
62
|
|
|
return QueryTransformer::transform($this->getFilterAll()); |
63
|
|
|
} |
64
|
|
|
if (!empty($query)) { |
65
|
|
|
if ($this->filter_all === null) { |
66
|
|
|
return QueryTransformer::transform($query); |
67
|
|
|
} |
68
|
|
|
|
69
|
|
|
return QueryTransformer::transform([ |
70
|
|
|
'$and' => [ |
71
|
|
|
$this->getFilterAll(), |
72
|
|
|
$query, |
73
|
|
|
], |
74
|
|
|
]); |
75
|
|
|
} |
76
|
|
|
|
77
|
|
|
return null; |
78
|
|
|
} |
79
|
|
|
|
80
|
|
|
/** |
81
|
|
|
* {@inheritdoc} |
82
|
|
|
*/ |
83
|
|
|
public function getAll(?array $query = null): Generator |
84
|
|
|
{ |
85
|
|
|
$options = $this->getRequestOptions(); |
86
|
|
|
$query = $this->transformQuery($query); |
87
|
|
|
$this->logGetAll($query); |
88
|
|
|
|
89
|
|
|
if ($query !== null) { |
90
|
|
|
$options['query']['$filter'] = $query; |
91
|
|
|
} |
92
|
|
|
|
93
|
|
|
$i = 0; |
94
|
|
|
$response = $this->client->get('', $options); |
95
|
|
|
$data = $this->getResponse($response); |
96
|
|
|
|
97
|
|
|
foreach ($data as $object) { |
98
|
|
|
if ($this->isGroupEndpoint()) { |
99
|
|
|
$object = array_merge($object, $this->fetchMembers($object['id'])); |
100
|
|
|
} |
101
|
|
|
|
102
|
|
|
yield $this->build($object); |
103
|
|
|
} |
104
|
|
|
|
105
|
|
|
return $i; |
106
|
|
|
} |
107
|
|
|
|
108
|
|
|
/** |
109
|
|
|
* {@inheritdoc} |
110
|
|
|
*/ |
111
|
|
|
public function create(AttributeMapInterface $map, array $object, bool $simulate = false): ?string |
112
|
|
|
{ |
113
|
|
|
$requests = []; |
114
|
|
|
$new = $object; |
115
|
|
|
|
116
|
|
|
unset($object['owners'], $object['members']); |
117
|
|
|
|
118
|
|
|
$id = parent::create($map, $object, $simulate); |
119
|
|
|
|
120
|
|
|
if ($this->isGroupEndpoint()) { |
121
|
|
|
$requests = $this->getMemberChangeBatchRequests($id, $new, []); |
122
|
|
|
} |
123
|
|
|
|
124
|
|
|
if ($this->isGroupEndpoint() && isset($object['resourceProvisioningOptions']) && in_array('Team', $object['resourceProvisioningOptions'])) { |
125
|
|
|
$requests[] = [ |
126
|
|
|
'id' => 'create-team', |
127
|
|
|
'url' => 'groups/'.$id.'/team', |
128
|
|
|
'method' => 'PUT', |
129
|
|
|
'body' => new \stdClass(), |
130
|
|
|
'headers' => [ |
131
|
|
|
'Content-Type' => 'application/json', |
132
|
|
|
], |
133
|
|
|
]; |
134
|
|
|
} |
135
|
|
|
|
136
|
|
|
if (count($requests) !== 0) { |
137
|
|
|
$this->batch($requests, $simulate); |
138
|
|
|
} |
139
|
|
|
|
140
|
|
|
return $id; |
141
|
|
|
} |
142
|
|
|
|
143
|
|
|
/** |
144
|
|
|
* {@inheritdoc} |
145
|
|
|
*/ |
146
|
|
|
public function change(AttributeMapInterface $map, array $diff, array $object, array $endpoint_object, bool $simulate = false): ?string |
147
|
|
|
{ |
148
|
|
|
$id = $this->getResourceId($object, $endpoint_object); |
149
|
|
|
$uri = $this->client->getConfig('base_uri').'/'.$id; |
150
|
|
|
$requests = []; |
151
|
|
|
|
152
|
|
|
if ($this->isGroupEndpoint()) { |
153
|
|
|
if (isset($object['resourceProvisioningOptions']) |
154
|
|
|
&& in_array('Team', $object['resourceProvisioningOptions']) |
155
|
|
|
&& isset($endpoint_object['resourceProvisioningOptions']) |
156
|
|
|
&& !in_array('Team', $endpoint_object['resourceProvisioningOptions'])) { |
157
|
|
|
$requests[] = [ |
158
|
|
|
'id' => 'create-team', |
159
|
|
|
'method' => 'PUT', |
160
|
|
|
'url' => '/groups/'.$id.'/team', |
161
|
|
|
'body' => new \stdClass(), |
162
|
|
|
'headers' => [ |
163
|
|
|
'Content-Type' => 'application/json', |
164
|
|
|
], |
165
|
|
|
]; |
166
|
|
|
} |
167
|
|
|
|
168
|
|
|
$requests = array_merge($requests, $this->getMemberChangeBatchRequests($id, $diff, $endpoint_object)); |
169
|
|
|
unset($diff['members'], $diff['owners']); |
170
|
|
|
} |
171
|
|
|
|
172
|
|
|
if (count($requests) === 0) { |
173
|
|
|
$this->logChange($uri, $diff); |
174
|
|
|
|
175
|
|
|
if (count($diff) !== 0 && $simulate === false) { |
176
|
|
|
$this->client->patch($uri, $this->getRequestOptions([ |
177
|
|
|
'json' => $diff, |
178
|
|
|
])); |
179
|
|
|
} |
180
|
|
|
} else { |
181
|
|
|
$request = []; |
182
|
|
|
|
183
|
|
|
if (count($diff) !== 0) { |
184
|
|
|
$request = [[ |
185
|
|
|
'id' => 'change', |
186
|
|
|
'method' => 'PATCH', |
187
|
|
|
'url' => substr($uri, strlen(self::API_ENDPOINT)), |
188
|
|
|
'body' => $diff, |
189
|
|
|
'headers' => [ |
190
|
|
|
'Content-Type' => 'application/json', |
191
|
|
|
], |
192
|
|
|
]]; |
193
|
|
|
} |
194
|
|
|
|
195
|
|
|
$this->logChange(self::BATCH_ENDPOINT, $requests); |
196
|
|
|
$this->batch(array_merge($request, $requests), $simulate); |
197
|
|
|
} |
198
|
|
|
|
199
|
|
|
return null; |
200
|
|
|
} |
201
|
|
|
|
202
|
|
|
/** |
203
|
|
|
* {@inheritdoc} |
204
|
|
|
*/ |
205
|
|
|
public function getOne(array $object, ?array $attributes = []): EndpointObjectInterface |
206
|
|
|
{ |
207
|
|
|
$filter = $this->transformQuery($this->getFilterOne($object)); |
208
|
|
|
$this->logGetOne($filter); |
209
|
|
|
|
210
|
|
|
$options = $this->getRequestOptions(); |
211
|
|
|
$options['query']['$filter'] = $filter; |
212
|
|
|
$attributes[] = $this->identifier; |
213
|
|
|
$options['query']['$select'] = join(',', $attributes); |
214
|
|
|
|
215
|
|
|
try { |
216
|
|
|
$result = $this->client->get('', $options); |
217
|
|
|
$data = $this->getResponse($result); |
218
|
|
|
} catch (RequestException $e) { |
|
|
|
|
219
|
|
|
if ($e->getCode() === 404) { |
220
|
|
|
throw new Exception\ObjectNotFound('no object found with filter '.$filter); |
221
|
|
|
} |
222
|
|
|
|
223
|
|
|
throw $e; |
224
|
|
|
} |
225
|
|
|
|
226
|
|
|
if (count($data) > 1) { |
227
|
|
|
throw new Exception\ObjectMultipleFound('found more than one object with filter '.$filter); |
228
|
|
|
} |
229
|
|
|
if (count($data) === 0) { |
230
|
|
|
throw new Exception\ObjectNotFound('no object found with filter '.$filter); |
231
|
|
|
} |
232
|
|
|
|
233
|
|
|
$data = array_shift($data); |
234
|
|
|
|
235
|
|
|
if ($this->isGroupEndpoint()) { |
236
|
|
|
$data = array_merge_recursive($data, $this->fetchMembers($data['id'])); |
237
|
|
|
} |
238
|
|
|
|
239
|
|
|
return $this->build($data); |
240
|
|
|
} |
241
|
|
|
|
242
|
|
|
/** |
243
|
|
|
* Check if endpoint holds group objects. |
244
|
|
|
*/ |
245
|
|
|
protected function isGroupEndpoint(): bool |
246
|
|
|
{ |
247
|
|
|
$url = (string) $this->client->getConfig('base_uri'); |
248
|
|
|
|
249
|
|
|
if ($url === 'https://graph.microsoft.com/v1.0/groups' || $url === 'https://graph.microsoft.com/beta/groups') { |
250
|
|
|
return true; |
251
|
|
|
} |
252
|
|
|
|
253
|
|
|
return false; |
254
|
|
|
} |
255
|
|
|
|
256
|
|
|
/** |
257
|
|
|
* Get member batch requests. |
258
|
|
|
*/ |
259
|
|
|
protected function getMemberChangeBatchRequests(string $id, array $diff, array $endpoint_object): array |
260
|
|
|
{ |
261
|
|
|
$requests = []; |
262
|
|
|
|
263
|
|
|
foreach (['members', 'owners'] as $type) { |
264
|
|
|
if (!isset($diff[$type])) { |
265
|
|
|
$this->logger->info('attribute [{attribute}] not in diff (no update required), skip group member batching', [ |
266
|
|
|
'category' => get_class($this), |
267
|
|
|
'attribute' => $type, |
268
|
|
|
]); |
269
|
|
|
|
270
|
|
|
continue; |
271
|
|
|
} |
272
|
|
|
|
273
|
|
|
$add = $diff[$type]; |
274
|
|
|
$remove = []; |
275
|
|
|
|
276
|
|
|
if (isset($endpoint_object[$type])) { |
277
|
|
|
$add = array_diff($diff[$type], $endpoint_object[$type]); |
278
|
|
|
$remove = array_diff($endpoint_object[$type], $diff[$type]); |
279
|
|
|
} |
280
|
|
|
|
281
|
|
|
foreach ($add as $member) { |
282
|
|
|
$requests[] = [ |
283
|
|
|
'id' => 'add-'.$type.'-'.$member, |
284
|
|
|
'method' => 'POST', |
285
|
|
|
'url' => '/groups/'.$id.'/'.$type.'/$ref', |
286
|
|
|
'body' => [ |
287
|
|
|
'@odata.id' => self::API_ENDPOINT.'/directoryObjects/'.$member, |
288
|
|
|
], |
289
|
|
|
'headers' => [ |
290
|
|
|
'Content-Type' => 'application/json', |
291
|
|
|
], |
292
|
|
|
]; |
293
|
|
|
} |
294
|
|
|
|
295
|
|
|
foreach ($remove as $member) { |
296
|
|
|
$requests[] = [ |
297
|
|
|
'id' => 'remove-'.$type.'-'.$member, |
298
|
|
|
'method' => 'DELETE', |
299
|
|
|
'url' => '/groups/'.$id.'/'.$type.'/'.$member.'/$ref', |
300
|
|
|
'headers' => [ |
301
|
|
|
'Content-Type' => 'application/json', |
302
|
|
|
], |
303
|
|
|
]; |
304
|
|
|
} |
305
|
|
|
} |
306
|
|
|
|
307
|
|
|
return $requests; |
308
|
|
|
} |
309
|
|
|
|
310
|
|
|
/** |
311
|
|
|
* Get object url. |
312
|
|
|
*/ |
313
|
|
|
protected function getObjectUrl(array $objects): array |
314
|
|
|
{ |
315
|
|
|
foreach ($objects as &$object) { |
316
|
|
|
$object = self::API_ENDPOINT.'/directoryObjects/'.$object; |
317
|
|
|
} |
318
|
|
|
|
319
|
|
|
return $objects; |
320
|
|
|
} |
321
|
|
|
|
322
|
|
|
/** |
323
|
|
|
* Batch call. |
324
|
|
|
*/ |
325
|
|
|
protected function batch(array $requests, bool $simulate = false, bool $throw = true): array |
326
|
|
|
{ |
327
|
|
|
$results = []; |
328
|
|
|
|
329
|
|
|
foreach (array_chunk($requests, self::BATCH_SIZE) as $chunk) { |
330
|
|
|
$chunk = ['requests' => $chunk]; |
331
|
|
|
|
332
|
|
|
$this->logger->debug('batch request chunk [{chunk}] ', [ |
333
|
|
|
'category' => get_class($this), |
334
|
|
|
'chunk' => $chunk, |
335
|
|
|
]); |
336
|
|
|
|
337
|
|
|
if ($simulate === false) { |
338
|
|
|
$response = $this->client->post(self::BATCH_ENDPOINT, $this->getRequestOptions([ |
339
|
|
|
'json' => $chunk, |
340
|
|
|
])); |
341
|
|
|
|
342
|
|
|
$results = array_merge($results, $this->validateBatchResponse($response, $throw)); |
343
|
|
|
} |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
return $results; |
347
|
|
|
} |
348
|
|
|
|
349
|
|
|
/** |
350
|
|
|
* Validate batch request. |
351
|
|
|
*/ |
352
|
|
|
protected function validateBatchResponse($response, bool $throw = true): array |
353
|
|
|
{ |
354
|
|
|
$data = json_decode($response->getBody()->getContents(), true); |
355
|
|
|
|
356
|
|
|
if (!isset($data['responses'])) { |
357
|
|
|
throw new GraphException\BatchRequestFailed('invalid batch response data, expected responses list'); |
358
|
|
|
} |
359
|
|
|
|
360
|
|
|
foreach ($data['responses'] as $result) { |
361
|
|
|
$this->logger->debug('validate batch request id [{id}]', [ |
362
|
|
|
'category' => get_class($this), |
363
|
|
|
'id' => $result['id'], |
364
|
|
|
]); |
365
|
|
|
|
366
|
|
|
if (isset($result['body']['error']) && $throw === true) { |
367
|
|
|
throw new GraphException\BatchRequestFailed('batch request part failed with error '.$result['body']['error']['message'].' and http code '.$result['status']); |
368
|
|
|
} |
369
|
|
|
|
370
|
|
|
$this->logger->debug('batch request part [{request}] succeeded with http code [{status}]', [ |
371
|
|
|
'category' => get_class($this), |
372
|
|
|
'request' => $result['id'] ?? null, |
373
|
|
|
'status' => $result['status'] ?? null, |
374
|
|
|
]); |
375
|
|
|
} |
376
|
|
|
|
377
|
|
|
return $data['responses']; |
378
|
|
|
} |
379
|
|
|
|
380
|
|
|
/** |
381
|
|
|
* Fetch members. |
382
|
|
|
*/ |
383
|
|
|
protected function fetchMembers(string $id): array |
384
|
|
|
{ |
385
|
|
|
$requests = [ |
386
|
|
|
[ |
387
|
|
|
'id' => 'members', |
388
|
|
|
'method' => 'GET', |
389
|
|
|
'url' => '/groups/'.$id.'/members', |
390
|
|
|
], |
391
|
|
|
[ |
392
|
|
|
'id' => 'owners', |
393
|
|
|
'method' => 'GET', |
394
|
|
|
'url' => '/groups/'.$id.'/owners', |
395
|
|
|
], |
396
|
|
|
[ |
397
|
|
|
'id' => 'team', |
398
|
|
|
'method' => 'GET', |
399
|
|
|
'url' => '/groups/'.$id.'/team', |
400
|
|
|
], |
401
|
|
|
]; |
402
|
|
|
|
403
|
|
|
$options = $this->getRequestOptions(); |
|
|
|
|
404
|
|
|
$this->logger->debug('fetch group members from batch request [{requests}]', [ |
405
|
|
|
'class' => get_class($this), |
406
|
|
|
'requests' => $requests, |
407
|
|
|
]); |
408
|
|
|
|
409
|
|
|
$data = $this->batch($requests, false, false); |
410
|
|
|
|
411
|
|
|
$set = [ |
412
|
|
|
'owners' => [], |
413
|
|
|
'members' => [], |
414
|
|
|
'resourceProvisioningOptions' => [], |
415
|
|
|
]; |
416
|
|
|
|
417
|
|
|
foreach ($data as $response) { |
418
|
|
|
switch ($response['id']) { |
419
|
|
|
case 'owners': |
420
|
|
|
case 'members': |
421
|
|
|
default: |
422
|
|
|
foreach ($response['body'][$this->container] as $record) { |
423
|
|
|
$set[$response['id']][] = $record['id']; |
424
|
|
|
} |
425
|
|
|
|
426
|
|
|
$id = $response['id']; |
427
|
|
|
$response = $response['body']; |
428
|
|
|
while (isset($response['@odata.nextLink'])) { |
429
|
|
|
$options = $this->getRequestOptions(); |
430
|
|
|
$response = $this->decodeResponse($this->client->get($response['@odata.nextLink'], $options)); |
431
|
|
|
$set[$id] = array_merge($set[$id], array_column($response[$this->container], 'id')); |
432
|
|
|
} |
433
|
|
|
|
434
|
|
|
break; |
435
|
|
|
case 'team': |
|
|
|
|
436
|
|
|
if ($response['status'] === 200) { |
437
|
|
|
$set['resourceProvisioningOptions'][] = 'Team'; |
438
|
|
|
} |
439
|
|
|
|
440
|
|
|
break; |
441
|
|
|
} |
442
|
|
|
} |
443
|
|
|
|
444
|
|
|
return $set; |
445
|
|
|
} |
446
|
|
|
} |
447
|
|
|
|
Scrutinizer analyzes your
composer.json
/composer.lock
file if available to determine the classes, and functions that are defined by your dependencies.It seems like the listed class was neither found in your dependencies, nor was it found in the analyzed files in your repository. If you are using some other form of dependency management, you might want to disable this analysis.