This commit is contained in:
snipe
2026-05-14 11:00:58 +01:00
parent 29b9a78f54
commit 2af7367480
+81 -79
View File
@@ -2,10 +2,6 @@
namespace App\Models;
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Helper;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
@@ -15,9 +11,10 @@ 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;
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
use Illuminate\Database\Eloquent\Model;
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
function a($name = null): Attribute
{
@@ -36,11 +33,10 @@ function eloquent($name, $attribute = null): Attribute
class EloquentWithRemove extends Eloquent
{
public function remove($value, Model &$object, Path $path = null)
public function remove($value, Model &$object, ?Path $path = null)
{
$object->{$this->attribute} = null;
}
}
class MappedTable extends Attribute
@@ -70,52 +66,48 @@ class MappedTable extends Attribute
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
}
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
}
}
class UpdatableComplex extends Complex
{
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey());
throw new \Exception("doWrite is not implemented yet for Operation: $operation ".($subop ? "($subop)" : '').'on attribute '.$this->getFullKey());
}
public function add($value, Model &$object)
{
$this->doWrite("add", null, $value, $object);
$this->doWrite('add', null, $value, $object);
}
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
$this->doWrite('replace', null, $value, $object, $path, $removeIfNotSet);
}
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
$this->doWrite('patch', $operation, $value, $object, $path, $removeIfNotSet);
}
public function remove($value, Model &$object, Path $path = null)
public function remove($value, Model &$object, ?Path $path = null)
{
$this->doWrite("remove", null, null, $object, $path);
$this->doWrite('remove', null, null, $object, $path);
}
}
class SnipeSCIMConfig
{
public function __construct()
{
}
public function __construct() {}
public function getConfigForResource($name)
{
$result = $this->getConfig();
return @$result[$name];
}
@@ -125,6 +117,7 @@ class SnipeSCIMConfig
}
const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User';
public function getUserConfig()
@@ -140,22 +133,19 @@ class SnipeSCIMConfig
'description' => 'User Account',
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:User",
self::ENTERPRISE,
self::GROKABILITY
]) extends Constant {
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:User', self::ENTERPRISE, self::GROKABILITY]) extends Constant
{
public function replace($value, &$object, $path = null)
{
// do nothing
$this->dirty = true;
}
},
(new class ('id', null) extends Constant { // TODO - this 'id' is in the same namespace for objects OR groups?
protected function doRead(&$object, $attributes = [])
{
return (string)$object->id;
}
(new class('id', null) extends Constant // TODO - this 'id' is in the same namespace for objects OR groups?
{protected function doRead(&$object, $attributes = [])
{
return (string) $object->id;
}
public function remove($value, &$object, $path = null)
{
@@ -166,90 +156,97 @@ class SnipeSCIMConfig
new Meta('Users'),
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
eloquent('userName', 'username')->ensure('required'),
(new class ('active', 'activated') extends Eloquent {
(new class('active', 'activated') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return (bool)$object->activated; // need this extension to force boolean-ness
return (bool) $object->activated; // need this extension to force boolean-ness
}
}),
complex('name')->withSubAttributes(
eloquent('givenName', 'first_name')->ensure('required'),
eloquent('familyName', 'last_name'),
), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not?
eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec
//eloquent('password')->ensure('nullable')->setReturned('never'),
eloquent('displayName', 'display_name'), // yes, this is *not* under 'name' - that's the spec
// eloquent('password')->ensure('nullable')->setReturned('never'),
eloquent('externalId', 'scim_externalid'),
// Email chonk
(new class ('emails') extends UpdatableComplex {
(new class('emails') extends UpdatableComplex
{
protected function doRead(&$object, $attributes = [])
{
return collect([$object->email])->map(function ($email) {
return [
'value' => $email,
'type' => 'work', //TODO - is this how we always have done it?
'primary' => true
'type' => 'work', // TODO - is this how we always have done it?
'primary' => true,
];
})->toArray();
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
if ($value) {
try {
$object->email = $value[0]['value'];
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
}
} else {
$object->email = null;
}
}
})->withSubAttributes(
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
eloquent('value', 'email')->ensure('email', 'nullable'), // Weird, this 'needs' nullable to work?
new Constant('type', 'work'),
(new Constant('primary', true))->ensure('boolean')
)->ensure('array')
->setMultiValued(true),
// phone chonk
(new class ('phoneNumbers') extends UpdatableComplex {
(new class('phoneNumbers') extends UpdatableComplex
{
protected function doRead(&$object, $attributes = [])
{
$phones = [];
if ($object->phone) {
$phones[] = [
'value' => $object->phone,
'type' => 'work'
'type' => 'work',
];
}
if ($object->mobile) {
$phones[] = [
'value' => $object->mobile,
'type' => 'mobile'
'type' => 'mobile',
];
}
return $phones;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
\Log::debug("Phones 'value' is: " . print_r($value, true));
\Log::debug("Phones 'value' is: ".print_r($value, true));
try {
if ($operation == "patch") {
if ($operation == 'patch') {
if ($path->getValuePathFilter() != null) {
if ((string) $path == 'phoneNumbers[type eq "mobile"].value') {
$object->mobile = $value; //I don't know why the value is the raw value, but it is?
$object->mobile = $value; // I don't know why the value is the raw value, but it is?
return;
}
if ((string) $path == 'phoneNumbers[type eq "work"].value') {
$object->phone = $value; //similar, don't know why, but it is
$object->phone = $value; // similar, don't know why, but it is
return;
}
}
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
return;
}
foreach ($value as $phone) {
@@ -263,15 +260,14 @@ class SnipeSCIMConfig
break;
default:
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
throw new SCIMException("Unknown phone type '".@$phone['type']."'", 400);
}
}
} catch (\Throwable $e) {
\Log::debug($e);
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
throw new SCIMException("Unknown phone object(s) '".print_r($value, true)."'", 422);
}
}
})->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all
(new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored
new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored
@@ -279,13 +275,14 @@ class SnipeSCIMConfig
->setMultiValued(true),
// addresses chonk
(new class ('addresses') extends UpdatableComplex {
static $addressmap = [
(new class('addresses') extends UpdatableComplex
{
public static $addressmap = [
'streetAddress' => 'address',
'locality' => 'city',
'region' => 'state',
'postalCode' => 'zip',
'country' => 'country'
'country' => 'country',
];
protected function doRead(&$object, $attributes = [])
@@ -300,10 +297,11 @@ class SnipeSCIMConfig
$address['type'] = 'work';
$address['primary'] = true;
}
return $address;
}
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
{
// TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts
if ($path->getValuePathFilter() != null) {
@@ -311,7 +309,7 @@ class SnipeSCIMConfig
// get the part of the $path that we actually care about - something like:
// addresses[type eq "work"]
$matches = null;
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
if (! preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string) $path, $matches)) {
throw new SCIMException("Unknown path type '$path'", 422);
}
$type = $matches[1];
@@ -321,14 +319,13 @@ class SnipeSCIMConfig
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
if (array_key_exists($attribute, self::$addressmap)) {
$object->{self::$addressmap[$attribute]} = $value;
return;
}
throw new SCIMException("Could not handle path for update $path", 422);
}
}
})->withSubAttributes(
eloquent('streetAddress', 'address'),
eloquent('locality', 'city'),
@@ -344,14 +341,15 @@ class SnipeSCIMConfig
eloquent('preferredLanguage', 'locale'),
(new Collection('groups'))->withSubAttributes(
eloquent('value', 'id'),
(new class ('$ref') extends Eloquent {
(new class('$ref') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Group',
'resourceObject' => $object->id ?? "not-saved"
'resourceObject' => $object->id ?? 'not-saved',
]
);
}
@@ -368,14 +366,16 @@ class SnipeSCIMConfig
(new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes(
eloquent('employeeNumber', 'employee_num')->ensure('nullable'),
new MappedTable('department', 'department', Department::class, 'department_id', 'name'),
(new class('manager') extends UpdatableComplex {
(new class('manager') extends UpdatableComplex
{
protected function doRead(&$object, $attributes = [])
{
if (!$object->manager) {
if (! $object->manager) {
return null;
}
return [
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
'value' => $object->manager->id, // TODO - ID's aren't unique like they're supposed to be :/
'$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]),
'displayName' => $object->manager->display_name,
];
@@ -383,10 +383,10 @@ class SnipeSCIMConfig
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
{
\Log::debug("What type of value is value? " . gettype($value));
\Log::debug('What type of value is value? '.gettype($value));
$manager_id = null;
if (is_scalar($value)) {
\Log::debug("Weird Microsoft mode - set manager to the \$value and move on with life?");
\Log::debug('Weird Microsoft mode - set manager to the $value and move on with life?');
$manager_id = $value;
} elseif (array_key_exists('$ref', $value)) {
// Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3
@@ -396,7 +396,7 @@ class SnipeSCIMConfig
// extract ID from URL, jam it in?
$url = $value['$ref'];
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
$users_prefix = route('scim.resources', ['resourceType' => 'User']).'/';
if (str_starts_with($url, $users_prefix)) {
$manager_id = substr($url, strlen($users_prefix));
}
@@ -407,9 +407,10 @@ class SnipeSCIMConfig
// that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one!
$manager_id = $value['value'];
}
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true));
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: ".print_r($value, true));
if ($manager_id && User::find($manager_id)) {
$object->manager_id = $manager_id;
return;
}
throw new SCIMException("No manager given, or manager doesn't exist", 400);
@@ -431,24 +432,24 @@ class SnipeSCIMConfig
'class' => $this->getGroupClass(),
'singular' => 'Group',
//eager loading
// eager loading
'withRelations' => [],
'description' => 'Group',
'map' => complex()->withSubAttributes(
new class ('schemas', [
"urn:ietf:params:scim:schemas:core:2.0:Group",
]) extends Constant {
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:Group']) extends Constant
{
public function replace($value, &$object, $path = null)
{
// do nothing
$this->dirty = true;
}
},
(new class ('id', null) extends Constant {
(new class('id', null) extends Constant
{
protected function doRead(&$object, $attributes = [])
{
return (string)$object->id;
return (string) $object->id;
}
public function remove($value, &$object, $path = null)
@@ -469,14 +470,15 @@ class SnipeSCIMConfig
}),
(new MutableCollection('members'))->withSubAttributes(
eloquent('value', 'id')->ensure('required'),
(new class ('$ref') extends Eloquent {
(new class('$ref') extends Eloquent
{
protected function doRead(&$object, $attributes = [])
{
return route(
'scim.resource',
[
'resourceType' => 'Users',
'resourceObject' => $object->id ?? "not-saved"
'resourceObject' => $object->id ?? 'not-saved',
]
);
}