Merge remote-tracking branch 'origin/develop'
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class SCIMUser extends User
|
||||
{
|
||||
protected $table = 'users';
|
||||
@@ -21,4 +23,9 @@ class SCIMUser extends User
|
||||
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'company_user', 'user_id', 'company_id');
|
||||
}
|
||||
|
||||
}
|
||||
+185
-10
@@ -12,6 +12,7 @@ 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\Parser;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -31,11 +32,137 @@ function eloquent($name, $attribute = null): Attribute
|
||||
return new Eloquent($name, $attribute);
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
// Extends Complex to handle schema-qualified attribute keys in PATCH add/replace operations.
|
||||
// Azure Entra ID sends PATCH without a "path" field, putting the full URN as the value dict key
|
||||
// e.g. {"op":"add","value":{"urn:...grokability...:location":"Head Office"}}.
|
||||
// The upstream library's add() only searches the default (core) schema, silently dropping grokability attrs.
|
||||
class SnipeRootComplex extends Complex
|
||||
{
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
private function findInSchema(string $schemaUrn, string $attrName): ?object
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
$schemaNode = $this->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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +173,8 @@ class MappedTable extends Attribute
|
||||
private string $relationship_name,
|
||||
private string $relationship_class,
|
||||
private string $relationship_id_field,
|
||||
private string $relationship_field)
|
||||
{
|
||||
private string $relationship_field
|
||||
) {
|
||||
parent::__construct($this->scim_attribute_name);
|
||||
}
|
||||
|
||||
@@ -72,6 +199,50 @@ class MappedTable extends Attribute
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -132,7 +303,7 @@ class SnipeSCIMConfig
|
||||
'withRelations' => [],
|
||||
'description' => 'User Account',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
'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)
|
||||
@@ -190,7 +361,11 @@ class SnipeSCIMConfig
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
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);
|
||||
@@ -299,7 +474,7 @@ class SnipeSCIMConfig
|
||||
$address['primary'] = true;
|
||||
}
|
||||
|
||||
return $address;
|
||||
return [$address];
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
@@ -422,7 +597,7 @@ class SnipeSCIMConfig
|
||||
),
|
||||
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
|
||||
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
|
||||
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
|
||||
new SCIMCompanyAttribute('company', 'company', Company::class, 'company_id', 'name'),
|
||||
)
|
||||
),
|
||||
];
|
||||
@@ -471,7 +646,7 @@ class SnipeSCIMConfig
|
||||
$fail('The name has already been taken.');
|
||||
}
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
(new SnipeMutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class('$ref') extends Eloquent
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user