Skip to content

Commit f72d84d

Browse files
Merge pull request #135 from limosa-io/feature/cross-resource-meta-filter
Support cross-resource meta filters
2 parents 88999ff + 2cb0117 commit f72d84d

File tree

12 files changed

+368
-9
lines changed

12 files changed

+368
-9
lines changed

src/Attribute/Attribute.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ public function getValidations()
6565
*/
6666
public function generateSchema()
6767
{
68-
return [
68+
$schema = [
6969
'name' => $this->name,
7070
'type' => $this->getType(),
7171
'mutability' => $this->mutability,
@@ -75,6 +75,12 @@ public function generateSchema()
7575
'multiValued' => $this->getMultiValued(),
7676
'caseExact' => false
7777
];
78+
79+
if ($this->description !== null) {
80+
$schema['description'] = $this->description;
81+
}
82+
83+
return $schema;
7884
}
7985

8086
public function setMultiValued($multiValued)
@@ -164,6 +170,13 @@ public function setParent($parent)
164170
return $this;
165171
}
166172

173+
public function setDescription(?string $description)
174+
{
175+
$this->description = $description;
176+
177+
return $this;
178+
}
179+
167180
protected function isRequested($attributes)
168181
{
169182
return empty($attributes) || in_array($this->name, $attributes) || in_array($this->getFullKey(), $attributes) || ($this->parent != null && $this->parent->isRequested($attributes));

src/Attribute/Collection.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99

1010
class Collection extends AbstractComplex
1111
{
12+
protected $type = 'complex';
13+
1214
protected $attribute;
1315
protected $multiValued = true;
1416

src/Attribute/Constant.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ArieTimmerman\Laravel\SCIMServer\Attribute;
44

55
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
6+
use Illuminate\Database\Eloquent\Builder;
67
use Illuminate\Database\Eloquent\Model;
78
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
89

@@ -42,4 +43,44 @@ public function patch($operation, $value, Model &$object, ?Path $path = null)
4243
{
4344
throw new SCIMException('Patch operation not supported for constant attributes');
4445
}
46+
47+
public function applyComparison(Builder &$query, Path $path, $parentAttribute = null)
48+
{
49+
$operator = $path->node->operator ?? null;
50+
$value = $path->node->compareValue ?? null;
51+
52+
if ($operator === null) {
53+
throw new SCIMException('Invalid comparison on constant attribute');
54+
}
55+
56+
$constantValue = $this->value;
57+
58+
$matches = $this->valuesAreEqual($constantValue, $value);
59+
60+
switch ($operator) {
61+
case 'pr':
62+
$query->whereRaw('1 = 1');
63+
return;
64+
65+
case 'eq':
66+
$query->whereRaw($matches ? '1 = 1' : '1 = 0');
67+
return;
68+
69+
case 'ne':
70+
$query->whereRaw($matches ? '1 = 0' : '1 = 1');
71+
return;
72+
73+
default:
74+
throw new SCIMException(sprintf('Operator "%s" not supported for constant attributes', $operator));
75+
}
76+
}
77+
78+
private function valuesAreEqual($constantValue, $compareValue): bool
79+
{
80+
if (is_string($constantValue) && is_string($compareValue)) {
81+
return strcasecmp($constantValue, $compareValue) === 0;
82+
}
83+
84+
return $constantValue === $compareValue;
85+
}
4586
}

src/Filter/Ast/Disjunction.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
namespace ArieTimmerman\Laravel\SCIMServer\Filter\Ast;
44

5-
class Disjunction extends Filter
5+
class Disjunction extends Term
66
{
77
/** @var Term[] */
88
private array $terms = [];

src/Http/Controllers/ResourceController.php

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use ArieTimmerman\Laravel\SCIMServer\Helper;
88
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
99
use ArieTimmerman\Laravel\SCIMServer\ResourceType;
10+
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
1011
use Illuminate\Database\Eloquent\Model;
1112
use ArieTimmerman\Laravel\SCIMServer\Events\Delete;
1213
use ArieTimmerman\Laravel\SCIMServer\Events\Get;
@@ -304,6 +305,24 @@ function (Builder $query) use ($filter, $resourceType) {
304305
);
305306
}
306307

308+
public function crossResourceSearch(Request $request, PolicyDecisionPoint $pdp, SCIMConfig $config)
309+
{
310+
$input = $request->json()->all();
311+
312+
if (!is_array($input) || !isset($input['schemas']) || !in_array('urn:ietf:params:scim:api:messages:2.0:SearchRequest', $input['schemas'])) {
313+
throw (new SCIMException('Invalid schema. MUST be "urn:ietf:params:scim:api:messages:2.0:SearchRequest"'))->setCode(400);
314+
}
315+
316+
$request->replace($input);
317+
318+
return $this->runCrossResourceQuery($request, $config);
319+
}
320+
321+
public function crossResourceIndex(Request $request, PolicyDecisionPoint $pdp, SCIMConfig $config)
322+
{
323+
return $this->runCrossResourceQuery($request, $config);
324+
}
325+
307326
public function search(Request $request, PolicyDecisionPoint $pdp, ResourceType $resourceType){
308327

309328
$input = $request->json()->all();
@@ -319,6 +338,126 @@ public function search(Request $request, PolicyDecisionPoint $pdp, ResourceType
319338
return $this->index($request, $pdp, $resourceType);
320339
}
321340

341+
protected function runCrossResourceQuery(Request $request, SCIMConfig $config): ListResponse
342+
{
343+
if ($request->has('cursor')) {
344+
throw (new SCIMException('Cursor pagination is not supported for cross-resource search'))->setCode(400)->setScimType('invalidCursor');
345+
}
346+
347+
[$attributes, $excludedAttributes] = $this->resolveAttributeParameters($request);
348+
349+
$count = min(max(0, intVal($request->input('count', config('scim.pagination.defaultPageSize')))), config('scim.pagination.maxPageSize'));
350+
$startIndex = max(1, intVal($request->input('startIndex', 1)));
351+
352+
$resourceTypes = $this->resolveResourceTypesForSearch($config);
353+
354+
if (empty($resourceTypes)) {
355+
return new ListResponse(collect(), $startIndex, 0, $attributes, $excludedAttributes);
356+
}
357+
358+
if ($request->filled('sortBy') && count($resourceTypes) > 1) {
359+
throw (new SCIMException('sortBy is only supported when a single resourceType is requested'))->setCode(400)->setScimType('invalidValue');
360+
}
361+
362+
$sortAttribute = null;
363+
$sortDirection = $request->input('sortOrder') === 'descending' ? 'desc' : 'asc';
364+
365+
if ($request->filled('sortBy')) {
366+
$sortAttribute = $resourceTypes[0]->getMapping()->getSortAttributeByPath(ParserParser::parse($request->input('sortBy')));
367+
}
368+
369+
$filter = $request->input('filter');
370+
371+
$resources = collect();
372+
$offset = $startIndex - 1;
373+
$remaining = $count;
374+
$perTypeTotals = [];
375+
$totalResults = 0;
376+
377+
$applyFilter = function (Builder $query, ResourceType $resourceType) use ($filter) {
378+
if ($filter === null) {
379+
return;
380+
}
381+
382+
try {
383+
Helper::scimFilterToLaravelQuery($resourceType, $query, ParserParser::parseFilter($filter));
384+
} catch (ParserFilterException $e) {
385+
throw (new SCIMException($e->getMessage()))->setCode(400)->setScimType('invalidFilter');
386+
}
387+
};
388+
389+
foreach ($resourceTypes as $resourceType) {
390+
$countQuery = $resourceType->getQuery();
391+
$applyFilter($countQuery, $resourceType);
392+
393+
$typeTotal = $countQuery->count();
394+
$perTypeTotals[] = [$resourceType, $typeTotal];
395+
$totalResults += $typeTotal;
396+
}
397+
398+
foreach ($perTypeTotals as [$resourceType, $typeTotal]) {
399+
if ($offset >= $typeTotal) {
400+
$offset -= $typeTotal;
401+
continue;
402+
}
403+
404+
if ($remaining === 0) {
405+
break;
406+
}
407+
408+
$dataQuery = $resourceType->getQuery();
409+
$applyFilter($dataQuery, $resourceType);
410+
411+
$dataQuery = $dataQuery->with($resourceType->getWithRelations());
412+
413+
if ($sortAttribute !== null) {
414+
$dataQuery = $dataQuery->orderBy($sortAttribute, $sortDirection);
415+
}
416+
417+
if ($offset > 0) {
418+
$dataQuery = $dataQuery->skip($offset);
419+
}
420+
421+
if ($remaining > 0) {
422+
$dataQuery = $dataQuery->take($remaining);
423+
}
424+
425+
$items = $dataQuery->get();
426+
427+
$offset = 0;
428+
$remaining -= $items->count();
429+
430+
foreach ($items as $item) {
431+
$resources->push(
432+
Helper::objectToSCIMArray($item, $resourceType, $attributes, $excludedAttributes)
433+
);
434+
}
435+
436+
if ($remaining <= 0) {
437+
break;
438+
}
439+
}
440+
441+
return new ListResponse($resources, $startIndex, $totalResults, $attributes, $excludedAttributes);
442+
}
443+
444+
protected function resolveResourceTypesForSearch(SCIMConfig $config): array
445+
{
446+
$configurations = $config->getConfig();
447+
448+
if (empty($configurations)) {
449+
return [];
450+
}
451+
452+
$resourceTypes = [];
453+
454+
foreach ($configurations as $name => $configuration) {
455+
$resourceTypes[] = new ResourceType($name, $configuration);
456+
}
457+
458+
return $resourceTypes;
459+
}
460+
322461
protected function respondWithResource(Request $request, ResourceType $resourceType, Model $resourceObject, int $status = 200)
323462
{
324463
[$attributes, $excludedAttributes] = $this->resolveAttributeParameters($request);

src/RouteProvider.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ public static function publicRoutes(array $options = [])
7171

7272
private static function allRoutes(array $options = [])
7373
{
74-
// TODO: Implement POST /.search for cross-resource queries per RFC 7644 §3.4.3.
75-
Route::post('.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'notImplemented']);
74+
Route::get('/', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceIndex']);
75+
Route::get('', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceIndex']);
76+
Route::post('.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceSearch']);
7677

7778
Route::post("/Bulk", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\BulkController::class, 'processBulkRequest']);
7879

src/SCIMConfig.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,9 @@ public function remove($value, &$object, $path = null)
184184
$fail('The name has already been taken.');
185185
}
186186
}),
187-
(new MutableCollection('members'))->withSubAttributes(
187+
(new MutableCollection('members'))
188+
->setDescription('A list of members of the Group.')
189+
->withSubAttributes(
188190
eloquent('value', 'id')->ensure('required'),
189191
(new class ('$ref') extends Eloquent {
190192
protected function doRead(&$object, $attributes = [])

0 commit comments

Comments
 (0)