diff --git a/app/Classes/LDAP/Attribute/Factory.php b/app/Classes/LDAP/Attribute/Factory.php index ffec3f99..a1005084 100644 --- a/app/Classes/LDAP/Attribute/Factory.php +++ b/app/Classes/LDAP/Attribute/Factory.php @@ -50,6 +50,7 @@ class Factory 'uniquemember' => Member::class, 'usercertificate' => Binary\Certificate::class, 'userpassword' => Password::class, + 'pwdreset' => PwdReset::class, ]; /** diff --git a/app/Classes/LDAP/Attribute/PwdReset.php b/app/Classes/LDAP/Attribute/PwdReset.php new file mode 100644 index 00000000..da79642b --- /dev/null +++ b/app/Classes/LDAP/Attribute/PwdReset.php @@ -0,0 +1,119 @@ +_is_internal = FALSE; + } + + /** + * Override properties to handle NULL schema gracefully for this virtual attribute + */ + public function __get(string $key): mixed + { + // Handle schema-based properties if schema exists + if ($this->schema !== NULL) { + switch ($key) { + case 'description': + case 'name': + case 'name_lc': + case 'is_editable': + case 'required_by': + case 'used_in': + return parent::__get($key); + } + } + + // Fallback values when schema is NULL (operational attribute not in LDAP schema) + return match ($key) { + 'description' => 'Password Reset Flag - Forces user to change password at next login (ppolicy overlay)', + 'name' => 'pwdReset', + 'name_lc' => 'pwdreset', + 'is_editable' => TRUE, + 'required_by' => collect(), + 'used_in' => collect(), + default => parent::__get($key), + }; + } + + /* METHODS */ + + public function isDirty(): bool + { + $old = $this->values_old->dot()->filter(fn($item)=>! is_null($item) && $item !== ''); + $new = $this->values->dot()->filter(fn($item)=>! is_null($item) && $item !== ''); + + return $old->count() !== $new->count() || $old->diff($new)->count() !== 0; + } + + /** + * pwdReset is an operational attribute (ppolicy overlay) that: + * - Can only be set to TRUE (server manages FALSE/removal automatically) + * - When set to TRUE, user must change password at next login + * - When set to FALSE we keep the attribute present with value FALSE to remain editable + */ + public function getDirty(): array + { + if (! $this->isDirty()) + return []; + + $normalized = $this->values + ->map(fn($values)=>array_values(array_map(fn($v)=>strtoupper(trim($v)) === 'TRUE' ? 'TRUE' : 'FALSE',$values))); + + // If any TRUE values exist, send only the TRUEs; otherwise send FALSE to keep attribute present + $trueValues = $normalized + ->map(fn($values)=>array_values(array_filter($values,fn($v)=>$v === 'TRUE'))) + ->filter(fn($values)=>count($values) > 0); + + return $trueValues->isNotEmpty() + ? [$this->name_lc => $trueValues->toArray()] + : [$this->name_lc => [Entry::TAG_NOTAG => ['FALSE']]]; + } + + public function render_item_old(string $dotkey): ?string + { + $value = $this->values_old->dot()->get($dotkey); + + if ($value === NULL || $value === '') + return NULL; + + return strtoupper($value) === 'TRUE' ? 'TRUE' : 'FALSE'; + } + + public function render_item_new(string $dotkey): ?string + { + $value = $this->values->dot()->get($dotkey); + + if ($value === NULL || $value === '') + return NULL; + + return strtoupper($value) === 'TRUE' ? 'TRUE' : 'FALSE'; + } + + public function render(string $attrtag,int $index,?View $view=NULL,bool $edit=FALSE,bool $editable=FALSE,bool $new=FALSE,bool $updated=FALSE,?Template $template=NULL): View + { + return parent::render( + attrtag: $attrtag, + index: $index, + view: view('components.attribute.value.pwdreset'), + edit: $edit, + editable: $editable, + new: $new, + updated: $updated, + template: $template); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 116e36b1..d8232baf 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -71,12 +71,19 @@ public function add(EntryAddRequest $request): \Illuminate\View\View $o->{$ao->name} = [Entry::TAG_NOTAG=>['']]; } + // Add pwdReset if defined in template (it's an operational attribute not in objectclass) + if ($template->attributes->keys()->map('strtolower')->contains('pwdreset')) + $o->pwdReset = [Entry::TAG_NOTAG=>['FALSE']]; + } elseif (count($x=collect(old('objectclass',$request->validated('objectclass')))->dot()->filter())) { $o->objectclass = Arr::undot($x); // Also add in our required attributes foreach ($o->getAvailableAttributes()->filter(fn($item)=>$item->is_must && ($item->name_lc !== 'objectclass')) as $ao) $o->{$ao->name} = [Entry::TAG_NOTAG=>['']]; + + // Add ppolicy virtual attributes for user entries + $this->addPPolicyAttributesIfNeeded($o); } } @@ -519,4 +526,21 @@ public function update_pending(EntryRequest $request): \Illuminate\Http\Redirect abort(500,$e->getMessage()); } } -} \ No newline at end of file + + /** + * Add ppolicy virtual attributes to user entries if applicable + * + * @param Entry $o + * @return void + */ + private function addPPolicyAttributesIfNeeded(Entry $o): void + { + // Only add for user entries (uses Entry::isUserEntry()) + if (! $o->isUserEntry()) + return; + + // Add pwdReset attribute with default value FALSE if not already present + if (! $o->hasAttribute('pwdreset')) + $o->pwdReset = [Entry::TAG_NOTAG=>['FALSE']]; + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d818652a..d302f719 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -48,7 +48,8 @@ public function frame(Request $request,?Collection $old=NULL): \Illuminate\View\ $o->setDN($key['dn']); } elseif ($key['dn']) { - $o = config('server')->fetch($key['dn']); + // Request ppolicy operational attributes explicitly when viewing an entry + $o = config('server')->fetch($key['dn'], ['*','+','pwdReset']); } if ($o) { diff --git a/app/Ldap/Entry.php b/app/Ldap/Entry.php index 9020b414..424f8428 100644 --- a/app/Ldap/Entry.php +++ b/app/Ldap/Entry.php @@ -357,6 +357,15 @@ private function getAttributesAsObjects(): Collection $result->put($attribute,$o); } + // Ensure ppolicy operational attributes exist for user entries so they stay editable even when absent on the server + if ($this->isUserEntry() && (! $result->has('pwdreset'))) + $result->put('pwdreset',Factory::create( + dn: $this->dn, + attribute: 'pwdReset', + values: [self::TAG_NOTAG=>['FALSE']], + oc: $entry_oc, + )); + $sort = collect(config('pla.attr_display_order',[]))->map(fn($item)=>strtolower($item)); // Order the attributes @@ -508,8 +517,26 @@ public function getOtherTags(): Collection */ public function getMissingAttributes(): Collection { - return $this->getAvailableAttributes() + $missing = $this->getAvailableAttributes() ->filter(fn($a)=>(! $this->getVisibleAttributes()->contains(fn($b)=>($a->name === $b->name)))); + + // Add ppolicy operational attributes for user entries + if ($this->isUserEntry()) { + $ppolicyAttrs = ['pwdReset']; + + foreach ($ppolicyAttrs as $attrName) { + $attrLower = strtolower($attrName); + + // Only add if not already present in entry or missing list + if (! $this->hasAttribute($attrLower) && ! $missing->contains(fn($item)=>strtolower($item->name) === $attrLower)) { + $schema = config('server')->schema('attributetypes',$attrName); + if ($schema) + $missing->push($schema); + } + } + } + + return $missing; } /** @@ -534,6 +561,20 @@ public function hasAttribute(int|string $key): bool ->has($key); } + /** + * Check if this entry is a user-like entry based on objectclasses + * + * @return bool + */ + public function isUserEntry(): bool + { + static $userObjectClasses = ['posixaccount','inetorgperson','person','account','organizationalperson']; + + $entryOCs = $this->getObject('objectclass')?->tagValues()->map(fn($item)=>strtolower(trim($item))) ?? collect(); + + return $entryOCs->intersect($userObjectClasses)->isNotEmpty(); + } + /** * Did this query generate a size limit exception * diff --git a/resources/views/components/attribute/value/pwdreset.blade.php b/resources/views/components/attribute/value/pwdreset.blade.php new file mode 100644 index 00000000..b4df6c5f --- /dev/null +++ b/resources/views/components/attribute/value/pwdreset.blade.php @@ -0,0 +1,59 @@ + + +