From 61df3bc462314008c55e2b399372d86b7591c7d0 Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Tue, 16 Sep 2025 11:44:10 +0100 Subject: [PATCH 1/7] WIP: switching to new version of laravel-scim-server --- composer.json | 2 +- composer.lock | 25 +++++++++++++------------ 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index f7b86417562e..0a33a3b0df52 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ "ext-mbstring": "*", "ext-pdo": "*", "alek13/slack": "^2.0", - "arietimmerman/laravel-scim-server": "dev-laravel_11_compatibility", + "arietimmerman/laravel-scim-server": "dev-upstream_master", "bacon/bacon-qr-code": "^2.0", "barryvdh/laravel-debugbar": "^3.13", "barryvdh/laravel-dompdf": "^2.0", diff --git a/composer.lock b/composer.lock index f3a06b455651..9069fb03179e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4a00fb4c4b9a2ac161183e9ae9217298", + "content-hash": "1086d1b9a8b59a62d87c4e74a5babf89", "packages": [ { "name": "alek13/slack", @@ -74,29 +74,30 @@ }, { "name": "arietimmerman/laravel-scim-server", - "version": "dev-laravel_11_compatibility", + "version": "dev-upstream_master", "source": { "type": "git", "url": "https://github.com/grokability/laravel-scim-server.git", - "reference": "6c771799090bfe04dcee94a1dc9f82870aed4dbe" + "reference": "da40db79d76cf3b4c7e57cde41df6ecf8119afb7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/6c771799090bfe04dcee94a1dc9f82870aed4dbe", - "reference": "6c771799090bfe04dcee94a1dc9f82870aed4dbe", + "url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/da40db79d76cf3b4c7e57cde41df6ecf8119afb7", + "reference": "da40db79d76cf3b4c7e57cde41df6ecf8119afb7", "shasum": "" }, "require": { - "illuminate/console": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/database": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "illuminate/support": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", - "php": "^7.0|^8.0", + "illuminate/console": "^10.0|^11.0|^12.0", + "illuminate/database": "^10.0|^11.0|^12.0", + "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.0", "tmilos/scim-filter-parser": "^1.3", "tmilos/scim-schema": "^0.1.0" }, "require-dev": { + "friendsofphp/php-cs-fixer": "^3.66", "laravel/legacy-factories": "*", - "orchestra/testbench": "^4.0|^5.0|^6.0|^7.0|^8.0" + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0" }, "type": "library", "extra": { @@ -130,9 +131,9 @@ ], "description": "Laravel Package for creating a SCIM server", "support": { - "source": "https://github.com/grokability/laravel-scim-server/tree/laravel_11_compatibility" + "source": "https://github.com/grokability/laravel-scim-server/tree/upstream_master" }, - "time": "2025-01-20T14:49:28+00:00" + "time": "2025-08-28T19:24:40+00:00" }, { "name": "aws/aws-crt-php", From 9525bbf502b0493ad52dbb9bd782e11cfbd66145 Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Tue, 16 Sep 2025 15:22:33 +0100 Subject: [PATCH 2/7] Re-worked the SCIMConfig for the new version of laravel-scim-server --- app/Models/SnipeSCIMConfig.php | 531 ++++++++++++++++++++------------- 1 file changed, 322 insertions(+), 209 deletions(-) diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 7387569e103b..61a678386528 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -2,250 +2,363 @@ namespace App\Models; -use Illuminate\Database\Eloquent\Model; -use Helper; +use ArieTimmerman\Laravel\SCIMServer\Helper; use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema; -use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute; +use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection; +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; +use Illuminate\Database\Eloquent\Model; +function a($name = null): Attribute +{ + return new Attribute($name); +} -class SnipeSCIMConfig extends \ArieTimmerman\Laravel\SCIMServer\SCIMConfig +function complex($name = null): Complex { - public function getUserConfig() + return new Complex($name); +} + +function eloquent($name, $attribute = null): Attribute +{ + return new Eloquent($name, $attribute); +} + +class MappedTable extends Attribute +{ + public function __construct( + private string $scim_attribute_name, + private string $relationship_name, + private string $relationship_class, + private string $relationship_field = 'name') + { + parent::__construct($this->scim_attribute_name); + } + + protected function doRead(&$object, $attributes = []) + { + return $object->{$this->relationship_name}?->{$this->relationship_field}; + } + + public function add($value, Model &$object) + { + \Log::error("Structure of 'value' is going to be weird - " . print_r($value, true)); + $object->{$this->relationship_name} = $value ? $relationship_class::firstOrCreate([$this->relationship_field => $value]) : null; + } + + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) { - // Much of this is copied verbatim from the library, then adjusted for our needs + $object->{$this->relationship_name} = $value ? $relationship_class::firstOrCreate([$this->relationship_field => $value]) : null; + } + +} + + +class SnipeSCIMConfig +{ + public function __construct() + { + } - /* - more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?): - - website - - notes? - - remote??? - - location_id ? - - company_id to "organization?" - */ + public function getConfigForResource($name) + { + $result = $this->getConfig(); + return @$result[$name]; + } + public function getGroupClass() + { + return Group::class; + } - $user_prefix = 'urn:ietf:params:scim:schemas:core:2.0:User:'; - $enterprise_prefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:'; + 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() + { return [ // Set to 'null' to make use of auth.providers.users.model (App\User::class) - 'class' => SCIMUser::class, - - 'validations' => [ - $user_prefix . 'userName' => 'required', - $user_prefix . 'displayName' => 'nullable|string', - $user_prefix . 'name.givenName' => 'required', - $user_prefix . 'name.familyName' => 'nullable|string', - $user_prefix . 'externalId' => 'nullable|string', - $user_prefix . 'emails' => 'nullable|array', - $user_prefix . 'emails.*.value' => 'nullable|email', - $user_prefix . 'active' => 'boolean', - $user_prefix . 'phoneNumbers' => 'nullable|array', - $user_prefix . 'phoneNumbers.*.value' => 'nullable|string', - $user_prefix . 'addresses' => 'nullable|array', - $user_prefix . 'addresses.*.streetAddress' => 'nullable|string', - $user_prefix . 'addresses.*.locality' => 'nullable|string', - $user_prefix . 'addresses.*.region' => 'nullable|string', - $user_prefix . 'addresses.*.postalCode' => 'nullable|string', - $user_prefix . 'addresses.*.country' => 'nullable|string', - $user_prefix . 'title' => 'nullable|string', - $user_prefix . 'preferredLanguage' => 'nullable|string', - - // Enterprise validations: - $enterprise_prefix . 'employeeNumber' => 'nullable|string', - $enterprise_prefix . 'department' => 'nullable|string', - $enterprise_prefix . 'manager' => 'nullable', - $enterprise_prefix . 'manager.value' => 'nullable|string' - ], - + 'class' => Helper::getAuthUserClass(), 'singular' => 'User', - 'schema' => [Schema::SCHEMA_USER], //eager loading 'withRelations' => [], - 'map_unmapped' => false, - // 'unmapped_namespace' => 'urn:ietf:params:scim:schemas:laravel:unmapped', 'description' => 'User Account', - // Map a SCIM attribute to an attribute of the object. - 'mapping' => [ - - 'id' => (new AttributeMapping())->setRead( - function (&$object) { + 'map' => complex()->withSubAttributes( + 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; } - )->disableWrite(), - - 'externalId' => AttributeMapping::eloquent('scim_externalid'), // FIXME - I have a PR that changes a lot of this. - - 'meta' => [ - 'created' => AttributeMapping::eloquent("created_at")->disableWrite(), - 'lastModified' => AttributeMapping::eloquent("updated_at")->disableWrite(), - - 'location' => (new AttributeMapping())->setRead( - function ($object) { - return route( - 'scim.resource', - [ - 'resourceType' => 'Users', - 'resourceObject' => $object->id - ] - ); + + public function remove($value, &$object, $path = null) + { + // do nothing + } + } + ), + new Meta('Users'), + (new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes( + eloquent('userName', 'username')->ensure('required'), + (new class ('active', 'activated') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return (bool)$object->activated; // need this extension to force boolean-ness + } + }), + complex('name')->withSubAttributes( + eloquent('givenName', 'first_name'), + eloquent('familyName', 'last_name'), + ), + 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 Complex { + protected function doRead(&$object, $attributes = []) + { + return collect([$object->email])->map(function ($email) { + return [ + 'value' => $email, + 'type' => 'other', + 'primary' => true + ]; + })->toArray(); } - )->disableWrite(), - 'resourceType' => AttributeMapping::constant("User") - ], + public function add($value, Model &$object) + { + $object->email = $value[0]['value']; + } - 'schemas' => AttributeMapping::constant( - [ - 'urn:ietf:params:scim:schemas:core:2.0:User', - 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' - ] - )->ignoreWrite(), + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) + { + $object->email = $value[0]['value']; + } + })->withSubAttributes( + eloquent('value', 'email')->ensure('required', 'email'), + new Constant('type', 'other'), + new Constant('primary', true) + )->ensure('required', 'array') + ->setMultiValued(true), + + // phone chonk + (new class ('phoneNumbers') extends Complex { + protected function doRead(&$object, $attributes = []) + { + $phones = []; + if ($object->phone) { + $phones[] = [ + 'value' => $object->phone, + 'type' => 'work' + ]; + } + if ($object->mobile) { + $phones[] = [ + 'value' => $object->mobile, + 'type' => 'mobile' + ]; + } + return $phones; + } - 'urn:ietf:params:scim:schemas:core:2.0:User' => [ + public function add($value, Model &$object) + { + throw new \Exception("Dunno about fones"); + $object->email = $value[0]['value']; + } - 'userName' => AttributeMapping::eloquent("username"), + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) - 'name' => [ - 'formatted' => (new AttributeMapping())->ignoreWrite()->setRead( - function (&$object) { - return $object->getFullNameAttribute(); + { + throw new \Exception ("still dunno afbout fones"); + $object->email = $value[0]['value']; + } + }),/* ->withSubAttributes( + eloquent('value', 'email')->ensure('required', 'email'), + new Constant('type', 'other'), + new Constant('primary', true) + )->ensure('required', 'array') + ->setMultiValued(true), */ + (new class ('addresses') extends Complex { + protected function doRead(&$object, $attributes = []) + { + $addressmap = [ + 'streetAddress' => 'address', + 'locality' => 'city', + 'region' => 'state', + 'postalCode' => 'zip', + 'country' => 'country' + ]; + $address = []; + foreach ($addressmap as $scim_field => $db_field) { + if ($object->{$db_field}) { + $address[$scim_field] = $object->{$db_field}; + } + } + if (count($address) > 0) { + $address['type'] = 'work'; + $address['primary'] = true; } - ), - 'familyName' => AttributeMapping::eloquent("last_name"), - 'givenName' => AttributeMapping::eloquent("first_name"), - 'middleName' => null, - 'honorificPrefix' => null, - 'honorificSuffix' => null - ], - - 'displayName' => AttributeMapping::eloquent("display_name"), - 'nickName' => null, - 'profileUrl' => null, - 'title' => AttributeMapping::eloquent('jobtitle'), - 'userType' => null, - 'preferredLanguage' => AttributeMapping::eloquent('locale'), // Section 5.3.5 of [RFC7231] - 'locale' => null, // see RFC5646 - 'timezone' => null, // see RFC6557 - 'active' => (new AttributeMapping())->setAdd( - function ($value, &$object) { - $object->activated = $value; + return $address; } - )->setReplace( - function ($value, &$object) { - $object->activated = $value; + + public function add($value, Model &$object) + { + throw new \Exception("Dunno about addresses to add"); + $object->email = $value[0]['value']; } - )->setRead( - // this works as specified. - function (&$object) { - return (bool)$object->activated; + + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) + + { + throw new \Exception ("still dunno afbout addresses to whatever"); + $object->email = $value[0]['value']; } - ), - 'password' => AttributeMapping::eloquent('password')->disableRead(), - - // Multi-Valued Attributes - 'emails' => [[ - "value" => AttributeMapping::eloquent("email"), - "display" => null, - "type" => AttributeMapping::constant("work")->ignoreWrite(), - "primary" => AttributeMapping::constant(true)->ignoreWrite() - ]], - - 'phoneNumbers' => [[ - "value" => AttributeMapping::eloquent("phone"), - "display" => null, - "type" => AttributeMapping::constant("work")->ignoreWrite(), - "primary" => AttributeMapping::constant(true)->ignoreWrite() - ]], - - 'ims' => [[ - "value" => null, - "display" => null, - "type" => null, - "primary" => null - ]], // Instant messaging addresses for the User - - 'photos' => [[ - "value" => null, - "display" => null, - "type" => null, - "primary" => null - ]], - - 'addresses' => [[ - 'type' => AttributeMapping::constant("work")->ignoreWrite(), - 'formatted' => AttributeMapping::constant("n/a")->ignoreWrite(), // TODO - is this right? This doesn't look right. - 'streetAddress' => AttributeMapping::eloquent("address"), - 'locality' => AttributeMapping::eloquent("city"), - 'region' => AttributeMapping::eloquent("state"), - 'postalCode' => AttributeMapping::eloquent("zip"), - 'country' => AttributeMapping::eloquent("country"), - 'primary' => AttributeMapping::constant(true)->ignoreWrite() //this isn't in the example? - ]], - - 'groups' => [[ - 'value' => null, - '$ref' => null, - 'display' => null, - 'type' => null, - ]], - - 'entitlements' => null, - 'roles' => null, - 'x509Certificates' => null - ], - - 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => [ - 'employeeNumber' => AttributeMapping::eloquent('employee_num'), - 'department' => (new AttributeMapping())->setAdd( // FIXME parent? - function ($value, &$object) { - $department = Department::where("name", $value)->first(); - if ($department) { - $object->department_id = $department->id; + }),/* ->withSubAttributes( + eloquent('value', 'email')->ensure('required', 'email'), + new Constant('type', 'other'), + new Constant('primary', true) + )->ensure('required', 'array') + ->setMultiValued(true), */ eloquent('title', 'jobtitle'), + eloquent('preferredLanguage', 'locale'), + (new Collection('groups'))->withSubAttributes( + eloquent('value', 'id'), + (new class ('$ref') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return route( + 'scim.resource', + [ + 'resourceType' => 'Group', + 'resourceObject' => $object->id ?? "not-saved" + ] + ); } - } - )->setReplace( - function ($value, &$object) { - $department = Department::where("name", $value)->first(); - if ($department) { - $object->department_id = $department->id; + }), + eloquent('display', 'name') + ), + (new JSONCollection('roles'))->withSubAttributes( // TODO - what is this? + 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(self::ENTERPRISE, false))->withSubAttributes( + eloquent('employeeNumber', 'employee_num')->ensure('nullable'), + new MappedTable('department', 'department', Department::class, 'name'), + //eloquent('manager', 'manager_id'), // FIXME - this is going to be more complicated and map to 'value' + (new class('manager') extends Complex { + protected function doRead(&$object, $attributes = []) + { + if (!$object->manager) { + return null; } + return [ + '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, + ]; } - )->setRead( - function (&$object) { - return $object->department ? $object->department->name : null; + }) // ->withSubAttributes() ... -> ensure() ? + ), + (new AttributeSchema(self::GROKABILITY, false))->withSubAttributes( + new MappedTable('location', 'location', Location::class, 'name'), + new MappedTable('company', 'company', Company::class, 'name'), + ) + ), + ]; + } + + public function getGroupConfig() + { + return [ + + 'class' => $this->getGroupClass(), + 'singular' => 'Group', + + //eager loading + 'withRelations' => [], + 'description' => 'Group', + + 'map' => complex()->withSubAttributes( + 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 { + protected function doRead(&$object, $attributes = []) + { + return (string)$object->id; + } + + public function remove($value, &$object, $path = null) + { + // do nothing + } + } + ), + new Meta('Groups'), + (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 = $this->getGroupClass()::where('displayName', $value)->first(); + if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) { + $fail('The name has already been taken.'); } - ), - 'manager' => [ - // FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool. - // '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(), - // 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(), - // NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing - 'value' => (new AttributeMapping())->setAdd( - function ($value, &$object) { - $manager = User::find($value); - if ($manager) { - $object->manager_id = $manager->id; - } - } - )->setReplace( - function ($value, &$object) { - $manager = User::find($value); - if ($manager) { - $object->manager_id = $manager->id; - } - } - )->setRead( - function (&$object) { - return $object->manager_id; + }), + (new MutableCollection('members'))->withSubAttributes( + eloquent('value', 'id')->ensure('required'), + (new class ('$ref') extends Eloquent { + protected function doRead(&$object, $attributes = []) + { + return route( + 'scim.resource', + [ + 'resourceType' => 'Users', + 'resourceObject' => $object->id ?? "not-saved" + ] + ); } - ), - ] - ] - ] + }), + eloquent('display', 'name') + )->ensure('nullable', 'array') + ) + ), + ]; + } + + public function getConfig() + { + return [ + 'Users' => $this->getUserConfig(), + 'Groups' => $this->getGroupConfig(), ]; } } From 92fbf83bdbd8d48da509ccbd7ef5f28f0505b0fa Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Tue, 16 Sep 2025 15:41:14 +0100 Subject: [PATCH 3/7] Adjusting some Schema settings to match our requirements --- app/Models/SnipeSCIMConfig.php | 44 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 61a678386528..609aafc3272c 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -126,9 +126,9 @@ protected function doRead(&$object, $attributes = []) } }), complex('name')->withSubAttributes( - eloquent('givenName', 'first_name'), + eloquent('givenName', 'first_name')->ensure('required'), eloquent('familyName', 'last_name'), - ), + )->ensure('required'), eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec //eloquent('password')->ensure('nullable')->setReturned('never'), eloquent('externalId', 'scim_externalid'), @@ -140,7 +140,7 @@ protected function doRead(&$object, $attributes = []) return collect([$object->email])->map(function ($email) { return [ 'value' => $email, - 'type' => 'other', + 'type' => 'work', //TODO - is this how we always have done it? 'primary' => true ]; })->toArray(); @@ -156,10 +156,10 @@ public function replace($value, Model &$object, $path = null, $removeIfNotSet = $object->email = $value[0]['value']; } })->withSubAttributes( - eloquent('value', 'email')->ensure('required', 'email'), - new Constant('type', 'other'), - new Constant('primary', true) - )->ensure('required', 'array') + eloquent('value', 'email')->ensure('email'), + new Constant('type', 'work'), + new Constant('primary', true)->ensure('boolean') + )->ensure('array') ->setMultiValued(true), // phone chonk @@ -194,12 +194,14 @@ public function replace($value, Model &$object, $path = null, $removeIfNotSet = throw new \Exception ("still dunno afbout fones"); $object->email = $value[0]['value']; } - }),/* ->withSubAttributes( - eloquent('value', 'email')->ensure('required', 'email'), + })->withSubAttributes( + new Constant('value', 'email')->ensure('string'), new Constant('type', 'other'), - new Constant('primary', true) - )->ensure('required', 'array') - ->setMultiValued(true), */ + new Constant('primary', true)->ensure('boolean'), + )->ensure('array') + ->setMultiValued(true), + + // addresses chonk (new class ('addresses') extends Complex { protected function doRead(&$object, $attributes = []) { @@ -223,6 +225,7 @@ protected function doRead(&$object, $attributes = []) return $address; } + /*** It's possible that the Eloquent mappings in the sub-attributes will handle this? public function add($value, Model &$object) { throw new \Exception("Dunno about addresses to add"); @@ -235,12 +238,19 @@ public function replace($value, Model &$object, $path = null, $removeIfNotSet = throw new \Exception ("still dunno afbout addresses to whatever"); $object->email = $value[0]['value']; } - }),/* ->withSubAttributes( - eloquent('value', 'email')->ensure('required', 'email'), + * *********/ + })->withSubAttributes( + eloquent('streetAddress', 'address'), + eloquent('locality', 'city'), + eloquent('region', 'state'), + eloquent('postalCode', 'zip'), + eloquent('country', 'country'), new Constant('type', 'other'), - new Constant('primary', true) - )->ensure('required', 'array') - ->setMultiValued(true), */ eloquent('title', 'jobtitle'), + new Constant('primary', true)->ensure('boolean') + )->ensure('array') + ->setMultiValued(true), + + eloquent('title', 'jobtitle'), eloquent('preferredLanguage', 'locale'), (new Collection('groups'))->withSubAttributes( eloquent('value', 'id'), From 760d089073703f60c4a036001b892e27b4bbb306 Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Tue, 23 Sep 2025 20:40:21 +0100 Subject: [PATCH 4/7] Azure-specific Manager is handled now --- app/Models/SnipeSCIMConfig.php | 96 +++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 13 deletions(-) diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 609aafc3272c..8349c06fddfd 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -3,6 +3,7 @@ namespace App\Models; 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; @@ -36,7 +37,8 @@ public function __construct( private string $scim_attribute_name, private string $relationship_name, private string $relationship_class, - private string $relationship_field = 'name') + private string $relationship_id_field, + private string $relationship_field) { parent::__construct($this->scim_attribute_name); } @@ -49,12 +51,18 @@ protected function doRead(&$object, $attributes = []) public function add($value, Model &$object) { \Log::error("Structure of 'value' is going to be weird - " . print_r($value, true)); - $object->{$this->relationship_name} = $value ? $relationship_class::firstOrCreate([$this->relationship_field => $value]) : null; + $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; } public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) { - $object->{$this->relationship_name} = $value ? $relationship_class::firstOrCreate([$this->relationship_field => $value]) : null; + $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) + { + \Log::error("implementing custom patch for value: '$value' of attribute " . $this->scim_attribute_name); + $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; } } @@ -85,7 +93,7 @@ public function getUserConfig() return [ // Set to 'null' to make use of auth.providers.users.model (App\User::class) - 'class' => Helper::getAuthUserClass(), + 'class' => ScimUser::class, 'singular' => 'User', //eager loading @@ -128,7 +136,7 @@ protected function doRead(&$object, $attributes = []) complex('name')->withSubAttributes( eloquent('givenName', 'first_name')->ensure('required'), eloquent('familyName', 'last_name'), - )->ensure('required'), + ), // ->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('externalId', 'scim_externalid'), @@ -184,6 +192,7 @@ protected function doRead(&$object, $attributes = []) public function add($value, Model &$object) { + // FIXME - do the same thing here!!!! throw new \Exception("Dunno about fones"); $object->email = $value[0]['value']; } @@ -191,8 +200,41 @@ public function add($value, Model &$object) public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) { - throw new \Exception ("still dunno afbout fones"); - $object->email = $value[0]['value']; + \Log::error("Phones 'value' is: " . print_r($value, true)); + foreach ($value as $phone) { + switch ($phone['type']) { + case 'work': + $object->phone = $phone['value']; + break; + + case 'mobile': + $object->mobile = $phone['value']; + break; + + default: + throw new \Exception("Unknown phone type '{$phone['type']}'"); + } + } +// $object->email = $value[0]['value']; + } + + public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + if ($path->getValuePathFilter() != null) { + \Log::error("value object IS: " . print_r($value, true)); + if ((string)$path == 'phoneNumbers[type eq "mobile"].value') { + \Log::error("YAY!!!!! We are patching an mobile fone! We can do this!"); //FIXME + $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') { + \Log::error("Patching work number!"); + $object->phone = $value; //similar, don't know why, but it is + return; + } + \Log::error("Uh-oh, maybe doing something weirder - path is: $path"); + } + parent::patch($operation, $value, $object, $path, $removeIfNotSet); } })->withSubAttributes( new Constant('value', 'email')->ensure('string'), @@ -257,6 +299,7 @@ public function replace($value, Model &$object, $path = null, $removeIfNotSet = (new class ('$ref') extends Eloquent { protected function doRead(&$object, $attributes = []) { + \Log::error("Checking to see if our 'doRead' even gets a chance to get called?"); return route( 'scim.resource', [ @@ -277,8 +320,7 @@ protected function doRead(&$object, $attributes = []) ), (new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes( eloquent('employeeNumber', 'employee_num')->ensure('nullable'), - new MappedTable('department', 'department', Department::class, 'name'), - //eloquent('manager', 'manager_id'), // FIXME - this is going to be more complicated and map to 'value' + new MappedTable('department', 'department', Department::class, 'department_id', 'name'), (new class('manager') extends Complex { protected function doRead(&$object, $attributes = []) { @@ -291,11 +333,39 @@ protected function doRead(&$object, $attributes = []) 'displayName' => $object->manager->display_name, ]; } + + public function add($value, Model &$object) + { + \Log::error("What type of value is value? " . gettype($value)); + if (is_scalar($value)) { + \Log::error("Weird Microsoft mode - set manager to the \$value and move on with life?"); + $object->manager_id = $value; + } else { + //FIXME - do this properly + \Log::error("Non-Microsoft - Trying to 'ADD' for maanger with value: " . print_r($value, true)); + throw new \Exception("dunno how to do this (add manager)"); + } + } + + // TODO - we keep repeating ourselves between add/replace, we should maybe make our own class + // to make this a nicer shorthand? + public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) + { + \Log::error("What type of value is value? " . gettype($value)); + if (is_scalar($value)) { + \Log::error("Weird Microsoft mode - set manager to the \$value and move on with life?"); + $object->manager_id = $value; + } else { + //FIXME - actualy do this? (Try on one of the other platforms) + \Log::error("Non-Microsoft - Trying to 'ADD' for maanger with value: " . print_r($value, true)); + throw new \Exception("dunno how to do this (add manager)"); + } + } }) // ->withSubAttributes() ... -> ensure() ? ), (new AttributeSchema(self::GROKABILITY, false))->withSubAttributes( - new MappedTable('location', 'location', Location::class, 'name'), - new MappedTable('company', 'company', Company::class, 'name'), + new MappedTable('location', 'location', Location::class, 'location_id', 'name'), + new MappedTable('company', 'company', Company::class, 'company_id', 'name'), ) ), ]; @@ -336,9 +406,9 @@ public function remove($value, &$object, $path = null) ), new Meta('Groups'), (new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes( - eloquent('displayName')->ensure('required', 'min:3', function ($attribute, $value, $fail) { + eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) { // check if group does not exist or if it exists, it is the same group - $group = $this->getGroupClass()::where('displayName', $value)->first(); + $group = $this->getGroupClass()::where('name', $value)->first(); if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) { $fail('The name has already been taken.'); } From ef1a42fff2cff4bc7d681191f924e0081eb90216 Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Wed, 24 Sep 2025 15:57:39 +0100 Subject: [PATCH 5/7] Progress! Got addresses updating correctly --- app/Models/SnipeSCIMConfig.php | 106 ++++++++++++++++++++++----------- 1 file changed, 70 insertions(+), 36 deletions(-) diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 8349c06fddfd..4aa462a82e4e 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -67,6 +67,38 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re } +class UpdatableComplex extends Complex +{ + + public function doWrite($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + throw new \Exception("doWrite is not implemented yet for Operation: $operation on attribute " . $this->getFullKey()); + } + + public function add($value, Model &$object) + { + $this->doWrite("add", $value, $object); + } + + public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + $this->doWrite("replace", $value, $object, $path, $removeIfNotSet); + } + + public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) + { + //FIXME - what to do with $operation?!?!!? + // Also - we don't really have a good repeatable way to do this :/ + // so we're probably going to end up just overriding this anyways :( + $this->doWrite("patch", $value, $object, $path, $removeIfNotSet); + } + + public function remove($value, Model &$object, Path $path = null) + { + $this->doWrite("remove", null, $object, $path); + } +} + class SnipeSCIMConfig { @@ -142,7 +174,7 @@ protected function doRead(&$object, $attributes = []) eloquent('externalId', 'scim_externalid'), // Email chonk - (new class ('emails') extends Complex { + (new class ('emails') extends UpdatableComplex { protected function doRead(&$object, $attributes = []) { return collect([$object->email])->map(function ($email) { @@ -154,14 +186,13 @@ protected function doRead(&$object, $attributes = []) })->toArray(); } - public function add($value, Model &$object) + public function doWrite($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) { - $object->email = $value[0]['value']; - } - - public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) - { - $object->email = $value[0]['value']; + if ($value) { + $object->email = $value[0]['value']; + } else { + $object->email = null; + } } })->withSubAttributes( eloquent('value', 'email')->ensure('email'), @@ -190,15 +221,7 @@ protected function doRead(&$object, $attributes = []) return $phones; } - public function add($value, Model &$object) - { - // FIXME - do the same thing here!!!! - throw new \Exception("Dunno about fones"); - $object->email = $value[0]['value']; - } - - public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) - + public function doWrite($operation, $value, Model &$object, Path $path = null) { \Log::error("Phones 'value' is: " . print_r($value, true)); foreach ($value as $phone) { @@ -215,7 +238,6 @@ public function replace($value, Model &$object, $path = null, $removeIfNotSet = throw new \Exception("Unknown phone type '{$phone['type']}'"); } } -// $object->email = $value[0]['value']; } public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) @@ -245,17 +267,18 @@ public function patch($operation, $value, Model &$object, Path $path = null, $re // addresses chonk (new class ('addresses') extends Complex { + static $addressmap = [ + 'streetAddress' => 'address', + 'locality' => 'city', + 'region' => 'state', + 'postalCode' => 'zip', + 'country' => 'country' + ]; + protected function doRead(&$object, $attributes = []) { - $addressmap = [ - 'streetAddress' => 'address', - 'locality' => 'city', - 'region' => 'state', - 'postalCode' => 'zip', - 'country' => 'country' - ]; $address = []; - foreach ($addressmap as $scim_field => $db_field) { + foreach (self::$addressmap as $scim_field => $db_field) { if ($object->{$db_field}) { $address[$scim_field] = $object->{$db_field}; } @@ -267,20 +290,31 @@ protected function doRead(&$object, $attributes = []) return $address; } - /*** It's possible that the Eloquent mappings in the sub-attributes will handle this? - public function add($value, Model &$object) + public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false) { - throw new \Exception("Dunno about addresses to add"); - $object->email = $value[0]['value']; - } + if ($path->getValuePathFilter() != null) { + \Log::error("path for update $path"); + // 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)) { + throw new \Exception("Unknown path type '$path'"); + } + $type = $matches[1]; + if ($type != 'work') { + throw new \Exception("Unknown object type '$type'"); + } + $attribute = array_key_exists(2, $matches) ? $matches[2] : null; + if (array_key_exists($attribute, self::$addressmap)) { + $object->{self::$addressmap[$attribute]} = $value; + return; + } - public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) - { - throw new \Exception ("still dunno afbout addresses to whatever"); - $object->email = $value[0]['value']; + throw new \Exception("path for update $path"); + } } - * *********/ + })->withSubAttributes( eloquent('streetAddress', 'address'), eloquent('locality', 'city'), From 73f4afa05ee1cbc888e62f2a42a6a9082e67b76d Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Mon, 29 Sep 2025 12:56:23 +0100 Subject: [PATCH 6/7] Got groups support working in Entra ID --- ...09_25_124321_add_external_id_to_groups.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 database/migrations/2025_09_25_124321_add_external_id_to_groups.php diff --git a/database/migrations/2025_09_25_124321_add_external_id_to_groups.php b/database/migrations/2025_09_25_124321_add_external_id_to_groups.php new file mode 100644 index 000000000000..c6e58c0cd11a --- /dev/null +++ b/database/migrations/2025_09_25_124321_add_external_id_to_groups.php @@ -0,0 +1,28 @@ +string('scim_externalid')->nullable()->default(null); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('permission_groups', function (Blueprint $table) { + $table->dropColumn('scim_externalid'); + }); + } +}; From fc4ac029b13710ad0144c55ab05131829ee83168 Mon Sep 17 00:00:00 2001 From: Brady Wetherington Date: Mon, 29 Sep 2025 12:56:59 +0100 Subject: [PATCH 7/7] Added the actual files to make that previous statement true --- app/Models/Group.php | 6 ++++++ app/Models/SCIMUser.php | 7 +++++++ app/Models/SnipeSCIMConfig.php | 1 + 3 files changed, 14 insertions(+) diff --git a/app/Models/Group.php b/app/Models/Group.php index 9f4f2e2e5677..b0991f4a9a3f 100755 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -59,6 +59,12 @@ public function users() return $this->belongsToMany(\App\Models\User::class, 'users_groups'); } + /* this is just a shim for SCIM to work */ + public function members() + { + return $this->users(); + } + /** * Get the user that created the group * diff --git a/app/Models/SCIMUser.php b/app/Models/SCIMUser.php index f261fea8b648..87b023dc5843 100644 --- a/app/Models/SCIMUser.php +++ b/app/Models/SCIMUser.php @@ -13,4 +13,11 @@ public function __construct(array $attributes = []) $attributes['password'] = $this->noPassword(); parent::__construct($attributes); } + + // Have to re-define this here because Eloquent will try to 'guess' a foreign key of s_c_i_m_user_id + // from SCIMUser + public function groups() + { + return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id'); + } } \ No newline at end of file diff --git a/app/Models/SnipeSCIMConfig.php b/app/Models/SnipeSCIMConfig.php index 4aa462a82e4e..a78bd6632c2b 100644 --- a/app/Models/SnipeSCIMConfig.php +++ b/app/Models/SnipeSCIMConfig.php @@ -438,6 +438,7 @@ public function remove($value, &$object, $path = null) } } ), + eloquent('externalId', 'scim_externalid'), new Meta('Groups'), (new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes( eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) {