Skip to content

Commit

Permalink
Support for JSONCollection columns (#91)
Browse files Browse the repository at this point in the history
  • Loading branch information
arietimmerman authored Sep 16, 2024
1 parent 9fefb3f commit 2f219c4
Show file tree
Hide file tree
Showing 17 changed files with 395 additions and 26 deletions.
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
FROM php:8.0-alpine
FROM php:8.1-alpine

RUN apk add --no-cache git jq moreutils
RUN apk add --no-cache $PHPIZE_DEPS postgresql-dev \
&& docker-php-ext-install pdo_pgsql \
&& pecl install xdebug-3.1.5 \
&& docker-php-ext-enable xdebug \
&& echo "xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
&& echo "xdebug.client_host = 172.19.0.1" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini

RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN composer create-project --prefer-dist laravel/laravel example && \
cd example
Expand Down
5 changes: 5 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ services:
laravel-scim-server:
build: .
ports:
# forward xdebug ports
- "127.0.0.1:18123:8000"
working_dir: /laravel-scim-server
environment:
- XDEBUG_MODE=debug
- XDEBUG_SESSION=1
volumes:
- .:/laravel-scim-server
4 changes: 2 additions & 2 deletions src/Attribute/Attribute.php
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ public function withFilter($filter)
return $this;
}

public function applyComparison(Builder &$query, Path $path, $parentAttribute = null)
public function applyComparison(Builder &$query, Path $path, Path $parentAttribute = null)
{
throw new SCIMException(sprintf('Comparison is not implemented for "%s"', $this->getFullKey()));
}
Expand All @@ -290,7 +290,7 @@ public function patch($operation, $value, Model &$object, Path $path = null)
throw new SCIMException(sprintf('Patch is not implemented for "%s"', $this->getFullKey()));
}

public function remove($value, Model &$object, string $path = null)
public function remove($value, Model &$object, Path $path = null)
{
throw new SCIMException(sprintf('Remove is not implemented for "%s"', $this->getFullKey()));
}
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ protected function doRead(&$object, $attributes = [])
return $result;
}

