First public release. - docs: document the audit log, user offboarding, CSV export and the "require a reason for destructive actions" setting across README, docs/ and CLAUDE/AGENTS.md; note retention for panel_audit - docs: fill the publishing placeholders (repo URL, security contact, issue-template links) and add a semantic-versioning policy - move agent/contributor context into CLAUDE/ (AGENTS.md + a README); root CLAUDE.md is now a thin @-import pointer - add a favicon (shield-lock, matching the login screen) to the login page and the app layout - CHANGELOG: promote [Unreleased] to [1.0.0]; keep the earlier internal build as [0.1.0] Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UfuvfwexztdS7LDpGuKsuK
200 lines
6.2 KiB
PHP
200 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use Illuminate\Support\Facades\Cache;
|
|
use LdapRecord\Models\ActiveDirectory\Computer;
|
|
use LdapRecord\Models\ActiveDirectory\Group;
|
|
use LdapRecord\Models\ActiveDirectory\OrganizationalUnit;
|
|
use LdapRecord\Models\ActiveDirectory\User;
|
|
|
|
/**
|
|
* Buduje drzewo OU ograniczone do zdefiniowanych rootów domeny,
|
|
* np. LDAP_DEFAULT_USER_OU oraz LDAP_DEFAULT_COMPUTER_OU.
|
|
*
|
|
* Zbudowane drzewo (wraz z licznikami obiektów) jest kosztowne - dla każdego
|
|
* OU leci rekursywne zapytanie zliczające - więc trzymamy je w cache pliku
|
|
* na TTL sekund. Przycisk "odśwież" w sidebarze woła OuTreeService::flush().
|
|
*/
|
|
class OuTreeService
|
|
{
|
|
/** Scope'y, dla których budujemy drzewo. */
|
|
public const SCOPES = ['users', 'computers', 'groups', 'gpo'];
|
|
|
|
protected const CACHE_TTL = 300;
|
|
|
|
protected array $tree = [];
|
|
|
|
/**
|
|
* Czyści cache drzewa OU dla wszystkich modułów.
|
|
*/
|
|
public static function flush(): void
|
|
{
|
|
foreach (self::SCOPES as $scope) {
|
|
Cache::store('file')->forget("ou_tree:{$scope}");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DN najwyższego węzła (rootu) drzewa dla danego scope - używane do
|
|
* domyślnego zaznaczenia OU po wejściu na stronę.
|
|
*/
|
|
public function rootDn(string $scope = 'users'): ?string
|
|
{
|
|
$tree = $this->tree($scope);
|
|
|
|
return $tree[0]['dn'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* Zwraca drzewo OU dla konkretnego modułu (users, computers, groups, gpo).
|
|
*/
|
|
public function tree(string $scope = 'users'): array
|
|
{
|
|
$scopeKey = strtolower($scope);
|
|
|
|
if (isset($this->tree[$scopeKey])) {
|
|
return $this->tree[$scopeKey];
|
|
}
|
|
|
|
// json_encode/decode spłaszcza referencje wewnątrz drzewa (children są
|
|
// dopinane przez &$node) - bez tego cache serializowałby dziwne grafy.
|
|
$build = fn () => json_decode(json_encode($this->build($scopeKey)), true);
|
|
|
|
$tree = (self::CACHE_TTL > 0 && ! app()->runningUnitTests())
|
|
? Cache::store('file')->remember("ou_tree:{$scopeKey}", self::CACHE_TTL, $build)
|
|
: $build();
|
|
|
|
return $this->tree[$scopeKey] = $tree;
|
|
}
|
|
|
|
/**
|
|
* Właściwe zbudowanie drzewa z LDAP (bez cache).
|
|
*/
|
|
protected function build(string $scopeKey): array
|
|
{
|
|
$allowedRoots = $this->allowedRootsForScope($scopeKey);
|
|
|
|
$ous = OrganizationalUnit::query()
|
|
->select(['ou', 'distinguishedname'])
|
|
->get();
|
|
|
|
$byDn = [];
|
|
|
|
foreach ($ous as $ou) {
|
|
$dn = trim((string) $ou->getDn());
|
|
|
|
if (! $this->isWithinAllowedRoots($dn, $allowedRoots)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedDn = strtolower($dn);
|
|
$byDn[$normalizedDn] = [
|
|
'dn' => $dn,
|
|
'name' => $ou->getFirstAttribute('ou') ?? $dn,
|
|
'children' => [],
|
|
'count' => 0,
|
|
];
|
|
}
|
|
|
|
$roots = [];
|
|
|
|
foreach ($byDn as $normalizedDn => &$node) {
|
|
$parentDn = $this->parentDn($normalizedDn);
|
|
|
|
while ($parentDn && ! isset($byDn[$parentDn])) {
|
|
$parentDn = $this->parentDn($parentDn);
|
|
}
|
|
|
|
if ($parentDn && isset($byDn[$parentDn])) {
|
|
$byDn[$parentDn]['children'][] = &$node;
|
|
} else {
|
|
$roots[] = &$node;
|
|
}
|
|
}
|
|
unset($node);
|
|
|
|
foreach ($roots as &$root) {
|
|
$this->attachCounts($root, $scopeKey);
|
|
}
|
|
unset($root);
|
|
|
|
return $roots;
|
|
}
|
|
|
|
protected function attachCounts(array &$node, string $scope): int
|
|
{
|
|
foreach ($node['children'] as &$child) {
|
|
$this->attachCounts($child, $scope);
|
|
}
|
|
unset($child);
|
|
|
|
// countObjectsInOu() liczy już rekursywnie (całe poddrzewo danego OU),
|
|
// więc NIE wolno dodawać sum z dzieci - to powodowało wielokrotne
|
|
// zliczanie tych samych obiektów (np. root 313 zamiast 121).
|
|
$node['count'] = $this->countObjectsInOu($node['dn'], $scope);
|
|
|
|
return $node['count'];
|
|
}
|
|
|
|
protected function countObjectsInOu(string $ouDn, string $scope): int
|
|
{
|
|
$query = match ($scope) {
|
|
'users' => User::query()->in($ouDn)->recursive(),
|
|
'computers' => Computer::query()->in($ouDn)->recursive(),
|
|
'groups' => Group::query()->in($ouDn)->recursive(),
|
|
default => null,
|
|
};
|
|
|
|
if (! $query) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) $query->select(['distinguishedname'])->get()->count();
|
|
}
|
|
|
|
protected function allowedRootsForScope(string $scope): array
|
|
{
|
|
$userOu = trim((string) config('ldap.defaults.user_ou', ''));
|
|
$computerOu = trim((string) config('ldap.defaults.computer_ou', ''));
|
|
$groupsOu = trim((string) config('ldap.defaults.groups_ou', ''));
|
|
$baseDn = trim((string) config('ldap.connections.default.base_dn', ''));
|
|
|
|
return match ($scope) {
|
|
'users' => array_values(array_filter([$userOu], fn ($value) => $value !== '')),
|
|
'computers' => array_values(array_filter([$computerOu], fn ($value) => $value !== '')),
|
|
'groups' => array_values(array_filter([$groupsOu ?: $userOu], fn ($value) => $value !== '')),
|
|
'gpo' => array_values(array_filter([$baseDn], fn ($value) => $value !== '')),
|
|
default => array_values(array_filter([$userOu, $computerOu, $groupsOu], fn ($value) => $value !== '')),
|
|
};
|
|
}
|
|
|
|
protected function isWithinAllowedRoots(string $dn, array $allowedRoots): bool
|
|
{
|
|
$normalizedDn = strtolower(trim($dn));
|
|
|
|
foreach ($allowedRoots as $root) {
|
|
$normalizedRoot = strtolower(trim((string) $root));
|
|
if ($normalizedRoot === '') {
|
|
continue;
|
|
}
|
|
|
|
if ($normalizedDn === $normalizedRoot || str_ends_with($normalizedDn, ','.$normalizedRoot)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
protected function parentDn(string $dn): ?string
|
|
{
|
|
$parts = explode(',', $dn);
|
|
array_shift($parts);
|
|
|
|
$parent = implode(',', $parts);
|
|
|
|
return $parent === '' ? null : strtolower($parent);
|
|
}
|
|
}
|