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
380 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|