public function applyComparison(Builder &$query, Path $path, $parentAttribute = null)
public function applyComparison(Builder &$query, Path $path, Path $parentAttribute = null)
{
if ($path == null || empty($path->getAttributePathAttributes())) {
throw new SCIMException('No attribute path attributes found. Could not apply comparison in ' . $this->getFullKey());
Expand Down
2 changes: 1 addition & 1 deletion src/Attribute/Complex.php
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ public function add($value, Model &$object)
}


public function remove($value, Model &$object, string $path = null)
public function remove($value, Model &$object, Path $path = null)
{
if ($this->mutability == 'readOnly') {
// silently ignore
Expand Down
155 changes: 155 additions & 0 deletions src/Attribute/JSONCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

namespace ArieTimmerman\Laravel\SCIMServer\Attribute;

use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;

class JSONCollection extends MutableCollection
{
public function add($value, Model &$object)
{
foreach ($value as $v) {
if (collect($object->{$this->attribute})->contains(
fn ($item) => collect($item)->diffAssoc($v)->isEmpty()
)) {
throw new SCIMException('Value already exists', 400);
}
}
$object->{$this->attribute} = collect($object->{$this->attribute})->merge($value);
}

public function replace($value, Model &$object, ?Path $path = null)
{
$object->{$this->attribute} = $value;
}

public function doRead(&$object, $attributes = [])
{
return $object->{$this->attribute}?->values()->all();
}

public function remove($value, Model &$object, Path $path = null)
{
if ($path?->getValuePathFilter()?->getComparisonExpression() != null) {
$attributes = $path?->getValuePathFilter()?->getComparisonExpression()?->attributePath?->attributeNames;
$operator = $path?->getValuePathFilter()?->getComparisonExpression()?->operator;
$compareValue = $path?->getValuePathFilter()?->getComparisonExpression()?->compareValue;

if ($value != null) {
throw new SCIMException('Non-null value is currently not supported for remove operation with filter');
}

if (count($attributes) != 1) {
throw new SCIMException('Only one attribute is currently supported for remove operation with filter');
}

$object->{$this->attribute} = collect($object->{$this->attribute})->filter(function ($item) use ($attributes, $operator, $compareValue) {
// check operator eq and ne
if ($operator == 'eq') {
return !(isset($item[$attributes[0]]) && $item[$attributes[0]] == $compareValue);
} elseif ($operator == 'ne') {
return !(!isset($item[$attributes[0]]) || $item[$attributes[0]] != $compareValue);
} else {
throw new SCIMException('Unsupported operator for remove operation with filter');
}
})->values()->all();
} else {
foreach ($value as $v) {
$object->{$this->attribute} = collect($object->{$this->attribute})->filter(function ($item) use ($v) {
return !collect($item)->diffAssoc($v)->isEmpty();
})->values()->all();
}
}
}

public function applyComparison(Builder &$query, Path $path, Path $parentAttribute = null)
{
$fieldName = 'value';

if ($path != null && !empty($path->getAttributePathAttributes())) {
$fieldName = $path->getAttributePathAttributes()[0];
}

$operator = $path->node->operator;
$value = $path->node->compareValue;

$exists = false;

foreach ($this->subAttributes as $subAttribute) {
if ($subAttribute->name == $fieldName) {
$exists = true;
break;
}
}

if (!$exists) {
throw new SCIMException('No attribute found with name ' . $path->getAttributePathAttributes()[0]);
}

// check if engine is postgres
if (DB::getConfig("driver") == 'pgsql') {
$baseQuery = sprintf("EXISTS (
SELECT 1
FROM json_array_elements(%s) elem
WHERE elem ->> '%s' LIKE ?
)", $this->attribute, $fieldName);
} elseif (DB::getConfig("driver") == 'sqlite') {
$baseQuery = sprintf("EXISTS (
SELECT 1
FROM json_each(%s) AS elem
WHERE json_extract(elem.value, '$.%s') LIKE ?
)", $this->attribute, $fieldName);
} else {
throw new SCIMException('Unsupported database engine');
}

switch ($operator) {
case "eq":
$query->whereRaw($baseQuery, [addcslashes($value, '%_')]);
break;
case "ne":
if (DB::getConfig("driver") == 'pgsql') {
$baseQuery = sprintf("EXISTS (
SELECT 1
FROM json_array_elements(%s) elem
WHERE elem ->> '%s' NOT LIKE ?
)", $this->attribute, $fieldName);
} elseif (DB::getConfig("driver") == 'sqlite') {
$baseQuery = sprintf("EXISTS (
SELECT 1
FROM json_each(%s) AS elem
WHERE json_extract(elem.value, '$.%s') NOT LIKE ?
)", $this->attribute, $fieldName);
} else {
throw new SCIMException('Unsupported database engine');
}
$query->whereRaw($baseQuery, [addcslashes($value, '%_')])->orWhereNull($this->attribute);
break;
case "co":
$query->whereRaw($baseQuery, ['%' . addcslashes($value, '%_') . "%"]);
break;
case "sw":
// $query->where($jsonAttribute, 'like', addcslashes($value, '%_') . '%');
$query->whereRaw($baseQuery, [addcslashes($value, '%_') . "%"]);
break;
case "ew":
$query->whereRaw($baseQuery, ['%' . addcslashes($value, '%_')]);
break;
case "pr":
$query->whereNotNull($this->attribute);
break;
case "gt":
case "ge":
case "lt":
case "le":
throw new SCIMException("This operator is not supported for this field: " . $operator);
break;
default:
throw new SCIMException("Unknown operator " . $operator);
}
}
}
3 changes: 2 additions & 1 deletion src/Attribute/Meta.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ArieTimmerman\Laravel\SCIMServer\Attribute;

use ArieTimmerman\Laravel\SCIMServer\Helper;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use Illuminate\Database\Eloquent\Model;

class Meta extends Complex
Expand Down Expand Up @@ -41,7 +42,7 @@ protected function doRead(&$object, $attributes = [])
);
}

public function remove($value, Model &$object, ?string $path = null)
public function remove($value, Model &$object, Path $path = null)
{
// ignore
}
Expand Down
6 changes: 3 additions & 3 deletions src/Attribute/MutableCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ public function add($value, Model &$object)
$object->load($this->attribute);
}

