Files
ADconsle/app/Http/Controllers/Admin/GpoController.php
T
kzbkowskiandClaude Sonnet 5 1126d96271
CI / PHP 8.2 (push) Failing after 2m46s
CI / PHP 8.3 (push) Failing after 59s
CI / PHP 8.4 (push) Failing after 1m7s
ADconsole v1.0.0
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
2026-09-08 23:14:39 +02:00

380 lines
14 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Carbon;
use LdapRecord\Models\ActiveDirectory\Container;
use LdapRecord\Models\ActiveDirectory\Entry;
use LdapRecord\Models\ActiveDirectory\OrganizationalUnit;
class GpoController extends Controller
{
/**
* Listuje obiekty GroupPolicyContainer z kontenera CN=Policies,CN=System.
*
* UWAGA: Pełna edycja zawartości GPO (rejestr, szablony administracyjne)
* wymaga dostępu do SYSVOL po SMB i wykracza poza zakres tego panelu -
* moduł pozwala na razie na przegląd polityk oraz podgląd, do jakich OU
* są podpięte (atrybut gpLink na obiektach OU).
*/
public function index(Request $request)
{
$search = trim((string) $request->query('q', ''));
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$baseDn = config('ldap.connections.default.base_dn');
$policiesContainer = "CN=Policies,CN=System,{$baseDn}";
$gpos = Entry::query()
->in($policiesContainer)
->whereEquals('objectclass', 'groupPolicyContainer')
->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags'])
->get();
$linkedOuMap = $this->buildGpoLinkMap($baseDn);
$allRows = collect($gpos)->map(function (Entry $gpo) use ($linkedOuMap) {
$guid = trim((string) $gpo->getFirstAttribute('cn'), '{}');
$flags = (int) ($gpo->getFirstAttribute('flags') ?? 0);
return [
'guid' => $guid,
'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'),
'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')),
'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')),
'version' => $gpo->getFirstAttribute('versionnumber'),
// flags: bit0 = user config disabled, bit1 = computer config disabled
'user_settings_enabled' => ! ($flags & 1),
'computer_settings_enabled' => ! ($flags & 2),
'linked_ous' => $linkedOuMap[strtolower('{'.$guid.'}')] ?? [],
];
});
if ($search !== '') {
$needle = mb_strtolower($search);
$allRows = $allRows->filter(function (array $row) use ($needle) {
return str_contains(mb_strtolower((string) $row['name']), $needle)
|| str_contains(mb_strtolower((string) $row['guid']), $needle)
|| str_contains(mb_strtolower(implode(' ', $row['linked_ous'])), $needle);
});
}
$allRows = $allRows->sortBy('name')->values();
$page = LengthAwarePaginator::resolveCurrentPage();
$gpos = new LengthAwarePaginator(
$allRows->forPage($page, $perPage)->values(),
$allRows->count(),
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
$gpos->appends(collect($request->query())->except(['partial', 'page'])->all());
$viewData = [
'search' => $search,
'gpos' => $gpos,
'rows' => collect($gpos->items()),
];
if ($request->boolean('partial')) {
return view('admin.gpo.partials.results', $viewData);
}
return view('admin.gpo.index', $viewData);
}
/**
* Szczegóły pojedynczego GPO: wersje, stan konfiguracji, ścieżka SYSVOL,
* filtr WMI, rozszerzenia CSE oraz miejsca podpięcia (z flagą "wymuszone").
*/
public function show(string $guid): JsonResponse
{
$baseDn = config('ldap.connections.default.base_dn');
$guid = '{'.trim($guid, '{}').'}';
$gpo = Entry::query()
->in("CN=Policies,CN=System,{$baseDn}")
->whereEquals('cn', $guid)
->select(['cn', 'displayname', 'whencreated', 'whenchanged', 'versionnumber', 'flags', 'gpcfilesyspath', 'gpcwqlfilter', 'gpcmachineextensionnames', 'gpcuserextensionnames'])
->first();
if (! $gpo) {
abort(404, __('gpo.not_found'));
}
$version = (int) ($gpo->getFirstAttribute('versionnumber') ?? 0);
$flags = (int) ($gpo->getFirstAttribute('flags') ?? 0);
// versionNumber: młodsze 16 bitów = User, starsze 16 bitów = Computer.
$userVer = $version & 0xFFFF;
$computerVer = ($version >> 16) & 0xFFFF;
// Filtr WMI: gPCWQLFilter = "[domena;{GUID};0]".
$wmi = null;
if ($wql = $gpo->getFirstAttribute('gpcwqlfilter')) {
if (preg_match('/\{[0-9A-Fa-f-]+\}/', $wql, $mm)) {
$filter = Entry::query()
->in("CN=SOM,CN=WMIPolicy,CN=System,{$baseDn}")
->whereEquals('cn', $mm[0])
->select(['msp-wmi-name'])
->first();
$wmi = $filter?->getFirstAttribute('msp-wmi-name') ?? $mm[0];
} else {
$wmi = $wql;
}
}
// Podpięcia + flaga enforced dla tego GPO.
$links = [];
foreach ($this->allGpLinkSources($baseDn) as $src) {
foreach ($this->parseGpLink($src['gplink']) as $lnk) {
if (strcasecmp($lnk['guid'], $guid) === 0) {
$links[] = ['location' => $src['name'], 'enforced' => $lnk['enforced'], 'enabled' => $lnk['enabled']];
}
}
}
return response()->json([
'guid' => trim($guid, '{}'),
'name' => $gpo->getFirstAttribute('displayname') ?: $gpo->getFirstAttribute('cn'),
'created' => $this->parseLdapDate($gpo->getFirstAttribute('whencreated')),
'changed' => $this->parseLdapDate($gpo->getFirstAttribute('whenchanged')),
'version' => $version,
'user_version' => $userVer,
'computer_version' => $computerVer,
'user_settings_enabled' => ! ($flags & 1),
'computer_settings_enabled' => ! ($flags & 2),
'sysvol_path' => $gpo->getFirstAttribute('gpcfilesyspath'),
'wmi_filter' => $wmi,
'machine_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcmachineextensionnames'), '['),
'user_cse_count' => substr_count((string) $gpo->getFirstAttribute('gpcuserextensionnames'), '['),
'links' => $links,
]);
}
/**
* "Które GPO obowiązują" (RSoP-lite) dla wskazanej OU: idziemy od domeny
* w dół do wybranej OU, respektując blokowanie dziedziczenia (gPOptions
* bit 0) oraz linki wymuszone (enforced - obowiązują mimo blokady).
*/
public function rsop(Request $request): JsonResponse
{
$targetDn = trim((string) $request->query('ou', ''));
$baseDn = config('ldap.connections.default.base_dn');
if ($targetDn === '') {
return response()->json(['error' => 'Podaj OU.'], 422);
}
$names = $this->gpoNameMap($baseDn);
// Łańcuch DN od domeny w dół do wskazanej OU.
$chain = $this->dnChain($targetDn, $baseDn);
$applied = []; // finalna lista (w kolejności stosowania: później = wyższy priorytet)
$enforced = []; // linki wymuszone - dokładane na końcu (najwyższy priorytet)
$blockedFrom = null;
foreach ($chain as $depth => $node) {
$obj = $this->readObject($node['dn']);
if (! $obj) {
continue;
}
$gpOptions = (int) ($obj->getFirstAttribute('gpoptions') ?? 0);
$blockInheritance = (bool) ($gpOptions & 1);
$linkList = $this->parseGpLink($obj->getFirstAttribute('gplink') ?? '');
// Kolejność stosowania na jednym poziomie: od dołu listy do góry.
$linkList = array_reverse($linkList);
foreach ($linkList as $lnk) {
if (! $lnk['enabled']) {
continue;
}
$entry = [
'guid' => trim($lnk['guid'], '{}'),
'name' => $names[strtolower($lnk['guid'])] ?? $lnk['guid'],
'from' => $node['name'],
'enforced' => $lnk['enforced'],
];
if ($lnk['enforced']) {
$enforced[] = $entry;
} else {
$applied[] = $entry;
}
}
if ($blockInheritance && $depth < count($chain) - 1) {
// Blokada na tym poziomie: linki nieenforced z poziomów WYŻEJ
// (już zebrane) przestają obowiązywać.
$applied = [];
$blockedFrom = $node['name'];
}
}
return response()->json([
'target' => $targetDn,
'blocked_inheritance_at' => $blockedFrom,
'effective' => array_merge($applied, $enforced),
]);
}
/**
* @return list<array{dn:string,name:string}> od domeny w dół do $targetDn
*/
protected function dnChain(string $targetDn, string $baseDn): array
{
$parts = array_map('trim', explode(',', $targetDn));
$chain = [];
for ($i = 0; $i < count($parts); $i++) {
$dn = implode(',', array_slice($parts, $i));
if (! str_contains(strtolower($dn), 'ou=') && strcasecmp($dn, $baseDn) !== 0) {
continue;
}
[$rdnKey, $rdnVal] = array_pad(explode('=', $parts[$i], 2), 2, '');
$chain[] = ['dn' => $dn, 'name' => strcasecmp($dn, $baseDn) === 0 ? 'Domena' : $rdnVal];
}
return array_reverse($chain); // domena najpierw
}
protected function readObject(string $dn): ?Entry
{
try {
return Entry::query()->select(['gplink', 'gpoptions'])->find($dn);
} catch (\Throwable) {
return null;
}
}
/**
* @return list<array{name:string,gplink:string}>
*/
protected function allGpLinkSources(string $baseDn): array
{
$out = [];
foreach (OrganizationalUnit::query()->select(['ou', 'distinguishedname', 'gplink'])->paginate(500) as $ou) {
if ($gp = $ou->getFirstAttribute('gplink')) {
$out[] = ['name' => $ou->getFirstAttribute('ou') ?? $ou->getDn(), 'gplink' => $gp];
}
}
$root = Container::query()->find($baseDn);
if ($root && ($gp = $root->getFirstAttribute('gplink'))) {
$out[] = ['name' => __('gpo.domain_root'), 'gplink' => $gp];
}
return $out;
}
/**
* @return array<string,string> strtolower({GUID}) => displayName
*/
protected function gpoNameMap(string $baseDn): array
{
$map = [];
foreach (Entry::query()->in("CN=Policies,CN=System,{$baseDn}")->whereEquals('objectclass', 'groupPolicyContainer')->select(['cn', 'displayname'])->paginate(500) as $g) {
$map[strtolower((string) $g->getFirstAttribute('cn'))] = $g->getFirstAttribute('displayname') ?: $g->getFirstAttribute('cn');
}
return $map;
}
/**
* Parsuje gpLink na listę linków z flagami.
*
* @return list<array{guid:string,enforced:bool,enabled:bool}>
*/
protected function parseGpLink(string $gpLink): array
{
preg_match_all('/\[LDAP:\/\/(?:cn=)?(\{[0-9A-Fa-f-]+\})[^;]*;\s*(\d+)\]/i', $gpLink, $m, PREG_SET_ORDER);
return array_map(function ($x) {
$opt = (int) $x[2];
return [
'guid' => $x[1],
'enabled' => ! ($opt & 1), // bit0 = link disabled
'enforced' => (bool) ($opt & 2), // bit1 = enforced
];
}, $m);
}
/**
* Buduje mapę GUID GPO => lista obiektów (OU / Domena), do których jest podpięte,
* na podstawie atrybutu gpLink obecnego na obiektach OU oraz w korzeniu domeny.
*
* Format gpLink: [LDAP://cn={GUID},cn=policies,cn=system,DC=...;<opcje>][...]
*/
protected function buildGpoLinkMap(string $baseDn): array
{
$map = [];
// 1. Pobierz podpięcia z obiektów OrganizationalUnit
$ous = OrganizationalUnit::query()
->select(['ou', 'distinguishedname', 'gplink'])
->get();
foreach ($ous as $ou) {
$gpLink = $ou->getFirstAttribute('gplink');
if ($gpLink) {
$ouName = $ou->getFirstAttribute('ou') ?? $ou->getDn();
$this->extractGuidsFromGpLink($gpLink, $ouName, $map);
}
}
// 2. Pobierz podpięcia bezpośrednio z korzenia domeny (Domain Root)
$domainRoot = Container::query()->find($baseDn);
if ($domainRoot) {
$gpLink = $domainRoot->getFirstAttribute('gplink');
if ($gpLink) {
$this->extractGuidsFromGpLink($gpLink, __('gpo.domain_root'), $map);
}
}
return $map;
}
/**
* Parsuje ciąg gpLink i dodaje odnalezione dopasowania do mapy.
*/
protected function extractGuidsFromGpLink(string $gpLink, string $locationName, array &$map): void
{
preg_match_all('/\[LDAP:\/\/cn=(\{[0-9A-Fa-f-]+\}),/i', $gpLink, $matches);
foreach ($matches[1] ?? [] as $guid) {
$key = strtolower($guid);
if (! isset($map[$key])) {
$map[$key] = [];
}
if (! in_array($locationName, $map[$key], true)) {
$map[$key][] = $locationName;
}
}
}
/**
* Konwertuje ciąg daty z LDAP (np. 20231025120000.0Z) na obiekt Carbon/string.
*/
protected function parseLdapDate(?string $ldapDate): ?string
{
if (! $ldapDate) {
return null;
}
try {
return Carbon::createFromFormat('YmdHis.0Z', $ldapDate)->format('Y-m-d H:i');
} catch (\Throwable $e) {
return $ldapDate;
}
}
}