getSubNode($schemaUrn); return ($schemaNode instanceof AttributeSchema) ? $schemaNode->getSubNode($attrName) : null; } public function add($value, Model &$object) { $match = false; $this->dirty = true; if ($this->mutability == 'readOnly') { return; } foreach ($value as $key => $v) { if (is_numeric($key)) { throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey()); } $path = Parser::parse($key); if ($path->isNotEmpty()) { $attributeNames = $path->getAttributePathAttributes(); $schema = $path->getAttributePath()?->path?->schema; $path = $path->shiftAttributePathAttributes(); $subNode = ($schema !== null) ? $this->findInSchema($schema, $attributeNames[0]) : null; if ($subNode === null) { $subNode = $this->getSubNode($attributeNames[0]); } $match = true; $newValue = $v; if ($path->isNotEmpty()) { $newValue = [implode('.', $path->getAttributePathAttributes()) => $v]; } if ($subNode !== null) { $subNode->add($newValue, $object); } } } if (! $match && $this->parent == null) { foreach ($this->subAttributes as $attribute) { if ($attribute instanceof AttributeSchema) { $attribute->add($value, $object); } } } } public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { $this->dirty = true; if ($this->mutability == 'readOnly') { return; } foreach ($value as $key => $v) { if (is_numeric($key)) { throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey()); } $subNode = null; $path = null; $key = trim($key); if (strpos($key, ':') !== false) { $parsed = Parser::parse($key); $schemaUrn = $parsed->getAttributePath()?->path?->schema; $attrName = $parsed->getAttributePathAttributes()[0] ?? null; if ($schemaUrn !== null && $attrName !== null) { $subNode = $this->findInSchema($schemaUrn, $attrName); } if ($subNode === null) { $subNode = $this->getSubNode($key); } } else { $path = Parser::parse($key); if ($path->isNotEmpty()) { $attributeNames = $path->getAttributePathAttributes(); $path = $path->shiftAttributePathAttributes(); $subNode = $this->getSubNode($attributeNames[0] ?? $path->getAttributePath()?->path?->schema); } } if ($subNode !== null) { $newValue = $v; if ($path !== null && $path->isNotEmpty()) { $newValue = [implode('.', $path->getAttributePathAttributes()) => $v]; } $subNode->replace($newValue, $object, $path); } } if ($subNode == null && $this->parent == null) { foreach ($this->subAttributes as $attribute) { if ($attribute instanceof AttributeSchema) { $attribute->replace($value, $object, $path); } } } if ($removeIfNotSet) { foreach ($this->subAttributes as $attribute) { if (! $attribute->isDirty()) { $attribute->remove(null, $object); } } } } } // Azure Entra ID sends op=replace with path=members and only the single user being provisioned, // not the full member list. Using sync() would wipe all other members on every user update. // Override replace() to use syncWithoutDetaching() so it behaves like add(); op=remove with a // filter path still handles explicit removals correctly. class SnipeMutableCollection extends MutableCollection { public function replace($value, Model &$object, ?Path $path = null) { $this->add($value, $object); } // POST /scim/v2/Groups with an initial `members` array arrives here with // an unsaved parent: the SCIM library runs attribute mappers before it // calls save() on the new resource. Letting parent::add() fire on an // unsaved model makes Eloquent's pivot INSERT bind NULL for group_id // (since $parent->getKey() is null) and blow up with a MySQL 23000 integrity // violation. Persist first so the pivot row has a real parent id, then // stash the object into the request so the displayName uniqueness closure // (which re-runs after mapping) can recognize its own row instead of // treating it as an existing name collision. public function add($value, Model &$object) { if (! $object->exists) { $object->save(); request()->attributes->set('scim_in_flight_resource', $object); } parent::add($value, $object); } } class MappedTable extends Attribute { public function __construct( private string $scim_attribute_name, private string $relationship_name, private string $relationship_class, private string $relationship_id_field, private string $relationship_field ) { 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) { $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_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) { $object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null; } } // Company is stored only in the company_user pivot, not company_id. Read from the pivot // and sync it on write. For new users (not yet saved) defer the sync via a saved() callback. class SCIMCompanyAttribute extends MappedTable { protected function doRead(&$object, $attributes = []) { return $object->companies->first()?->name; } private function applyCompany(?int $companyId, Model &$object): void { $ids = $companyId ? [$companyId] : []; if ($object->exists) { $object->companies()->sync($ids); } else { $object->saved(fn () => $object->companies()->sync($ids)); } } public function add($value, Model &$object) { $this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object); } public function replace($value, Model &$object, $path = null, $removeIfNotSet = false) { $this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object); } public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { $this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object); } } class EloquentWithRemove extends Eloquent { public function remove($value, Model &$object, ?Path $path = null) { $object->{$this->attribute} = null; } } class UpdatableComplex extends Complex { 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()); } public function add($value, Model &$object) { $this->doWrite('add', null, $value, $object); } public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { $this->doWrite('replace', null, $value, $object, $path, $removeIfNotSet); } public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { $this->doWrite('patch', $operation, $value, $object, $path, $removeIfNotSet); } public function remove($value, Model &$object, ?Path $path = null) { $this->doWrite('remove', null, null, $object, $path); } } class SnipeSCIMConfig { public function __construct() {} public function getConfigForResource($name) { $result = $this->getConfig(); return @$result[$name]; } public function getGroupClass() { return Group::class; } 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, 'singular' => 'User', // eager loading 'withRelations' => [], 'description' => 'User Account', 'map' => (new SnipeRootComplex)->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; } 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')->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('externalId', 'scim_externalid'), // Email chonk (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, ]; })->toArray(); } public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { if ($value) { try { if (is_string($value)) { $object->email = $value; // Weird MS-SCIM stuff :/ } else { $object->email = $value[0]['value']; } } catch (\Throwable $e) { \Log::debug($e); 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? new Constant('type', 'work'), (new Constant('primary', true))->ensure('boolean') )->ensure('array') ->setMultiValued(true), // phone chonk (new class('phoneNumbers') extends UpdatableComplex { 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; } public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false) { \Log::debug("Phones 'value' is: ".print_r($value, true)); try { 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? return; } if ((string) $path == 'phoneNumbers[type eq "work"].value') { $object->phone = $value; // similar, don't know why, but it is return; } } parent::patch($subop, $value, $object, $path, $removeIfNotSet); return; } foreach ($value as $phone) { switch ($phone['type']) { case 'work': $object->phone = $phone['value']; break; case 'mobile': $object->mobile = $phone['value']; break; default: 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); } } })->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 )->ensure('array') ->setMultiValued(true), // addresses chonk (new class('addresses') extends UpdatableComplex { public static $addressmap = [ 'streetAddress' => 'address', 'locality' => 'city', 'region' => 'state', 'postalCode' => 'zip', 'country' => 'country', ]; protected function doRead(&$object, $attributes = []) { $address = []; foreach (self::$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; } return [$address]; } 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) { \Log::debug("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 SCIMException("Unknown path type '$path'", 422); } $type = $matches[1]; if ($type != 'work') { throw new SCIMException("Unknown object type '$type'", 422); } $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'), eloquent('region', 'state'), eloquent('postalCode', 'zip'), eloquent('country', 'country'), new Constant('type', 'other'), (new Constant('primary', true))->ensure('boolean') )->ensure('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', ] ); } }), 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, 'department_id', 'name'), (new class('manager') extends UpdatableComplex { 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, ]; } public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false) { \Log::debug('What type of value is value? '.gettype($value)); $manager_id = null; if (is_null($value)) { // nothing to do } elseif (is_scalar($value)) { \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 // according to the spec it's _recommended_ to do: // $ref - which should be the URI of the manager // extract ID from URL, jam it in? $url = $value['$ref']; $users_prefix = route('scim.resources', ['resourceType' => 'User']).'/'; if (str_starts_with($url, $users_prefix)) { $manager_id = substr($url, strlen($users_prefix)); } } elseif (array_key_exists('value', $value)) { // this is _Snipe-IT_'s ID being passed as 'value' I believe? // if you use the 'managerId' field in Okta, you get: // [value] => 9999999 // 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)); 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); } }) // ->withSubAttributes() ... -> ensure() ? ), (new AttributeSchema(self::GROKABILITY, false))->withSubAttributes( new MappedTable('location', 'location', Location::class, 'location_id', 'name'), new SCIMCompanyAttribute('company', 'company', Company::class, 'company_id', '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 EloquentWithRemove('externalId', 'scim_externalid'), new Meta('Groups'), (new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes( 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('name', $value)->first(); if (! $group) { return; } // PATCH/PUT: the group being edited is route-bound. $routeResource = request()->route('resourceObject'); if ($routeResource && $group->id == $routeResource->id) { return; } // POST create with an initial members[] triggers SnipeMutableCollection // to save the parent early (so the pivot INSERT has a valid group_id), // which puts a row in the DB before this closure re-runs. Recognize the // just-saved row as ours so validation doesn't false-positive. $inFlight = request()->attributes->get('scim_in_flight_resource'); if ($inFlight && $group->id == $inFlight->id) { return; } $fail('The name has already been taken.'); }), (new SnipeMutableCollection('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(), ]; } }