public function remove($value, Model &$object, string $path = null)
public function remove($value, Model &$object, Path $path = null)
{
$values = collect($value)->pluck('value')->all();

Expand Down Expand Up @@ -66,9 +66,9 @@ public function patch($operation, $value, Model &$object, ?Path $path = null)
if ($operation == 'add') {
$this->add($value, $object);
} elseif ($operation == 'remove') {
$this->remove($value, $object);
$this->remove($value, $object, $path);
} elseif ($operation == 'replace') {
$this->replace($value, $object);
$this->replace($value, $object, $path);
} else {
throw new SCIMException('Operation not supported: ' . $operation);
}
Expand Down
9 changes: 7 additions & 2 deletions src/Attribute/Schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ class Schema extends Complex

public function generateSchema()
{
return [
$result = [
"schemas" => [
"urn:ietf:params:scim:schemas:core:2.0:Schema"
],
Expand All @@ -21,9 +21,14 @@ public function generateSchema()
],
// name is substring after last occurence of :
"name" => substr($this->name, strrpos($this->name, ':') + 1),
"description" => $this->description,
"attributes" => collect($this->subAttributes)->map(fn ($element) => $element->generateSchema())->toArray()
];

if($this->description !== null){
$result['description'] = $this->description;
}

return $result;
}

}
4 changes: 2 additions & 2 deletions src/Http/Controllers/ResourceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,12 @@ public function update(Request $request, PolicyDecisionPoint $pdp, ResourceType
foreach ($input['Operations'] as $operation) {
switch (strtolower($operation['op'])) {
case "add":
$resourceType->getMapping()->patch('add', $operation['value'], $resourceObject, ParserParser::parse($operation['path'] ?? null));
$resourceType->getMapping()->patch('add', $operation['value'] ?? null, $resourceObject, ParserParser::parse($operation['path'] ?? null));
break;

case "remove":
if (isset($operation['path'])) {
$resourceType->getMapping()->patch('remove', $operation['value'], $resourceObject, ParserParser::parse($operation['path'] ?? null));
$resourceType->getMapping()->patch('remove', $operation['value'] ?? null, $resourceObject, ParserParser::parse($operation['path'] ?? null));
} else {
throw new SCIMException('You MUST provide a "Path"');
}
Expand Down
15 changes: 9 additions & 6 deletions src/Parser/Filter.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@

namespace ArieTimmerman\Laravel\SCIMServer\Parser;

use Tmilos\ScimFilterParser\Ast\ComparisonExpression;
use Tmilos\ScimFilterParser\Ast\Filter as AstFilter;

class Filter {

public $filter;

public function __construct(AstFilter $filter){
$this->filter = $filter;
class Filter
{
public function __construct(public AstFilter $filter)
{
}

public function getComparisonExpression(): ComparisonExpression
{
return $this->filter instanceof ComparisonExpression ? $this->filter : null;
}
}
3 changes: 2 additions & 1 deletion src/Parser/Path.php
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ public function shiftValuePathAttributes(): Path {
$this->getValuePath()->getAttributePath()->shiftAttributeName();

if(empty($this->getValuePathAttributes())){
$this->setValuePath(null);
// The line below isp probably not needed
// $this->setValuePath(null);
}

return $this;
Expand Down
10 changes: 8 additions & 2 deletions src/SCIMConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Constant;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Eloquent;
use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
Expand All @@ -33,7 +34,6 @@ function eloquent($name, $attribute = null): Attribute

class SCIMConfig
{

public function __construct()
{
}
Expand Down Expand Up @@ -120,6 +120,12 @@ protected function doRead(&$object, $attributes = [])
}),
eloquent('display', 'name')
),
(new JSONCollection('roles'))->withSubAttributes(
eloquent('value')->ensure('required', 'min:3', 'alpha_dash:ascii'),
eloquent('display')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
eloquent('type')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
eloquent('primary')->ensure('boolean')->default(false)
)->ensure('nullable', 'array', 'max:20')
),
(new AttributeSchema('urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', true))->withSubAttributes(
eloquent('employeeNumber')->ensure('nullable')
Expand Down Expand Up @@ -160,7 +166,7 @@ protected function doRead(&$object, $attributes = [])
(new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes(
eloquent('displayName')->ensure('required', 'min:3', function ($attribute, $value, $fail) {
// check if group does not exist or if it exists, it is the same group
$group = Group::where('name', $value)->first();
$group = Group::where('displayName', $value)->first();
if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) {
$fail('The name has already been taken.');
}
Expand Down
16 changes: 16 additions & 0 deletions tests/Model/Role.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php
namespace ArieTimmerman\Laravel\SCIMServer\Tests\Model;

use Illuminate\Database\Eloquent\Model;

class Role extends Model
{

protected $fillable = ['value', 'display', 'type'];

// primary key is value, of type string
public $incrementing = false;
protected $primaryKey = 'value';
protected $keyType = 'string';

}
Loading

0 comments on commit 2f219c4

Please sign in to comment.