diff --git a/src/Attribute/Attribute.php b/src/Attribute/Attribute.php index b0b9911..047c62f 100644 --- a/src/Attribute/Attribute.php +++ b/src/Attribute/Attribute.php @@ -65,7 +65,7 @@ public function getValidations() */ public function generateSchema() { - return [ + $schema = [ 'name' => $this->name, 'type' => $this->getType(), 'mutability' => $this->mutability, @@ -75,6 +75,12 @@ public function generateSchema() 'multiValued' => $this->getMultiValued(), 'caseExact' => false ]; + + if ($this->description !== null) { + $schema['description'] = $this->description; + } + + return $schema; } public function setMultiValued($multiValued) @@ -164,6 +170,13 @@ public function setParent($parent) return $this; } + public function setDescription(?string $description) + { + $this->description = $description; + + return $this; + } + protected function isRequested($attributes) { return empty($attributes) || in_array($this->name, $attributes) || in_array($this->getFullKey(), $attributes) || ($this->parent != null && $this->parent->isRequested($attributes)); diff --git a/src/Attribute/Collection.php b/src/Attribute/Collection.php index b7c4fb5..beeb78f 100644 --- a/src/Attribute/Collection.php +++ b/src/Attribute/Collection.php @@ -9,6 +9,8 @@ class Collection extends AbstractComplex { + protected $type = 'complex'; + protected $attribute; protected $multiValued = true; diff --git a/src/Attribute/Constant.php b/src/Attribute/Constant.php index 6ab312c..5fa0778 100644 --- a/src/Attribute/Constant.php +++ b/src/Attribute/Constant.php @@ -3,6 +3,7 @@ namespace ArieTimmerman\Laravel\SCIMServer\Attribute; use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use ArieTimmerman\Laravel\SCIMServer\Parser\Path; @@ -42,4 +43,44 @@ public function patch($operation, $value, Model &$object, ?Path $path = null) { throw new SCIMException('Patch operation not supported for constant attributes'); } + + public function applyComparison(Builder &$query, Path $path, $parentAttribute = null) + { + $operator = $path->node->operator ?? null; + $value = $path->node->compareValue ?? null; + + if ($operator === null) { + throw new SCIMException('Invalid comparison on constant attribute'); + } + + $constantValue = $this->value; + + $matches = $this->valuesAreEqual($constantValue, $value); + + switch ($operator) { + case 'pr': + $query->whereRaw('1 = 1'); + return; + + case 'eq': + $query->whereRaw($matches ? '1 = 1' : '1 = 0'); + return; + + case 'ne': + $query->whereRaw($matches ? '1 = 0' : '1 = 1'); + return; + + default: + throw new SCIMException(sprintf('Operator "%s" not supported for constant attributes', $operator)); + } + } + + private function valuesAreEqual($constantValue, $compareValue): bool + { + if (is_string($constantValue) && is_string($compareValue)) { + return strcasecmp($constantValue, $compareValue) === 0; + } + + return $constantValue === $compareValue; + } } diff --git a/src/Filter/Ast/Disjunction.php b/src/Filter/Ast/Disjunction.php index 3a3cf05..1cdf666 100644 --- a/src/Filter/Ast/Disjunction.php +++ b/src/Filter/Ast/Disjunction.php @@ -2,7 +2,7 @@ namespace ArieTimmerman\Laravel\SCIMServer\Filter\Ast; -class Disjunction extends Filter +class Disjunction extends Term { /** @var Term[] */ private array $terms = []; diff --git a/src/Http/Controllers/ResourceController.php b/src/Http/Controllers/ResourceController.php index 20ce83f..7cb2830 100644 --- a/src/Http/Controllers/ResourceController.php +++ b/src/Http/Controllers/ResourceController.php @@ -7,6 +7,7 @@ use ArieTimmerman\Laravel\SCIMServer\Helper; use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException; use ArieTimmerman\Laravel\SCIMServer\ResourceType; +use ArieTimmerman\Laravel\SCIMServer\SCIMConfig; use Illuminate\Database\Eloquent\Model; use ArieTimmerman\Laravel\SCIMServer\Events\Delete; use ArieTimmerman\Laravel\SCIMServer\Events\Get; @@ -304,6 +305,24 @@ function (Builder $query) use ($filter, $resourceType) { ); } + public function crossResourceSearch(Request $request, PolicyDecisionPoint $pdp, SCIMConfig $config) + { + $input = $request->json()->all(); + + if (!is_array($input) || !isset($input['schemas']) || !in_array('urn:ietf:params:scim:api:messages:2.0:SearchRequest', $input['schemas'])) { + throw (new SCIMException('Invalid schema. MUST be "urn:ietf:params:scim:api:messages:2.0:SearchRequest"'))->setCode(400); + } + + $request->replace($input); + + return $this->runCrossResourceQuery($request, $config); + } + + public function crossResourceIndex(Request $request, PolicyDecisionPoint $pdp, SCIMConfig $config) + { + return $this->runCrossResourceQuery($request, $config); + } + public function search(Request $request, PolicyDecisionPoint $pdp, ResourceType $resourceType){ $input = $request->json()->all(); @@ -319,6 +338,126 @@ public function search(Request $request, PolicyDecisionPoint $pdp, ResourceType return $this->index($request, $pdp, $resourceType); } + protected function runCrossResourceQuery(Request $request, SCIMConfig $config): ListResponse + { + if ($request->has('cursor')) { + throw (new SCIMException('Cursor pagination is not supported for cross-resource search'))->setCode(400)->setScimType('invalidCursor'); + } + + [$attributes, $excludedAttributes] = $this->resolveAttributeParameters($request); + + $count = min(max(0, intVal($request->input('count', config('scim.pagination.defaultPageSize')))), config('scim.pagination.maxPageSize')); + $startIndex = max(1, intVal($request->input('startIndex', 1))); + + $resourceTypes = $this->resolveResourceTypesForSearch($config); + + if (empty($resourceTypes)) { + return new ListResponse(collect(), $startIndex, 0, $attributes, $excludedAttributes); + } + + if ($request->filled('sortBy') && count($resourceTypes) > 1) { + throw (new SCIMException('sortBy is only supported when a single resourceType is requested'))->setCode(400)->setScimType('invalidValue'); + } + + $sortAttribute = null; + $sortDirection = $request->input('sortOrder') === 'descending' ? 'desc' : 'asc'; + + if ($request->filled('sortBy')) { + $sortAttribute = $resourceTypes[0]->getMapping()->getSortAttributeByPath(ParserParser::parse($request->input('sortBy'))); + } + + $filter = $request->input('filter'); + + $resources = collect(); + $offset = $startIndex - 1; + $remaining = $count; + $perTypeTotals = []; + $totalResults = 0; + + $applyFilter = function (Builder $query, ResourceType $resourceType) use ($filter) { + if ($filter === null) { + return; + } + + try { + Helper::scimFilterToLaravelQuery($resourceType, $query, ParserParser::parseFilter($filter)); + } catch (ParserFilterException $e) { + throw (new SCIMException($e->getMessage()))->setCode(400)->setScimType('invalidFilter'); + } + }; + + foreach ($resourceTypes as $resourceType) { + $countQuery = $resourceType->getQuery(); + $applyFilter($countQuery, $resourceType); + + $typeTotal = $countQuery->count(); + $perTypeTotals[] = [$resourceType, $typeTotal]; + $totalResults += $typeTotal; + } + + foreach ($perTypeTotals as [$resourceType, $typeTotal]) { + if ($offset >= $typeTotal) { + $offset -= $typeTotal; + continue; + } + + if ($remaining === 0) { + break; + } + + $dataQuery = $resourceType->getQuery(); + $applyFilter($dataQuery, $resourceType); + + $dataQuery = $dataQuery->with($resourceType->getWithRelations()); + + if ($sortAttribute !== null) { + $dataQuery = $dataQuery->orderBy($sortAttribute, $sortDirection); + } + + if ($offset > 0) { + $dataQuery = $dataQuery->skip($offset); + } + + if ($remaining > 0) { + $dataQuery = $dataQuery->take($remaining); + } + + $items = $dataQuery->get(); + + $offset = 0; + $remaining -= $items->count(); + + foreach ($items as $item) { + $resources->push( + Helper::objectToSCIMArray($item, $resourceType, $attributes, $excludedAttributes) + ); + } + + if ($remaining <= 0) { + break; + } + } + + return new ListResponse($resources, $startIndex, $totalResults, $attributes, $excludedAttributes); + } + + protected function resolveResourceTypesForSearch(SCIMConfig $config): array + { + $configurations = $config->getConfig(); + + if (empty($configurations)) { + return []; + } + + $resourceTypes = []; + + foreach ($configurations as $name => $configuration) { + $resourceTypes[] = new ResourceType($name, $configuration); + } + + return $resourceTypes; + } + protected function respondWithResource(Request $request, ResourceType $resourceType, Model $resourceObject, int $status = 200) { [$attributes, $excludedAttributes] = $this->resolveAttributeParameters($request); diff --git a/src/RouteProvider.php b/src/RouteProvider.php index 16408ca..26f8442 100644 --- a/src/RouteProvider.php +++ b/src/RouteProvider.php @@ -71,8 +71,9 @@ public static function publicRoutes(array $options = []) private static function allRoutes(array $options = []) { - // TODO: Implement POST /.search for cross-resource queries per RFC 7644 ยง3.4.3. - Route::post('.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'notImplemented']); + Route::get('/', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceIndex']); + Route::get('', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceIndex']); + Route::post('.search', [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\ResourceController::class, 'crossResourceSearch']); Route::post("/Bulk", [\ArieTimmerman\Laravel\SCIMServer\Http\Controllers\BulkController::class, 'processBulkRequest']); diff --git a/src/SCIMConfig.php b/src/SCIMConfig.php index e8931db..8a3d63c 100644 --- a/src/SCIMConfig.php +++ b/src/SCIMConfig.php @@ -184,7 +184,9 @@ public function remove($value, &$object, $path = null) $fail('The name has already been taken.'); } }), - (new MutableCollection('members'))->withSubAttributes( + (new MutableCollection('members')) + ->setDescription('A list of members of the Group.') + ->withSubAttributes( eloquent('value', 'id')->ensure('required'), (new class ('$ref') extends Eloquent { protected function doRead(&$object, $attributes = []) diff --git a/tests/CrossResourceSearchTest.php b/tests/CrossResourceSearchTest.php new file mode 100644 index 0000000..250ea7d --- /dev/null +++ b/tests/CrossResourceSearchTest.php @@ -0,0 +1,138 @@ +get('/scim/v2'); + + $response->assertStatus(200); + $response->assertJson([ + 'totalResults' => 200, + 'itemsPerPage' => 10, + 'startIndex' => 1, + ]); + + $resources = $response->json('Resources'); + + $this->assertCount(10, $resources); + $this->assertEquals('User', $resources[0]['meta']['resourceType']); + } + + public function testIndexSupportsStartIndexAcrossResources() + { + $response = $this->get('/scim/v2?startIndex=101&count=5'); + + $response->assertStatus(200); + $response->assertJson([ + 'totalResults' => 200, + 'itemsPerPage' => 5, + 'startIndex' => 101, + ]); + + $resources = $response->json('Resources'); + + $this->assertCount(5, $resources); + $this->assertEquals('Group', $resources[0]['meta']['resourceType']); + } + + public function testIndexReturnsUserAndGroup() + { + $response = $this->get('/scim/v2?startIndex=99&count=4'); + + $response->assertStatus(200); + + $resources = collect($response->json('Resources')); + + $this->assertSame([ + 'Group', + 'User', + ], $resources->pluck('meta.resourceType')->unique()->sort()->values()->toArray()); + + $this->assertTrue( + $resources->contains(fn ($resource) => + ($resource['meta']['resourceType'] ?? null) === 'User' + && ($resource['urn:ietf:params:scim:schemas:core:2.0:User']['userName'] ?? null) === 'boundary.user' + ), + 'Boundary User not found in combined listing.' + ); + + $this->assertTrue( + $resources->contains(fn ($resource) => + ($resource['meta']['resourceType'] ?? null) === 'Group' + && ($resource['urn:ietf:params:scim:schemas:core:2.0:Group']['displayName'] ?? null) === 'Boundary Group' + ), + 'Boundary Group not found in combined listing.' + ); + } + + public function testIndexFilterRestrictsToUsers() + { + $response = $this->get('/scim/v2?filter=(meta.resourceType eq "User")'); + + $response->assertStatus(200); + + $resources = collect($response->json('Resources')); + + $this->assertNotEmpty($resources); + $this->assertSame(['User'], $resources->pluck('meta.resourceType')->unique()->values()->toArray()); + } + + public function testIndexFilterSupportsOrAcrossResourceTypes() + { + $responseFirstPage = $this->get('/scim/v2?count=10&filter=(meta.resourceType eq "User") or (meta.resourceType eq "Group")'); + + $responseFirstPage->assertStatus(200); + + $resourcesFirstPage = collect($responseFirstPage->json('Resources')); + + $this->assertTrue($resourcesFirstPage->contains(fn ($resource) => ($resource['meta']['resourceType'] ?? null) === 'User')); + $this->assertEmpty(array_diff($resourcesFirstPage->pluck('meta.resourceType')->unique()->values()->toArray(), ['User', 'Group'])); + + $responseSecondPage = $this->get('/scim/v2?startIndex=101&count=5&filter=(meta.resourceType eq "User") or (meta.resourceType eq "Group")'); + + $responseSecondPage->assertStatus(200); + + $resourcesSecondPage = collect($responseSecondPage->json('Resources')); + + $this->assertNotEmpty($resourcesSecondPage); + $this->assertEquals('Group', $resourcesSecondPage->first()['meta']['resourceType']); + } + + public function testIndexRejectsCursorParameter() + { + $response = $this->get('/scim/v2?cursor=opaque'); + + $response->assertStatus(400); + $response->assertJson([ + 'scimType' => 'invalidCursor', + 'status' => '400', + ]); + } + + public function testPostSearchHonorsCountAndStartIndex() + { + $response = $this->postJson( + '/scim/v2/.search', + [ + 'schemas' => ['urn:ietf:params:scim:api:messages:2.0:SearchRequest'], + 'count' => 5, + 'startIndex' => 198, + ] + ); + + $response->assertStatus(200); + $response->assertJson([ + 'totalResults' => 200, + 'startIndex' => 198, + ]); + + $resources = $response->json('Resources'); + + $this->assertCount(3, $resources); + $this->assertSame(count($resources), $response->json('itemsPerPage')); + $this->assertEquals('Group', $resources[0]['meta']['resourceType']); + } +} diff --git a/tests/Model/Group.php b/tests/Model/Group.php index 58732f2..f75a120 100644 --- a/tests/Model/Group.php +++ b/tests/Model/Group.php @@ -6,6 +6,8 @@ class Group extends Model { + protected $guarded = []; + public function members() { return $this diff --git a/tests/Model/User.php b/tests/Model/User.php index ff1c935..ea30a5d 100644 --- a/tests/Model/User.php +++ b/tests/Model/User.php @@ -6,10 +6,7 @@ class User extends \Illuminate\Foundation\Auth\User { - - protected $fillable = [ - 'roles', - ]; + protected $guarded = []; protected $casts = [ 'active' => 'boolean', diff --git a/tests/SchemaTest.php b/tests/SchemaTest.php index f4f2e1e..5b86e56 100644 --- a/tests/SchemaTest.php +++ b/tests/SchemaTest.php @@ -64,6 +64,19 @@ public function testGet() $this->assertNotNull($activeAttribute, "Active attribute not found"); $this->assertEquals('boolean', $activeAttribute['type'], "Active attribute is not of type boolean"); + // Inspect the group schema to validate collection metadata for members. + $groupSchema = collect($jsonResponse['Resources'] ?? [])->firstWhere('id', 'urn:ietf:params:scim:schemas:core:2.0:Group'); + + $this->assertNotNull($groupSchema, 'Group schema not found'); + + $membersAttribute = collect($groupSchema['attributes'] ?? [])->firstWhere('name', 'members'); + + $this->assertNotNull($membersAttribute, 'members attribute missing from Group schema'); + $this->assertSame('complex', $membersAttribute['type']); + $this->assertTrue($membersAttribute['multiValued']); + $this->assertSame('A list of members of the Group.', $membersAttribute['description']); + $this->assertNotEmpty($membersAttribute['subAttributes']); + } public function getSchemaGenerator(){ diff --git a/tests/TestCase.php b/tests/TestCase.php index a6c025a..4f8768e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -63,6 +63,17 @@ protected function setUp(): void } $groups = factory(Group::class, 100)->create(); + // Ensure deterministic boundary records for cross-resource pagination tests. + optional($users->sortBy('id')->last())->update([ + 'name' => 'boundary.user', + 'formatted' => 'Boundary User', + 'email' => 'boundary.user@example.test', + ]); + + optional($groups->sortBy('id')->first())->update([ + 'displayName' => 'Boundary Group', + ]); + $users->each(function ($user) use ($groups) { $user->groups()->attach( $groups->random(rand(1, 3))->pluck('id')->toArray()