From 39df28921dda733e1cccc36c8702152b59258892 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 13:44:24 +0100 Subject: [PATCH 01/22] Gate to superuser --- app/Http/Controllers/Api/UsersController.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 915cb54c49..456e77606a 100644 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -1043,7 +1043,13 @@ class UsersController extends Controller */ public function syncLdapUsers(Request $request) { - $this->authorize('update', User::class); + // Superuser-only: a bulk LDAP sync surfaces users from across + // the entire directory (all companies, all OUs), so anyone with + // "users.edit" but no full-directory access shouldn't be able + // to run it or read the summary that lists them. + if (! auth()->user()?->isSuperUser()) { + abort(403); + } // Call Artisan LDAP import command. Artisan::call('snipeit:ldap-sync', ['--location_id' => $request->input('location_id'), '--json_summary' => true]); From 2e677f8d850bec3110651faa480364260e939992 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 13:58:43 +0100 Subject: [PATCH 02/22] Refactor isDeletable to be re-usable via LDAP sync --- app/Models/User.php | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/Models/User.php b/app/Models/User.php index b697ebbdea..e397d2861c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -657,15 +657,28 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo */ public function isDeletable() { - return Gate::allows('delete', $this) - && (($this->assets_count ?? $this->assets()->count()) === 0) + && $this->hasNoAssignmentBlockers() + && ($this->deleted_at == ''); + } + + /** + * The association-blocker half of isDeletable(): true only when the + * user has no assigned assets / accessories / licenses / consumables + * and isn't managing any users or locations. Split out from + * isDeletable() so scripts running outside a request context, + * Artisan `snipeit:ldap-sync --delete` flow in particular, can share + * the exact same rule without needing an authenticated Gate user to + * satisfy the delete-permission check. + */ + public function hasNoAssignmentBlockers(): bool + { + return (($this->assets_count ?? $this->assets()->count()) === 0) && (($this->accessories_count ?? $this->accessories()->count()) === 0) && (($this->licenses_count ?? $this->licenses()->count()) === 0) && (($this->consumables_count ?? $this->consumables()->count()) === 0) && (($this->manages_users_count ?? $this->managesUsers()->count()) === 0) - && (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0) - && ($this->deleted_at == ''); + && (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0); } /** From 486eabb4c1ec317d21ee7560d25839c58d1ce66d Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 13:59:05 +0100 Subject: [PATCH 03/22] Return a field map that can be re-used --- app/Models/Ldap.php | 123 +++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 59 deletions(-) diff --git a/app/Models/Ldap.php b/app/Models/Ldap.php index c4a20767e9..3d29f8408d 100644 --- a/app/Models/Ldap.php +++ b/app/Models/Ldap.php @@ -311,51 +311,56 @@ class Ldap extends Model * @param $ldapatttibutes * @return array|bool */ + /** + * Single source of truth for the LDAP-attribute mapping. Internal + * key (used across parseAndMapLdapAttributes' $item, the User field + * writes in applyLdapAttributesToUser, and LdapSync's specific + * lookups) => LDAP attribute name pulled from Settings. A null / '' + * value means the admin left that particular mapping unconfigured. + * + * @return array + */ + public static function attributeMap(): array + { + $settings = Setting::getSettings(); + + return [ + 'username' => $settings->ldap_username_field, + 'first_name' => $settings->ldap_fname_field, + 'last_name' => $settings->ldap_lname_field, + 'employee_number' => $settings->ldap_emp_num, + 'display_name' => $settings->ldap_display_name, + 'email' => $settings->ldap_email, + 'phone' => $settings->ldap_phone_field, + 'mobile' => $settings->ldap_mobile, + 'jobtitle' => $settings->ldap_jobtitle, + 'address' => $settings->ldap_address, + 'city' => $settings->ldap_city, + 'state' => $settings->ldap_state, + 'zip' => $settings->ldap_zip, + 'country' => $settings->ldap_country, + 'department' => $settings->ldap_dept, + 'location' => $settings->ldap_location, + 'manager' => $settings->ldap_manager, + // LdapSync-only: active_flag is consumed by the + // active-directory sync logic in the console command. + // parseAndMapLdapAttributes does not surface it because + // the first-login path has no use for it (the user just + // successfully bound to LDAP, they're active by definition). + 'active_flag' => $settings->ldap_active_flag, + ]; + } + public static function parseAndMapLdapAttributes($ldapattributes) { - // Get LDAP attribute config. The settings column names here are - // the same ones LdapSync's $ldap_map reads, so this parser and - // the bulk-sync command see identical field lookups. - $settings = Setting::getSettings(); - $ldap_result_username = $settings->ldap_username_field; - $ldap_result_emp_num = $settings->ldap_emp_num; - $ldap_result_last_name = $settings->ldap_lname_field; - $ldap_result_first_name = $settings->ldap_fname_field; - $ldap_result_display_name = $settings->ldap_display_name; - $ldap_result_email = $settings->ldap_email; - $ldap_result_phone = $settings->ldap_phone_field; - $ldap_result_mobile = $settings->ldap_mobile; - $ldap_result_jobtitle = $settings->ldap_jobtitle; - $ldap_result_address = $settings->ldap_address; - $ldap_result_city = $settings->ldap_city; - $ldap_result_state = $settings->ldap_state; - $ldap_result_zip = $settings->ldap_zip; - $ldap_result_country = $settings->ldap_country; - $ldap_result_location = $settings->ldap_location; - $ldap_result_dept = $settings->ldap_dept; - $ldap_result_manager = $settings->ldap_manager; - - // Get LDAP user data. Kept in the same shape LdapSync's per-user - // $item array uses so the two paths stay comparable when this - // one grows. $item = []; - $item['username'] = $ldapattributes[$ldap_result_username][0] ?? ''; - $item['employee_number'] = $ldapattributes[$ldap_result_emp_num][0] ?? ''; - $item['lastname'] = $ldapattributes[$ldap_result_last_name][0] ?? ''; - $item['firstname'] = $ldapattributes[$ldap_result_first_name][0] ?? ''; - $item['display_name'] = $ldapattributes[$ldap_result_display_name][0] ?? ''; - $item['email'] = $ldapattributes[$ldap_result_email][0] ?? ''; - $item['telephone'] = $ldapattributes[$ldap_result_phone][0] ?? ''; - $item['mobile'] = $ldapattributes[$ldap_result_mobile][0] ?? ''; - $item['jobtitle'] = $ldapattributes[$ldap_result_jobtitle][0] ?? ''; - $item['address'] = $ldapattributes[$ldap_result_address][0] ?? ''; - $item['city'] = $ldapattributes[$ldap_result_city][0] ?? ''; - $item['state'] = $ldapattributes[$ldap_result_state][0] ?? ''; - $item['zip'] = $ldapattributes[$ldap_result_zip][0] ?? ''; - $item['country'] = $ldapattributes[$ldap_result_country][0] ?? ''; - $item['department'] = $ldapattributes[$ldap_result_dept][0] ?? ''; - $item['manager'] = $ldapattributes[$ldap_result_manager][0] ?? ''; - $item['location'] = $ldapattributes[$ldap_result_location][0] ?? ''; + foreach (self::attributeMap() as $key => $ldapAttr) { + // active_flag is LdapSync's concern. See attributeMap(). + if ($key === 'active_flag') { + continue; + } + $item[$key] = $ldapAttr ? ($ldapattributes[$ldapAttr][0] ?? '') : ''; + } $item['locale'] = app()->getLocale(); return $item; @@ -381,53 +386,53 @@ class Ldap extends Model */ public static function applyLdapAttributesToUser(User $user, array $ldapAttr): void { - $settings = Setting::getSettings(); + $map = self::attributeMap(); // Always-written identity fields. These have no per-field gate // because Snipe-IT considers username / first name / last name / - // email load-bearing for every user — if a mapping's blank the + // email load-bearing for every user, if a mapping's blank the // LDAP payload just gives us an empty string, matching the // pre-fix behavior on the create path. $user->username = $ldapAttr['username']; - $user->first_name = $ldapAttr['firstname']; - $user->last_name = $ldapAttr['lastname']; + $user->first_name = $ldapAttr['first_name']; + $user->last_name = $ldapAttr['last_name']; $user->email = $ldapAttr['email']; - if ($settings->ldap_display_name != '') { + if ($map['display_name'] != '') { $user->display_name = $ldapAttr['display_name']; } - if ($settings->ldap_emp_num != '') { + if ($map['employee_number'] != '') { $user->employee_num = e($ldapAttr['employee_number']); } - if ($settings->ldap_phone_field != '') { - $user->phone = $ldapAttr['telephone']; + if ($map['phone'] != '') { + $user->phone = $ldapAttr['phone']; } - if ($settings->ldap_mobile != '') { + if ($map['mobile'] != '') { $user->mobile = $ldapAttr['mobile']; } - if ($settings->ldap_jobtitle != '') { + if ($map['jobtitle'] != '') { $user->jobtitle = $ldapAttr['jobtitle']; } - if ($settings->ldap_address != '') { + if ($map['address'] != '') { $user->address = $ldapAttr['address']; } - if ($settings->ldap_city != '') { + if ($map['city'] != '') { $user->city = $ldapAttr['city']; } - if ($settings->ldap_state != '') { + if ($map['state'] != '') { $user->state = $ldapAttr['state']; } - if ($settings->ldap_zip != '') { + if ($map['zip'] != '') { $user->zip = $ldapAttr['zip']; } - if ($settings->ldap_country != '') { + if ($map['country'] != '') { $user->country = $ldapAttr['country']; } - if ($settings->ldap_dept != '' && $ldapAttr['department'] !== '') { + if ($map['department'] != '' && $ldapAttr['department'] !== '') { $department = Department::firstOrCreate(['name' => $ldapAttr['department']]); $user->department_id = $department->id; } - if ($settings->ldap_location != '' && $ldapAttr['location'] !== '') { + if ($map['location'] != '' && $ldapAttr['location'] !== '') { $location = Location::firstOrCreate(['name' => $ldapAttr['location']]); $user->location_id = $location->id; } From ae20155679e6aea0a7c7bb3a1d72937a088365ca Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 13:59:27 +0100 Subject: [PATCH 04/22] Restrict manual sync to superusers --- .../Users/LDAPImportController.php | 15 +- resources/views/users/ldap.blade.php | 186 ++++++++---------- 2 files changed, 98 insertions(+), 103 deletions(-) diff --git a/app/Http/Controllers/Users/LDAPImportController.php b/app/Http/Controllers/Users/LDAPImportController.php index 84a2214942..48d2a15568 100644 --- a/app/Http/Controllers/Users/LDAPImportController.php +++ b/app/Http/Controllers/Users/LDAPImportController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Users; use App\Http\Controllers\Controller; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\View\View; use Illuminate\Http\RedirectResponse; @@ -28,8 +27,13 @@ class LDAPImportController extends Controller */ public function create() { - // I guess this prolly oughtta... I dunno. Do something? - $this->authorize('update', User::class); + // Superuser-only: a bulk LDAP sync surfaces users from across + // the entire directory (all companies, all OUs), so anyone with + // "users.edit" but no full-directory access shouldn't be able + // to run it or read the summary that lists them. + if (! auth()->user()?->isSuperUser()) { + abort(403); + } try { // $this->ldap->connect(); I don't think this actually exists in LdapAd.php, and we don't really 'persist' LDAP connections anyways...right? } catch (\Exception $e) { @@ -52,7 +56,10 @@ class LDAPImportController extends Controller */ public function store(Request $request) { - $this->authorize('update', User::class); + // See create() for the superuser-only rationale. + if (! auth()->user()?->isSuperUser()) { + abort(403); + } // Call Artisan LDAP import command. Artisan::call('snipeit:ldap-sync', ['--location_id' => $request->input('location_id'), '--json_summary' => true]); diff --git a/resources/views/users/ldap.blade.php b/resources/views/users/ldap.blade.php index dcec3060c1..eac9ec3a5a 100644 --- a/resources/views/users/ldap.blade.php +++ b/resources/views/users/ldap.blade.php @@ -8,110 +8,98 @@ {{-- Page content --}} @section('content') -
-
- @if ($snipeSettings->ldap_enabled == 0) - {{ trans('admin/users/message.ldap_not_configured') }} - @else + + @if ($snipeSettings->ldap_enabled == 0) + {{ trans('admin/users/message.ldap_not_configured') }} + @else + + + +

+ + {!! trans('admin/users/general.ldap_sync_intro', ['link' => 'https://snipe-it.readme.io/docs/ldap-sync#/']) !!} + +

+
-
-
- {{csrf_field()}} -
-
-

- - - {!! trans('admin/users/general.ldap_sync_intro', ['link' => 'https://snipe-it.readme.io/docs/ldap-sync#/']) !!} - -

+ + + + - - - -
- - @endif -
-
+
+ + + {{ trans('general.synchronize') }} + +
+
+ + + + @endif + @if (Session::get('summary')) -
-
- -
-
-

- {{ trans('general.sync_results') }} -

-
- -
- - - - - - - - - - - - - - - - @foreach (Session::get('summary') as $entry) - - - - - - - - - - - @endforeach - -
{{ trans('general.id') }}{{ trans('general.username') }}{{ trans('admin/users/table.display_name') }}{{ trans('general.employee_number') }}{{ trans('general.first_name') }}{{ trans('general.last_name') }}{{ trans('general.email') }}{{ trans('general.notes') }}
{{ (array_key_exists('id', $entry)) ? $entry['id'] : '' }}{{ $entry['username'] }}{{ $entry['display_name'] }}{{ $entry['employee_number'] }}{{ $entry['firstname'] }}{{ $entry['lastname'] }}{{ $entry['email'] }} - @if ($entry['status']=='success') - {!! $entry['note'] !!} - @else - {!! $entry['note'] !!} - @endif -
-
-
-
-
+ + + + + + + + + + + + + + + + + @foreach (Session::get('summary') as $entry) + + + + + + + + + + + @endforeach + +
{{ trans('general.id') }}{{ trans('general.username') }}{{ trans('admin/users/table.display_name') }}{{ trans('general.employee_number') }}{{ trans('general.first_name') }}{{ trans('general.last_name') }}{{ trans('general.email') }}{{ trans('general.notes') }}
{{ (array_key_exists('id', $entry)) ? $entry['id'] : '' }}{{ $entry['username'] }}{{ $entry['display_name'] }}{{ $entry['employee_number'] }}{{ $entry['first_name'] }}{{ $entry['last_name'] }}{{ $entry['email'] }} + @if ($entry['status']=='success') + {!! $entry['note'] !!} + @else + {!! $entry['note'] !!} + @endif +
+
+
@endif @stop From 9b38bb3e07dd19826ff15f0fb9f0f7087081f3ff Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:01:05 +0100 Subject: [PATCH 05/22] Use the new attribute map and isDeleteable gate --- app/Console/Commands/LdapSync.php | 181 ++++++++---------------------- 1 file changed, 49 insertions(+), 132 deletions(-) diff --git a/app/Console/Commands/LdapSync.php b/app/Console/Commands/LdapSync.php index a51a0e16c3..201a06a8dc 100644 --- a/app/Console/Commands/LdapSync.php +++ b/app/Console/Commands/LdapSync.php @@ -3,7 +3,6 @@ namespace App\Console\Commands; use App\Models\Asset; -use App\Models\Department; use App\Models\Group; use App\Models\Ldap; use App\Models\Location; @@ -55,27 +54,12 @@ class LdapSync extends Command ini_set('max_execution_time', env('LDAP_TIME_LIM', 600)); // 600 seconds = 10 minutes ini_set('memory_limit', env('LDAP_MEM_LIM', '500M')); - // Map the LDAP attributes to the Snipe-IT user fields. - $ldap_map = [ - 'username' => Setting::getSettings()->ldap_username_field, - 'last_name' => Setting::getSettings()->ldap_lname_field, - 'first_name' => Setting::getSettings()->ldap_fname_field, - 'active_flag' => Setting::getSettings()->ldap_active_flag, - 'emp_num' => Setting::getSettings()->ldap_emp_num, - 'email' => Setting::getSettings()->ldap_email, - 'phone' => Setting::getSettings()->ldap_phone_field, - 'mobile' => Setting::getSettings()->ldap_mobile, - 'jobtitle' => Setting::getSettings()->ldap_jobtitle, - 'address' => Setting::getSettings()->ldap_address, - 'city' => Setting::getSettings()->ldap_city, - 'state' => Setting::getSettings()->ldap_state, - 'zip' => Setting::getSettings()->ldap_zip, - 'country' => Setting::getSettings()->ldap_country, - 'location' => Setting::getSettings()->ldap_location, - 'dept' => Setting::getSettings()->ldap_dept, - 'manager' => Setting::getSettings()->ldap_manager, - 'display_name' => Setting::getSettings()->ldap_display_name, - ]; + // Single source of truth for internal-key => LDAP-attribute-name + // lives on the Ldap model so parseAndMapLdapAttributes and this + // command can't drift. Used here for the LDAP query attribute + // list plus a handful of specific-lookup gates (active_flag, + // manager, location, username) that only LdapSync needs. + $ldap_map = Ldap::attributeMap(); $ldap_default_group = Setting::getSettings()->ldap_default_group; $search_base = Setting::getSettings()->ldap_base_dn; @@ -243,37 +227,15 @@ class LdapSync extends Command // Assign the mapped LDAP attributes for each user to the Snipe-IT user fields for ($i = 0; $i < $results['count']; $i++) { - $item = []; - $item['username'] = $results[$i][$ldap_map['username']][0] ?? null; - $item['display_name'] = $results[$i][$ldap_map['display_name']][0] ?? null; - $item['employee_number'] = $results[$i][$ldap_map['emp_num']][0] ?? null; - $item['lastname'] = $results[$i][$ldap_map['last_name']][0] ?? null; - $item['firstname'] = $results[$i][$ldap_map['first_name']][0] ?? null; - $item['email'] = $results[$i][$ldap_map['email']][0] ?? null; + // parseAndMapLdapAttributes is the shared parser used by the + // first-login create path too, so the two flows can't drift + // on field names / lookup shape. The two OU-override keys + // (ldap_location_override, location_id) are LdapSync-only, + // injected earlier by the OU sweep at line 191 or so, so we + // stitch them back on here. + $item = Ldap::parseAndMapLdapAttributes($results[$i]); $item['ldap_location_override'] = $results[$i]['ldap_location_override'] ?? null; $item['location_id'] = $results[$i]['location_id'] ?? null; - $item['telephone'] = $results[$i][$ldap_map['phone']][0] ?? null; - $item['mobile'] = $results[$i][$ldap_map['mobile']][0] ?? null; - $item['jobtitle'] = $results[$i][$ldap_map['jobtitle']][0] ?? null; - $item['address'] = $results[$i][$ldap_map['address']][0] ?? null; - $item['city'] = $results[$i][$ldap_map['city']][0] ?? null; - $item['state'] = $results[$i][$ldap_map['state']][0] ?? null; - $item['country'] = $results[$i][$ldap_map['country']][0] ?? null; - $item['zip'] = $results[$i][$ldap_map['zip']][0] ?? null; - $item['department'] = $results[$i][$ldap_map['dept']][0] ?? null; - $item['manager'] = $results[$i][$ldap_map['manager']][0] ?? null; - $item['location'] = $results[$i][$ldap_map['location']][0] ?? null; - $location = $default_location; // initially, set '$location' to the default_location (which may just be null) - - // ONLY if you are using the "ldap_location" option *AND* you have an actual result - if ($ldap_map['location'] && $item['location']) { - $location = Location::firstOrCreate([ - 'name' => $item['location'], - ]); - } - $department = Department::firstOrCreate([ - 'name' => $item['department'], - ]); $user = User::withTrashed()->where('username', $item['username'])->first(); if (! empty($item['username'])) { @@ -294,55 +256,14 @@ class LdapSync extends Command $item['createorupdate'] = 'created'; } - // If a sync option is not filled in on the LDAP settings don't populate the user field - if ($ldap_map['username'] != null) { - $user->username = $item['username']; - } - if ($ldap_map['display_name'] != null) { - $user->display_name = $item['display_name']; - } - if ($ldap_map['last_name'] != null) { - $user->last_name = $item['lastname']; - } - if ($ldap_map['first_name'] != null) { - $user->first_name = $item['firstname']; - } - if ($ldap_map['emp_num'] != null) { - $user->employee_num = e($item['employee_number']); - } - if ($ldap_map['email'] != null) { - $user->email = $item['email']; - } - if ($ldap_map['phone'] != null) { - $user->phone = $item['telephone']; - } - if ($ldap_map['mobile'] != null) { - $user->mobile = $item['mobile']; - } - if ($ldap_map['jobtitle'] != null) { - $user->jobtitle = $item['jobtitle']; - } - if ($ldap_map['address'] != null) { - $user->address = $item['address']; - } - if ($ldap_map['city'] != null) { - $user->city = $item['city']; - } - if ($ldap_map['state'] != null) { - $user->state = $item['state']; - } - if ($ldap_map['country'] != null) { - $user->country = $item['country']; - } - if ($ldap_map['zip'] != null) { - $user->zip = $item['zip']; - } - if ($ldap_map['dept'] != null) { - $user->department_id = $department->id; - } - if ($ldap_map['location'] != null) { - $user->location_id = $location?->id; - } + // Shared field-write path with the first-login create flow. + // Handles every mapped scalar field, plus Department and + // Location firstOrCreate for the LDAP-derived values. The + // three LdapSync-only concerns (manager LDAP re-query, + // active_flag / UAC, OU location override) are handled + // inline below because they don't apply to the first-login + // path. + Ldap::applyLdapAttributesToUser($user, $item); if ($ldap_map['manager'] != null) { if ($item['manager'] != null) { @@ -449,19 +370,28 @@ class LdapSync extends Command } /* implied 'else' here - leave the $user->activated flag alone. Newly-created accounts will be active. already-existing accounts will be however the administrator has set them */ + // Location resolution: applyLdapAttributesToUser above has + // already written location_id from the LDAP payload when a + // value was present. This block layers on the two overrides + // it doesn't know about: the OU-based override wins over + // everything when set, and the --location CLI flag fills in + // when neither the OU override nor an LDAP-derived location + // applied to this run. + $ldapProvidedLocation = $ldap_map['location'] !== null && $item['location'] !== ''; + if ($item['ldap_location_override'] == true) { $user->location_id = $item['location_id']; - } elseif ((isset($location)) && (! empty($location))) { - if ((is_array($location)) && (array_key_exists('id', $location))) { - $user->location_id = $location['id']; - } elseif (is_object($location)) { - $user->location_id = $location->id; // THIS is the magic line, this should do it. - } + } elseif (! $ldapProvidedLocation && ! empty($default_location)) { + $user->location_id = is_array($default_location) + ? $default_location['id'] + : $default_location->id; } - // TODO - should we be NULLING locations if $location is really `null`, and that's what we came up with? - // will that conflict with any overriding setting that the user set? Like, if they moved someone from - // the 'null' location to somewhere, we wouldn't want to try to override that, right? - $location = null; + // TODO - should we be NULLING locations when neither the OU + // override, the LDAP payload, nor --location produced a + // location for this user? Currently we leave whatever they + // had, matching the pre-refactor behavior. Changing that + // could clobber a location an admin set by hand. + $user->ldap_import = 1; $errors = ''; @@ -505,7 +435,15 @@ class LdapSync extends Command $missing_ldap_users = $missing_ldap_users->get(); foreach ($missing_ldap_users as $missing_user) { - $is_deletable = $this->isUserDeletable($missing_user); + // Match the rule a manual "delete user" click uses. We + // can't call User::isDeletable() directly here because + // it wraps a Gate::allows('delete', $user) check that + // needs an authenticated web-session user, and this + // command runs from cron with no such user. The + // association-blocker half is what's actually load- + // bearing for "safe to delete" and is shared via the + // hasNoAssignmentBlockers() helper on User. + $is_deletable = $missing_user->hasNoAssignmentBlockers(); $missing_item = [ 'id' => $missing_user->id, @@ -530,8 +468,6 @@ class LdapSync extends Command } } - - if ($this->option('summary')) { for ($x = 0; $x < count($summary); $x++) { if ($summary[$x]['status'] == 'error') { @@ -547,23 +483,4 @@ class LdapSync extends Command return $summary; } } - - /** - * Checks if the user is deletable without gate check - * - * A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations. - * - * @param User $user The user to check - * - * @return bool True if the user is deletable, false otherwise - */ - private function isUserDeletable(User $user): bool - { - return (($user->assets_count ?? $user->assets()->count()) === 0) - && (($user->accessories_count ?? $user->accessories()->count()) === 0) - && (($user->licenses_count ?? $user->licenses()->count()) === 0) - && (($user->consumables_count ?? $user->consumables()->count()) === 0) - && (($user->manages_users_count ?? $user->managesUsers()->count()) === 0) - && (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0); - } } From e9f14dd2e2728f0433b8b9132fe02223ff93d75d Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:01:17 +0100 Subject: [PATCH 06/22] Removed dead company_id hidden field --- resources/views/departments/edit.blade.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/resources/views/departments/edit.blade.php b/resources/views/departments/edit.blade.php index 9893a59639..ca7668d640 100644 --- a/resources/views/departments/edit.blade.php +++ b/resources/views/departments/edit.blade.php @@ -28,8 +28,6 @@ name="company_id" :selected="old('company_id', $item->company_id)" /> - @else - @endif Date: Fri, 7 Aug 2026 14:01:31 +0100 Subject: [PATCH 07/22] Hide button in blade for non-superusers --- resources/views/users/index.blade.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/resources/views/users/index.blade.php b/resources/views/users/index.blade.php index fd0d6c6f6c..804c2edb37 100755 --- a/resources/views/users/index.blade.php +++ b/resources/views/users/index.blade.php @@ -18,11 +18,12 @@ @section('header_right') - @can('create', \App\Models\User::class) - @if ($snipeSettings->ldap_enabled == 1) - {{trans('general.ldap_sync')}} - @endif - @endcan + {{-- LDAP sync surfaces users from across the entire directory + regardless of company scoping, so the button is superuser-only + to match LDAPImportController's authorization. --}} + @if (auth()->user()?->isSuperUser() && $snipeSettings->ldap_enabled == 1) + {{trans('general.ldap_sync')}} + @endif @stop {{-- Page content --}} From 4231a528e79ec1ba2c9efb7f92826f5477b4c3de Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:01:46 +0100 Subject: [PATCH 08/22] Show companies on print view --- resources/views/users/print.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/users/print.blade.php b/resources/views/users/print.blade.php index df29f04fe6..15d7075c2b 100644 --- a/resources/views/users/print.blade.php +++ b/resources/views/users/print.blade.php @@ -95,8 +95,8 @@ @endphp
{{-- used for page breaks when printing --}}

- @if ($show_user->company) - {{ trans('admin/companies/table.name') }}: {{ $show_user->company->name }} + @if ($show_user->companies->isNotEmpty()) + {{ trans('admin/companies/table.name') }}: {{ $show_user->companies->pluck('name')->join(', ') }}
@endif {{ trans('general.assigned_to', ['name' => $show_user->display_name]) }} From 052f1820a987b15e9fcc2eee51555089111fe38c Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:02:33 +0100 Subject: [PATCH 09/22] Updated comment --- app/Console/Commands/LdapSync.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/Console/Commands/LdapSync.php b/app/Console/Commands/LdapSync.php index 201a06a8dc..12cf3340e5 100644 --- a/app/Console/Commands/LdapSync.php +++ b/app/Console/Commands/LdapSync.php @@ -439,10 +439,7 @@ class LdapSync extends Command // can't call User::isDeletable() directly here because // it wraps a Gate::allows('delete', $user) check that // needs an authenticated web-session user, and this - // command runs from cron with no such user. The - // association-blocker half is what's actually load- - // bearing for "safe to delete" and is shared via the - // hasNoAssignmentBlockers() helper on User. + // command runs from cron with no such user $is_deletable = $missing_user->hasNoAssignmentBlockers(); $missing_item = [ From fc77678d743d48d9a1aaa85f385eecf32c60ce1a Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:29:22 +0100 Subject: [PATCH 10/22] Fixed typo --- resources/views/users/ldap.blade.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/resources/views/users/ldap.blade.php b/resources/views/users/ldap.blade.php index eac9ec3a5a..404d23e32f 100644 --- a/resources/views/users/ldap.blade.php +++ b/resources/views/users/ldap.blade.php @@ -15,16 +15,17 @@ -

+ {!! trans('admin/users/general.ldap_sync_intro', ['link' => 'https://snipe-it.readme.io/docs/ldap-sync#/']) !!} -

+
{{ (array_key_exists('id', $entry)) ? $entry['id'] : '' }} {{ $entry['username'] }} {{ $entry['display_name'] }} - {{ $entry['employee_number'] }} + {{ $entry['employee_num'] }} {{ $entry['first_name'] }} {{ $entry['last_name'] }} {{ $entry['email'] }} From 671c6c160ca756dabd33dbadfbe50dbfeaad3ddb Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:32:20 +0100 Subject: [PATCH 11/22] Removed undefined return --- app/Models/Ldap.php | 69 +++++++++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/app/Models/Ldap.php b/app/Models/Ldap.php index 3d29f8408d..c196d5c825 100644 --- a/app/Models/Ldap.php +++ b/app/Models/Ldap.php @@ -60,8 +60,6 @@ class Ldap extends Model * @author [A. Gianotto] [] * * @since [v3.0] - * - * @return connection */ public static function connectToLdap() { @@ -180,16 +178,14 @@ class Ldap extends Model } /** - * Binds/authenticates the user to LDAP, and returns their attributes. + * Binds/authenticates the user to LDAP, and returns their attributes + * (lowercase-keyed) on success or false when the bind or search fails. * * @author [A. Gianotto] [] * * @since [v3.0] * - * @param bool|false $user - * @return bool true if the username and/or password provided are valid - * false if the username and/or password provided are invalid - * array of ldap_attributes if $user is true + * @return array|false */ public static function findAndBindUserLdap($username, $password) { @@ -315,39 +311,50 @@ class Ldap extends Model * Single source of truth for the LDAP-attribute mapping. Internal * key (used across parseAndMapLdapAttributes' $item, the User field * writes in applyLdapAttributesToUser, and LdapSync's specific - * lookups) => LDAP attribute name pulled from Settings. A null / '' - * value means the admin left that particular mapping unconfigured. + * lookups) => LDAP attribute name. * + * Keys mirror the User model's column names (jobtitle, + * employee_num, first_name, etc.) so downstream code can walk this + * map and write straight onto a User without translation. + * + * `$source` defaults to the persisted Setting model so backend + * callers (LdapSync, parseAndMapLdapAttributes, applyLdapAttributesToUser) + * see saved values. The LDAP wizard Livewire component passes + * `$this` so its live preview reflects in-flight form edits before + * they're written to Settings. Any object exposing the same + * `ldap_*` properties works. + * + * @param object|null $source Setting-shaped object; defaults to Setting::getSettings() * @return array */ - public static function attributeMap(): array + public static function attributeMap(?object $source = null): array { - $settings = Setting::getSettings(); + $source ??= Setting::getSettings(); return [ - 'username' => $settings->ldap_username_field, - 'first_name' => $settings->ldap_fname_field, - 'last_name' => $settings->ldap_lname_field, - 'employee_number' => $settings->ldap_emp_num, - 'display_name' => $settings->ldap_display_name, - 'email' => $settings->ldap_email, - 'phone' => $settings->ldap_phone_field, - 'mobile' => $settings->ldap_mobile, - 'jobtitle' => $settings->ldap_jobtitle, - 'address' => $settings->ldap_address, - 'city' => $settings->ldap_city, - 'state' => $settings->ldap_state, - 'zip' => $settings->ldap_zip, - 'country' => $settings->ldap_country, - 'department' => $settings->ldap_dept, - 'location' => $settings->ldap_location, - 'manager' => $settings->ldap_manager, + 'username' => $source->ldap_username_field, + 'first_name' => $source->ldap_fname_field, + 'last_name' => $source->ldap_lname_field, + 'employee_num' => $source->ldap_emp_num, + 'display_name' => $source->ldap_display_name, + 'email' => $source->ldap_email, + 'phone' => $source->ldap_phone_field, + 'mobile' => $source->ldap_mobile, + 'jobtitle' => $source->ldap_jobtitle, + 'address' => $source->ldap_address, + 'city' => $source->ldap_city, + 'state' => $source->ldap_state, + 'zip' => $source->ldap_zip, + 'country' => $source->ldap_country, + 'department' => $source->ldap_dept, + 'location' => $source->ldap_location, + 'manager' => $source->ldap_manager, // LdapSync-only: active_flag is consumed by the // active-directory sync logic in the console command. // parseAndMapLdapAttributes does not surface it because // the first-login path has no use for it (the user just // successfully bound to LDAP, they're active by definition). - 'active_flag' => $settings->ldap_active_flag, + 'active_flag' => $source->ldap_active_flag, ]; } @@ -401,8 +408,8 @@ class Ldap extends Model if ($map['display_name'] != '') { $user->display_name = $ldapAttr['display_name']; } - if ($map['employee_number'] != '') { - $user->employee_num = e($ldapAttr['employee_number']); + if ($map['employee_num'] != '') { + $user->employee_num = e($ldapAttr['employee_num']); } if ($map['phone'] != '') { $user->phone = $ldapAttr['phone']; From b2e3e7b562ae1edba2e4aa92e61f05e88e130d84 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:36:10 +0100 Subject: [PATCH 12/22] Removed locale --- app/Models/Ldap.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Models/Ldap.php b/app/Models/Ldap.php index c196d5c825..a49cc10ffc 100644 --- a/app/Models/Ldap.php +++ b/app/Models/Ldap.php @@ -368,7 +368,6 @@ class Ldap extends Model } $item[$key] = $ldapAttr ? ($ldapattributes[$ldapAttr][0] ?? '') : ''; } - $item['locale'] = app()->getLocale(); return $item; } @@ -467,7 +466,7 @@ class Ldap extends Model $user = new User; self::applyLdapAttributesToUser($user, $item); - $user->locale = $item['locale']; + $user->locale = app()->getLocale(); $user->password = $user->noPassword(); if ($settings->ldap_pw_sync == '1') { $user->password = bcrypt($password); From 8558a3c5a6684978993b94133b925fdb686309be Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:48:00 +0100 Subject: [PATCH 13/22] Use the map in the legacy ldap --- resources/views/settings/ldap.blade.php | 52 ++++++------------------- 1 file changed, 12 insertions(+), 40 deletions(-) diff --git a/resources/views/settings/ldap.blade.php b/resources/views/settings/ldap.blade.php index 313b7d70fc..b8d149329d 100644 --- a/resources/views/settings/ldap.blade.php +++ b/resources/views/settings/ldap.blade.php @@ -852,60 +852,32 @@ html += '
' html += '
{{ trans('admin/settings/message.ldap.sync_success') }}

' html += '' - html += buildLdapResultsTableHeader() - html += buildLdapResultsTableBody(results.user_sync.users) + html += buildLdapResultsTableHeader(results.user_sync.fields) + html += buildLdapResultsTableBody(results.user_sync.users, results.user_sync.fields) html += '
' html += '' return html; } - function buildLdapResultsTableHeader(user) + function buildLdapResultsTableHeader(fields) { - var keys = [ - '{{ trans('admin/settings/general.employee_number') }}', - '{{ trans('mail.username') }}', - '{{ trans('admin/users/table.display_name') }}', - '{{ trans('general.first_name') }}', - '{{ trans('general.last_name') }}', - '{{ trans('general.email') }}', - '{{ trans('general.phone') }}', - '{{ trans('admin/users/table.mobile') }}', - '{{ trans('admin/users/table.manager') }}', - '{{ trans('general.address') }}', - '{{ trans('general.city') }}', - '{{ trans('general.state') }}', - '{{ trans('general.zip') }}', - '{{ trans('general.country') }}', - '{{ trans('general.location') }}', - ] let header = '' - for (var i in keys) { - header += '' + keys[i] + '' + for (const key in fields) { + header += '' + fields[key] + '' } header += "" return header; } - function buildLdapResultsTableBody(users) + function buildLdapResultsTableBody(users, fields) { let body = '' - for (var i in users) { - body += ''; - body += '' + (users[i].employee_number ?? 'NULL') + ''; - body += '' + (users[i].username ?? 'NULL') + ''; - body += '' + (users[i].display_name ?? 'NULL') + ''; - body += '' + (users[i].firstname ?? 'NULL') + ''; - body += '' + (users[i].lastname ?? 'NULL') + ''; - body += '' + (users[i].email ?? 'NULL') + ''; - body += '' + (users[i].phone ?? 'NULL') + ''; - body += '' + (users[i].mobile ?? 'NULL') + ''; - body += '' + (users[i].manager ?? 'NULL') + ''; - body += '' + (users[i].address ?? 'NULL') + ''; - body += '' + (users[i].city ?? 'NULL') + ''; - body += '' + (users[i].state ?? 'NULL') + ''; - body += '' + (users[i].zip ?? 'NULL') + ''; - body += '' + (users[i].country ?? 'NULL') + ''; - body += '' + (users[i].location ?? 'NULL') + ''; + const nullCell = 'NULL' + for (const i in users) { + body += '' + for (const key in fields) { + body += '' + (users[i][key] ?? nullCell) + '' + } body += '' } body += "" From 07220e129a94a5483fda132d9c006b2cb922eed0 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:48:15 +0100 Subject: [PATCH 14/22] Wire up nicer translations for the preview --- app/Models/Ldap.php | 49 ++++++++++++++++--- .../views/livewire/ldap-settings.blade.php | 2 +- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/app/Models/Ldap.php b/app/Models/Ldap.php index a49cc10ffc..43eec7e31c 100644 --- a/app/Models/Ldap.php +++ b/app/Models/Ldap.php @@ -349,12 +349,49 @@ class Ldap extends Model 'department' => $source->ldap_dept, 'location' => $source->ldap_location, 'manager' => $source->ldap_manager, - // LdapSync-only: active_flag is consumed by the - // active-directory sync logic in the console command. + // LdapSync-only: activated is consumed by the active- + // directory sync logic in the console command, which reads + // the mapped LDAP attribute (or AD's useraccountcontrol) + // and writes the resulting bool onto user.activated. // parseAndMapLdapAttributes does not surface it because // the first-login path has no use for it (the user just // successfully bound to LDAP, they're active by definition). - 'active_flag' => $source->ldap_active_flag, + 'activated' => $source->ldap_active_flag, + ]; + } + + /** + * Companion to attributeMap(): internal key => translated human + * label. Consumed by the LDAP wizard's step-3 preview table and by + * the settings-page ldaptest results table, so both render + * "Employee Number" / "Title" / etc. instead of the raw + * snake_case internal keys. Adding a new key to attributeMap() + * should be paired with an entry here so it shows up nicely in + * both places automatically. + * + * @return array + */ + public static function attributeLabels(): array + { + return [ + 'username' => trans('general.username'), + 'first_name' => trans('general.first_name'), + 'last_name' => trans('general.last_name'), + 'employee_num' => trans('general.employee_number'), + 'display_name' => trans('admin/users/table.display_name'), + 'email' => trans('general.email'), + 'phone' => trans('general.phone'), + 'mobile' => trans('admin/users/table.mobile'), + 'jobtitle' => trans('admin/users/table.title'), + 'address' => trans('general.address'), + 'city' => trans('general.city'), + 'state' => trans('general.state'), + 'zip' => trans('general.zip'), + 'country' => trans('general.country'), + 'department' => trans('general.department'), + 'location' => trans('general.location'), + 'manager' => trans('admin/users/table.manager'), + 'activated' => trans('admin/users/table.activated'), ]; } @@ -362,8 +399,8 @@ class Ldap extends Model { $item = []; foreach (self::attributeMap() as $key => $ldapAttr) { - // active_flag is LdapSync's concern. See attributeMap(). - if ($key === 'active_flag') { + // activated is LdapSync's concern. See attributeMap(). + if ($key === 'activated') { continue; } $item[$key] = $ldapAttr ? ($ldapattributes[$ldapAttr][0] ?? '') : ''; @@ -396,7 +433,7 @@ class Ldap extends Model // Always-written identity fields. These have no per-field gate // because Snipe-IT considers username / first name / last name / - // email load-bearing for every user, if a mapping's blank the + // email important for every user, if a mapping's blank the // LDAP payload just gives us an empty string, matching the // pre-fix behavior on the create path. $user->username = $ldapAttr['username']; diff --git a/resources/views/livewire/ldap-settings.blade.php b/resources/views/livewire/ldap-settings.blade.php index b80fd30d59..506e952e74 100644 --- a/resources/views/livewire/ldap-settings.blade.php +++ b/resources/views/livewire/ldap-settings.blade.php @@ -646,7 +646,7 @@ @foreach ($step3TestAttributes as $snipeField => $preview) - {{ $snipeField }} + {{ $preview['label'] ?? $snipeField }} @if ($preview['attr']) {{ $preview['attr'] }} From 69e5894380200d86bd2eeb10d29b55c7a2045d7e Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:50:12 +0100 Subject: [PATCH 15/22] Use mapping array --- app/Console/Commands/LdapSync.php | 10 ++--- .../Controllers/Api/SettingsController.php | 39 +++++++++---------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/app/Console/Commands/LdapSync.php b/app/Console/Commands/LdapSync.php index 12cf3340e5..7a97d3a047 100644 --- a/app/Console/Commands/LdapSync.php +++ b/app/Console/Commands/LdapSync.php @@ -57,7 +57,7 @@ class LdapSync extends Command // Single source of truth for internal-key => LDAP-attribute-name // lives on the Ldap model so parseAndMapLdapAttributes and this // command can't drift. Used here for the LDAP query attribute - // list plus a handful of specific-lookup gates (active_flag, + // list plus a handful of specific-lookup gates (activated, // manager, location, username) that only LdapSync needs. $ldap_map = Ldap::attributeMap(); @@ -117,7 +117,7 @@ class LdapSync extends Command */ $attributes = array_values(array_filter($ldap_map)); - if (Setting::getSettings()->is_ad === 1 && is_null($ldap_map['active_flag'])) { + if (Setting::getSettings()->is_ad === 1 && is_null($ldap_map['activated'])) { $attributes[] = 'useraccountcontrol'; } @@ -260,7 +260,7 @@ class LdapSync extends Command // Handles every mapped scalar field, plus Department and // Location firstOrCreate for the LDAP-derived values. The // three LdapSync-only concerns (manager LDAP re-query, - // active_flag / UAC, OU location override) are handled + // activated / UAC, OU location override) are handled // inline below because they don't apply to the first-login // path. Ldap::applyLdapAttributesToUser($user, $item); @@ -315,10 +315,10 @@ class LdapSync extends Command } // Sync activated state for Active Directory. - if (! empty($ldap_map['active_flag'])) { // IF we have an 'active' flag set.... + if (! empty($ldap_map['activated'])) { // IF we have an 'active' flag set.... // ....then *most* things that are truthy will activate the user. Anything falsey will deactivate them. // (Specifically, we don't handle a value of '0.0' correctly) - $raw_value = @$results[$i][$ldap_map['active_flag']][0]; + $raw_value = @$results[$i][$ldap_map['activated']][0]; $filter_var = filter_var($raw_value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); $boolean_cast = (bool) $raw_value; diff --git a/app/Http/Controllers/Api/SettingsController.php b/app/Http/Controllers/Api/SettingsController.php index 297e3fbc5c..cef0e53037 100644 --- a/app/Http/Controllers/Api/SettingsController.php +++ b/app/Http/Controllers/Api/SettingsController.php @@ -43,31 +43,30 @@ class SettingsController extends Controller 'message' => 'Successfully connected to LDAP server.', ]; + // Shape is driven by Ldap::parseAndMapLdapAttributes so + // this endpoint stays in sync with the sync command and + // the first-login create path. Blank fields collapse to + // null in the JSON so the JS side's `?? NULL` fallback + // renders "NULL" for missing values. $users = collect(Ldap::findLdapUsers(null, 10))->filter(function ($value, $key) { return is_int($key); - })->slice(0, 10)->map(function ($item) use ($settings) { - return (object) [ - 'username' => $item[$settings['ldap_username_field']][0] ?? null, - 'display_name' => $item[$settings['ldap_display_name']][0] ?? null, - 'employee_number' => $item[$settings['ldap_emp_num']][0] ?? null, - 'lastname' => $item[$settings['ldap_lname_field']][0] ?? null, - 'firstname' => $item[$settings['ldap_fname_field']][0] ?? null, - 'email' => $item[$settings['ldap_email']][0] ?? null, - 'phone' => $item[$settings['ldap_phone_field']][0] ?? null, - 'mobile' => $item[$settings['ldap_mobile']][0] ?? null, - 'jobtitle' => $item[$settings['ldap_jobtitle']][0] ?? null, - 'department' => $item[$settings['ldap_department']][0] ?? null, - 'manager' => $item[$settings['ldap_manager']][0] ?? null, - 'address' => $item[$settings['ldap_address']][0] ?? null, - 'city' => $item[$settings['ldap_city']][0] ?? null, - 'state' => $item[$settings['ldap_state']][0] ?? null, - 'zip' => $item[$settings['ldap_zip']][0] ?? null, - 'country' => $item[$settings['ldap_country']][0] ?? null, - 'location' => $item[$settings['ldap_location']][0] ?? null, - ]; + })->slice(0, 10)->map(function ($item) { + $mapped = Ldap::parseAndMapLdapAttributes($item); + + return (object) array_map(fn ($value) => $value === '' ? null : $value, $mapped); }); if ($users->count() > 0) { + // `fields` is the ordered internal_key => translated + // label map the JS iterates to build the results + // table's header + per-row cells. Same shape drives + // the LDAP wizard's step-3 preview, so both stay + // consistent as attributeMap() grows. + $labels = Ldap::attributeLabels(); + $fields = collect(array_keys(Ldap::parseAndMapLdapAttributes([]))) + ->mapWithKeys(fn ($key) => [$key => $labels[$key] ?? $key]) + ->all(); $message['user_sync'] = [ + 'fields' => $fields, 'users' => $users, ]; } else { From 856da438178be01c16ca940a809337cbf1558089 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:50:27 +0100 Subject: [PATCH 16/22] Use unified mapping key --- app/Livewire/LdapSettings.php | 43 ++++++----------------------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/app/Livewire/LdapSettings.php b/app/Livewire/LdapSettings.php index a5b22536a2..4887531a67 100644 --- a/app/Livewire/LdapSettings.php +++ b/app/Livewire/LdapSettings.php @@ -2,6 +2,7 @@ namespace App\Livewire; +use App\Models\Ldap; use App\Models\Setting; use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Gate; @@ -770,38 +771,6 @@ class LdapSettings extends Component // === Step 3: Attribute mapping ========================================= ============================= - /** - * Snipe-IT field name → LDAP-attribute-name Livewire property. Used - * both by the preview-table render and by any future sync-side code - * that wants a canonical map of "what field goes where." Order here - * defines the order in the preview table. - * - * Any new mappings we create would need to go here too. - */ - protected function attributeMap(): array - { - return [ - 'username' => $this->ldap_username_field, - 'first_name' => $this->ldap_fname_field, - 'last_name' => $this->ldap_lname_field, - 'display_name' => $this->ldap_display_name, - 'email' => $this->ldap_email, - 'employee_num' => $this->ldap_emp_num, - 'phone' => $this->ldap_phone_field, - 'mobile' => $this->ldap_mobile, - 'job_title' => $this->ldap_jobtitle, - 'manager' => $this->ldap_manager, - 'department' => $this->ldap_dept, - 'address' => $this->ldap_address, - 'city' => $this->ldap_city, - 'state' => $this->ldap_state, - 'zip' => $this->ldap_zip, - 'country' => $this->ldap_country, - 'location' => $this->ldap_location, - 'active_flag' => $this->ldap_active_flag, - ]; - } - protected function saveStep3(): void { $this->validate($this->step3SyntaxRules(), attributes: $this->step3SyntaxAttributes()); @@ -923,7 +892,7 @@ class LdapSettings extends Component // whole entry when we only care about a handful. $requestedAttrs = array_values(array_filter(array_map( fn ($attr) => trim(strtolower((string) $attr)), - $this->attributeMap(), + Ldap::attributeMap($this), ))); $searchResult = @ldap_search($conn, $this->ldap_basedn, $lookupFilter, $requestedAttrs); @@ -986,10 +955,12 @@ class LdapSettings extends Component // muted). Attribute names are compared lowercase. LDAP is // case-insensitive on attribute names. $preview = []; - foreach ($this->attributeMap() as $snipeField => $ldapAttr) { + $labels = Ldap::attributeLabels(); + foreach (Ldap::attributeMap($this) as $snipeField => $ldapAttr) { + $label = $labels[$snipeField] ?? $snipeField; $ldapAttrLower = trim(strtolower((string) $ldapAttr)); if ($ldapAttrLower === '') { - $preview[$snipeField] = ['attr' => null, 'value' => null]; + $preview[$snipeField] = ['label' => $label, 'attr' => null, 'value' => null]; continue; } @@ -997,7 +968,7 @@ class LdapSettings extends Component if (isset($attributes[$ldapAttrLower][0])) { $value = $attributes[$ldapAttrLower][0]; } - $preview[$snipeField] = ['attr' => $ldapAttr, 'value' => $value]; + $preview[$snipeField] = ['label' => $label, 'attr' => $ldapAttr, 'value' => $value]; } $this->step3TestDn = (string) $dn; From c12b695f1b70c8c174bec1d636057a94b70c7aa7 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:50:34 +0100 Subject: [PATCH 17/22] Updated baseline --- phpstan-baseline.neon | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1d73da8459..85e74f864a 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -306,24 +306,12 @@ parameters: count: 1 path: app/Console/Commands/LdapSync.php - - - message: '#^Access to an undefined property App\\Models\\User\:\:\$display_name\.$#' - identifier: property.notFound - count: 1 - path: app/Console/Commands/LdapSync.php - - message: '#^Call to an undefined method Illuminate\\Database\\Eloquent\\Relations\\Relation\:\:attach\(\)\.$#' identifier: method.notFound count: 1 path: app/Console/Commands/LdapSync.php - - - message: '#^Call to function array_key_exists\(\) with ''id'' and \*NEVER\* will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: app/Console/Commands/LdapSync.php - - message: '#^Call to function is_array\(\) with App\\Models\\Location will always evaluate to false\.$#' identifier: function.impossibleType @@ -351,7 +339,7 @@ parameters: - message: '#^Result of && is always false\.$#' identifier: booleanAnd.alwaysFalse - count: 2 + count: 1 path: app/Console/Commands/LdapSync.php - @@ -360,12 +348,6 @@ parameters: count: 2 path: app/Console/Commands/LdapSync.php - - - message: '#^Variable \$location in empty\(\) always exists and is not falsy\.$#' - identifier: empty.variable - count: 1 - path: app/Console/Commands/LdapSync.php - - message: '#^Access to an undefined property App\\Console\\Commands\\LdapTroubleshooter\:\:\$settings\.$#' identifier: property.notFound From 9c882c3503d0b92f64049a99d47de019752df084 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 14:56:19 +0100 Subject: [PATCH 18/22] Use table output for summary --- app/Console/Commands/LdapSync.php | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/LdapSync.php b/app/Console/Commands/LdapSync.php index 7a97d3a047..0de38461b7 100644 --- a/app/Console/Commands/LdapSync.php +++ b/app/Console/Commands/LdapSync.php @@ -445,8 +445,8 @@ class LdapSync extends Command $missing_item = [ 'id' => $missing_user->id, 'username' => $missing_user->username, - 'firstname' => $missing_user->first_name, - 'lastname' => $missing_user->last_name, + 'first_name' => $missing_user->first_name, + 'last_name' => $missing_user->last_name, 'email' => $missing_user->email, 'createorupdate' => 'skipped', 'status' => 'info', @@ -466,13 +466,18 @@ class LdapSync extends Command } if ($this->option('summary')) { - for ($x = 0; $x < count($summary); $x++) { - if ($summary[$x]['status'] == 'error') { - $this->error('ERROR: '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was not imported: '.$summary[$x]['note']); - } else { - $this->info('User '.$summary[$x]['firstname'].' '.$summary[$x]['lastname'].' (username: '.$summary[$x]['username'].') was '.strtoupper($summary[$x]['createorupdate']).'.'); - } - } + $rows = array_map(fn ($row) => [ + $row['username'] ?? '', + trim(($row['first_name'] ?? '').' '.($row['last_name'] ?? '')), + strtoupper($row['createorupdate'] ?? ''), + strtoupper($row['status'] ?? ''), + $row['note'] ?? '', + ], $summary); + + $this->table( + ['Username', 'Name', 'Action', 'Status', 'Note'], + $rows, + ); } elseif ($this->option('json_summary')) { $json_summary = ['error' => false, 'error_message' => '', 'summary' => $summary]; // hardcoding the error to false and the error_message to blank seems a bit weird $this->info(json_encode($json_summary)); From 4de579fcc99de2510502254469c1a2dc3b9694d4 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 15:39:12 +0100 Subject: [PATCH 19/22] Enable ldap by default in seeder --- database/seeders/SettingsSeeder.php | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/database/seeders/SettingsSeeder.php b/database/seeders/SettingsSeeder.php index cc7f1b4d27..48337168ce 100644 --- a/database/seeders/SettingsSeeder.php +++ b/database/seeders/SettingsSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\Setting; use App\Models\User; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Crypt; use Illuminate\Support\Facades\Storage; class SettingsSeeder extends Seeder @@ -22,7 +23,33 @@ class SettingsSeeder extends Seeder $settings->label2_2d_type = 'QRCODE'; $settings->default_currency = 'USD'; $settings->brand = 2; - $settings->ldap_enabled = 0; + // Forumsys hosts a free public read-only LDAP directory + // (ldap://ldap.forumsys.com) that's handy for exercising the + // LDAP wizard against a real server without standing up your + // own. Pre-filling the fields lets devs (and the demo site) + // click through Admin > Settings > LDAP and use the Test + // Bind / Test Find User previews end to end. LDAP is enabled + // so the wizard's return-visitor branch unlocks all steps + // for demo visitors. The login controller separately skips + // the LDAP auth branch when the app is in demo mode + // (config('app.lock_passwords')), so demo logins never + // actually hit Forumsys. + $settings->ldap_enabled = '1'; + $settings->ldap_server = 'ldap://ldap.forumsys.com'; + $settings->is_ad = false; + $settings->ldap_tls = false; + $settings->ldap_server_cert_ignore = false; + $settings->ldap_client_tls_cert = null; + $settings->ldap_client_tls_key = null; + $settings->ldap_basedn = 'dc=example,dc=com'; + $settings->ldap_uname = 'cn=read-only-admin,dc=example,dc=com'; + $settings->ldap_pword = Crypt::encrypt('password'); + $settings->ldap_filter = ''; + $settings->ldap_auth_filter_query = 'uid='; + $settings->ldap_username_field = 'uid'; + $settings->ldap_fname_field = 'cn'; + $settings->ldap_lname_field = 'sn'; + $settings->ldap_email = 'mail'; $settings->full_multiple_companies_support = 0; $settings->label2_1d_type = 'C128'; $settings->skin = 'blue'; From c5ed9ab17fe6489c923189c8001660baa6669c8c Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 15:39:35 +0100 Subject: [PATCH 20/22] Fixed typo --- app/Http/Controllers/Auth/LoginController.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 60a932c6ab..b618920635 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -300,7 +300,7 @@ class LoginController extends Controller return redirect()->back()->withInput()->withErrors($validator); } - // Set the custom lockout attempts from the env and sett the custom lockout throttle from the env. + // Set the custom lockout attempts from the env and set the custom lockout throttle from the env. // We divide decayMinutes by 60 here to get minutes, since Laravel changed the default from minutes // to seconds, and we don't want to break limits on existing systems $this->maxAttempts = config('auth.passwords.users.throttle.max_attempts'); @@ -314,8 +314,12 @@ class LoginController extends Controller $user = null; - // Should we even check for LDAP users? - if (Setting::getSettings()->ldap_enabled) { // avoid hitting the $this->ldap + // Should we even check for LDAP users? Skip LDAP entirely when + // the app is in demo mode. The LDAP wizard's + // demo seed points at Forumsys as a reference config for + // visitors to click through, we don't want the login form to + // actually try to bind against it on every demo sign-in. + if (Setting::getSettings()->ldap_enabled && !config('app.lock_passwords')) { // avoid hitting the $this->ldap Log::debug('LDAP is enabled.'); try { Log::debug('Attempting to log user in by LDAP authentication.'); From 707580080de092b10999b31945689a316f9fa859 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 15:45:56 +0100 Subject: [PATCH 21/22] Make fields read-only in demo mode --- .../views/livewire/ldap-settings.blade.php | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/resources/views/livewire/ldap-settings.blade.php b/resources/views/livewire/ldap-settings.blade.php index 506e952e74..826ab50813 100644 --- a/resources/views/livewire/ldap-settings.blade.php +++ b/resources/views/livewire/ldap-settings.blade.php @@ -212,9 +212,6 @@
- - {{ trans('admin/settings/general.ldap_wizard.locked_help') }} - {{-- Step title + help text, always the current step's copy. --}} @php @@ -230,10 +227,22 @@ @endif + @if ($isReadOnly) + + This is a demo. Every LDAP config field is read-only, but you can still enter + a sample username on step 3 and use the Test Find User button to see the wizard + search against the pre-seeded readonly directory. (You can search on tesla, einstein, or curie.) + The wizard will not actually save any LDAP settings in this demo. + + + @endif + {{-- Wizard progress indicator. Same .bs-wizard class the quickstart setup layout + importer modal use. Flex + flex:1 on children rather than bootstrap col-md-*, so the layout stays uniform regardless of step count. --}} + +
@foreach ($this->stepTitles as $stepNum => $stepTitle) @php @@ -344,6 +353,7 @@ wire:model.live="is_ad" :label="trans('admin/settings/general.ad')" :checked="$is_ad" + :disabled="$isReadOnly" /> @@ -359,6 +369,7 @@ wire:model="ad_domain" placeholder="{{ trans('general.example').'example.com' }}" :required="true" + :readonly="$isReadOnly" /> @@ -376,6 +387,7 @@ wire:model.live.debounce.500ms="ldap_server" placeholder="{{ trans('general.example').'ldap://ldap.example.com' }}" :required="true" + :readonly="$isReadOnly" /> @@ -387,6 +399,7 @@ :label="trans('admin/settings/general.ldap_tls')" :checked="$ldap_tls" help_text="{!! trans('admin/settings/general.ldap_tls_help') !!}" + :disabled="$isReadOnly" /> @@ -396,6 +409,7 @@ :label="trans('admin/settings/general.ldap_server_cert_ignore')" :checked="$ldap_server_cert_ignore" help_text="{!! trans('admin/settings/general.ldap_server_cert_help') !!}" + :disabled="$isReadOnly" /> @@ -410,6 +424,7 @@ rows="4" :placeholder="sprintf('%s-----BEGIN RSA PRIVATE KEY-----%s1234567890%s-----END RSA PRIVATE KEY-----', trans('general.example'), PHP_EOL, PHP_EOL)" :required="$ldap_client_tls_cert !== ''" + :readonly="$isReadOnly" /> @@ -427,6 +442,7 @@ rows="4" :placeholder="sprintf('%s-----BEGIN CERTIFICATE-----%s1234567890%s-----END CERTIFICATE-----', trans('general.example'), PHP_EOL, PHP_EOL)" :required="$ldap_client_tls_key !== ''" + :readonly="$isReadOnly" /> @@ -452,6 +468,7 @@ placeholder="{{ trans('general.example').'ou=users,dc=example,dc=com' }}" :required="true" :ignore-autofill="true" + :readonly="$isReadOnly" /> @@ -471,6 +488,7 @@ placeholder="{{ trans('general.example').($is_ad ? 'admin@example.com' : 'cn=admin,dc=example,dc=com') }}" :ignore-autofill="true" :required="true" + :readonly="$isReadOnly" /> @@ -487,6 +505,7 @@ wire:model.live.debounce.500ms="ldap_pword" :required="true" :ignore-autofill="true" + :readonly="$isReadOnly" /> @@ -503,6 +522,7 @@ wire:model.live.debounce.500ms="ldap_filter" placeholder="{{ trans('general.example').'&(cn=*)' }}" :ignore-autofill="true" + :readonly="$isReadOnly" /> @@ -520,6 +540,7 @@ placeholder="{{ trans('general.example').'uid=' }}" :required="true" :ignore-autofill="true" + :readonly="$isReadOnly" /> @@ -553,6 +574,7 @@ :placeholder="$placeholderExample !== '' ? trans('general.example').$placeholderExample : ''" :required="$required" :ignore-autofill="true" + :readonly="$isReadOnly" /> @@ -568,6 +590,7 @@ :label="trans('admin/settings/general.ldap_invert_active_flag')" :checked="$ldap_invert_active_flag" help_text="{!! trans('admin/settings/general.ldap_invert_active_flag_help') !!}" + :disabled="$isReadOnly" /> {{-- Sample-lookup section, boxed in an x-well so it @@ -683,6 +706,7 @@ :label="trans('admin/settings/general.ldap_wizard.sync.ldap_pw_sync_label')" :checked="$ldap_pw_sync" help_text="{!! trans('admin/settings/general.ldap_pw_sync_help') !!}" + :disabled="$isReadOnly" /> @@ -701,6 +725,7 @@ ] + $this->permissionGroups" :forLivewire="true" style="width: 100%" + :disabled="$isReadOnly" /> @@ -717,6 +742,7 @@ name="custom_forgot_pass_url" wire:model.blur="custom_forgot_pass_url" placeholder="{{ trans('general.example').'https://my.ldapserver-forgotpass.com' }}" + :readonly="$isReadOnly" /> @@ -762,7 +788,6 @@ {{ trans('admin/settings/general.ldap_wizard.verifying_help') }}

- {{ trans('general.feature_disabled') }}
From 83a05e9383b72e39c4294ae4d699b4070e7e6232 Mon Sep 17 00:00:00 2001 From: snipe Date: Fri, 7 Aug 2026 15:51:22 +0100 Subject: [PATCH 22/22] =?UTF-8?q?Unlock=20button=20in=20demo=20mode=20but?= =?UTF-8?q?=20don=E2=80=99t=20persist=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/ResetDemoSettings.php | 7 ++- app/Livewire/LdapSettings.php | 55 +++++++++++++++++++ .../views/livewire/ldap-settings.blade.php | 2 +- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/ResetDemoSettings.php b/app/Console/Commands/ResetDemoSettings.php index cfc624e9a5..695a6961b0 100644 --- a/app/Console/Commands/ResetDemoSettings.php +++ b/app/Console/Commands/ResetDemoSettings.php @@ -56,7 +56,12 @@ class ResetDemoSettings extends Command $settings->label2_2d_type = 'QRCODE'; $settings->default_currency = 'USD'; $settings->brand = 2; - $settings->ldap_enabled = 0; + // Enabled so the wizard's return-visitor branch unlocks all 5 + // steps for demo visitors and they can jump straight to the + // step-3 Test Find User preview against the seeded Forumsys + // config. Safe on the demo because LoginController skips the + // LDAP auth branch when config('app.lock_passwords') is on. + $settings->ldap_enabled = '1'; $settings->full_multiple_companies_support = 0; $settings->label2_1d_type = 'C128'; $settings->email_domain = 'snipeitapp.com'; diff --git a/app/Livewire/LdapSettings.php b/app/Livewire/LdapSettings.php index 4887531a67..82692d91ad 100644 --- a/app/Livewire/LdapSettings.php +++ b/app/Livewire/LdapSettings.php @@ -78,6 +78,24 @@ class LdapSettings extends Component // finishWizard so back-nav doesn't retrigger the animation. public ?int $justCompletedStep = null; + // Read-only lock. Set from config('app.lock_passwords') in mount() + // and blade uses it to render every wire:model input with the + // `readonly` / `disabled` attribute so demo visitors can't retype + // real LDAP creds into the wizard. Server-side enforcement lives + // in updated(), which reverts any prop mutation back to the + // persisted Setting values (defense against a caller that fakes + // wire:model updates around the disabled UI). + public bool $isReadOnly = false; + + // Properties that stay editable even when isReadOnly is on. The + // sample-username field on step 3 has to remain writable so the + // Test Find User preview still works, which is the one wizard + // interaction we do want demo visitors to exercise. + private const READ_ONLY_ALLOWED_PROPS = [ + 'currentStep', + 'test_sample_username', + ]; + // Step 1: Connection public bool $ldap_enabled = false; @@ -185,6 +203,7 @@ class LdapSettings extends Component public function mount(): void { + $this->isReadOnly = (bool) config('app.lock_passwords'); $this->hydrateFromPersisted(); // Restore in-flight wizard progress from the session so a page @@ -209,6 +228,17 @@ class LdapSettings extends Component } } + // Demo mode unlocks the wizard independent of ldap_enabled. + // The save/advance methods are gated shut by lock_passwords + // so a visitor with ldap_enabled=false would otherwise be + // trapped on step 1 with no way to reach the Test Find User + // preview on step 3. Unlocking the stepper here lets them + // jump to any step. Fields stay locked via isReadOnly / + // updated() enforcement. + if ($this->isReadOnly) { + $this->highestStepReached = 5; + } + // Clamp against total step count in case a session pointer // was seeded when the wizard had a different step layout. $this->highestStepReached = min($this->highestStepReached, 5); @@ -313,7 +343,18 @@ class LdapSettings extends Component public function saveAndAdvance() { + // Demo mode: nothing to save (isReadOnly + updated() lock the + // fields to seeded values), but visitors still want to walk + // the wizard forward step by step to see each screen. Skip + // validation / network test / persist and just advance. The + // per-step Test Bind / Test Find User buttons on individual + // steps remain available for anyone who wants to fire a live + // request against the seeded Forumsys config. if (config('app.lock_passwords')) { + if ($this->currentStep < 5) { + $this->goToStep($this->currentStep + 1); + } + return null; } @@ -1365,6 +1406,20 @@ class LdapSettings extends Component public function updated(string $property): void { + // Read-only lock: in demo mode any mutation to a persisted + // LDAP config field gets reverted to the seeded Setting value + // before the rest of the updated() logic runs. Server-side + // enforcement, so a client that fakes wire:model updates + // around the UI's readonly / disabled attributes still can't + // get modified creds into a Test Bind / Test Find User call. + // test_sample_username stays writable so the Look Up preview + // still works. + if ($this->isReadOnly && ! in_array($property, self::READ_ONLY_ALLOWED_PROPS, true)) { + $this->hydrateFromPersisted(); + + return; + } + // Trim string values on assignment so pasted-with-whitespace // inputs get normalized both in the visible field and in the // saved config. Without this a leading space on ldap_server diff --git a/resources/views/livewire/ldap-settings.blade.php b/resources/views/livewire/ldap-settings.blade.php index 826ab50813..ccb7ba8cbf 100644 --- a/resources/views/livewire/ldap-settings.blade.php +++ b/resources/views/livewire/ldap-settings.blade.php @@ -814,7 +814,7 @@ wire:loading.attr="disabled" wire:target="saveAndAdvance" class="btn btn-primary" - @disabled(config('app.lock_passwords') || ! $this->canAdvance) + @disabled(! config('app.lock_passwords') && ! $this->canAdvance) > @if ($currentStep === 4)