Files
ADconsle/app/Http/Controllers/Admin/GroupController.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

332 lines
11 KiB
PHP

<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Services\AuditLogger;
use App\Services\OuTreeService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use LdapRecord\Models\ActiveDirectory\Entry;
use LdapRecord\Models\ActiveDirectory\Group;
use LdapRecord\Models\ActiveDirectory\User;
use LdapRecord\Models\ModelNotFoundException;
class GroupController extends Controller
{
// groupType (bity) - patrz [MS-ADTS] 2.2.13.
protected const TYPE_SECURITY = 0x80000000;
protected const SCOPE_GLOBAL = 0x00000002;
protected const SCOPE_DOMAIN_LOCAL = 0x00000004;
protected const SCOPE_UNIVERSAL = 0x00000008;
public function __construct(protected AuditLogger $audit) {}
public function index(Request $request, OuTreeService $ouTree)
{
if ($request->boolean('refresh_ou')) {
OuTreeService::flush();
}
$allOu = $request->boolean('all_ou');
$currentOu = $request->query('ou') ?: ($allOu ? null : $ouTree->rootDn('groups'));
$search = trim((string) $request->query('q', ''));
$type = $request->query('type'); // security|distribution
$scope = $request->query('scope'); // global|domain_local|universal
$perPage = max(10, min(200, (int) $request->query('per_page', 25)));
$query = Group::query();
if ($currentOu) {
$query->in($currentOu)->recursive();
}
if ($search !== '') {
$searchAttrs = ['cn', 'description', 'mail', 'samaccountname', 'info'];
$query->orFilter(function ($q) use ($searchAttrs, $search) {
foreach ($searchAttrs as $attr) {
$q->whereContains($attr, $search);
}
});
}
// 1. Pobieramy dopasowane grupy z LDAP (paged results control).
$allGroups = $query
->select(['cn', 'description', 'mail', 'grouptype', 'member', 'distinguishedname'])
->orderBy('cn')
->paginate(500);
// 2. Mapujemy na DTO/Tablicę i filtrujemy według typu i zasięgu przed stronnicowaniem
$allRows = collect($allGroups)->map(fn (Group $g) => $this->presentGroup($g))
->when($type, fn ($rows) => $rows->where('type_key', $type))
->when($scope, fn ($rows) => $rows->where('scope_key', $scope))
->values();
// 3. Budujemy natywny LengthAwarePaginator Laravela
$page = LengthAwarePaginator::resolveCurrentPage();
$total = $allRows->count();
$groups = new LengthAwarePaginator(
$allRows->forPage($page, $perPage)->values(),
$total,
$perPage,
$page,
['path' => LengthAwarePaginator::resolveCurrentPath()]
);
$groups->appends(collect($request->query())->except(['partial', 'page'])->all());
$viewData = [
'currentOu' => $currentOu,
'allOu' => $allOu,
'search' => $search,
'typeFilter' => $type,
'scopeFilter' => $scope,
'groups' => $groups,
'rows' => collect($groups->items()),
];
if ($request->boolean('partial')) {
return view('admin.groups.partials.results', $viewData);
}
return view('admin.groups.index', $viewData + [
'ouTree' => $ouTree->tree('groups'),
]);
}
/**
* Podpowiedzi grup (typeahead) - do dodawania członkostwa z profilu użytkownika.
*/
public function search(Request $request): JsonResponse
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json([]);
}
$results = Group::query()
->in(config('ldap.defaults.groups_ou') ?: config('ldap.defaults.user_ou'))->recursive()
->select(['cn', 'description'])
->orFilter(function ($sub) use ($q) {
foreach (['cn', 'samaccountname', 'description'] as $attr) {
$sub->whereContains($attr, $q);
}
})
->orderBy('cn')
->limit(25)
->paginate(25);
return response()->json(
collect($results)->take(25)->map(fn (Group $g) => [
'dn' => $g->getDn(),
'cn' => $g->getFirstAttribute('cn'),
'description' => $g->getFirstAttribute('description'),
])->values()
);
}
public function show(string $cn): JsonResponse
{
$group = $this->findByCn($cn);
$members = collect($group->members()->get())->map(function ($member) {
return [
'name' => $member->getFirstAttribute('cn'),
'samaccountname' => $member->getFirstAttribute('samaccountname'),
'dn' => $member->getDn(),
];
})->values();
return response()->json([
'cn' => $group->getFirstAttribute('cn'),
'description' => $group->getFirstAttribute('description'),
'mail' => $group->getFirstAttribute('mail'),
'dn' => $group->getDn(),
'members' => $members,
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'cn' => ['required', 'string', 'max:64'],
'description' => ['nullable', 'string', 'max:255'],
'ou' => ['required', 'string'],
'group_type' => ['required', 'in:security,distribution'],
'group_scope' => ['required', 'in:global,domain_local,universal'],
]);
$groupType = $this->groupTypeValue($data['group_type'], $data['group_scope']);
$group = new Group;
$group->inside($data['ou']);
$group->cn = $data['cn'];
$group->samaccountname = $data['cn'];
$group->description = $data['description'] ?? null;
$group->grouptype = $groupType;
$group->save();
$this->audit->log('group.create', [
'target_type' => 'group', 'target' => $data['cn'], 'target_dn' => $group->getDn(),
'context' => ['ou' => $data['ou'], 'type' => $data['group_type'], 'scope' => $data['group_scope']],
]);
return back()->with('status', __('groups.flash.created', ['cn' => $data['cn']]));
}
public function update(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'description' => ['nullable', 'string', 'max:255'],
'mail' => ['nullable', 'email'],
]);
$group = $this->findByCn($cn);
$changes = [];
foreach (['description' => 'description', 'mail' => 'mail'] as $field => $attr) {
$old = $group->getFirstAttribute($attr);
$new = $data[$field] ?? null;
if ((string) ($old ?? '') !== (string) ($new ?? '')) {
$changes[$field] = [$old, $new];
}
}
$group->description = $data['description'] ?? null;
$group->mail = $data['mail'] ?? null;
$group->save();
$this->audit->log('group.update', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'changes' => $changes ?: null,
]);
return back()->with('status', __('groups.flash.updated'));
}
public function addMember(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'samaccountname' => ['required', 'string'],
]);
$group = $this->findByCn($cn);
$user = User::query()->where('samaccountname', '=', $data['samaccountname'])->firstOrFail();
$group->members()->attach($user);
$this->audit->log('group.member_add', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'context' => ['member' => $data['samaccountname']],
]);
return back()->with('status', __('groups.flash.member_added'));
}
public function removeMember(Request $request, string $cn): RedirectResponse
{
$data = $request->validate([
'member_dn' => ['required', 'string'],
'reason' => ['nullable', 'string', 'max:1000'],
]);
$group = $this->findByCn($cn);
$member = Entry::query()->findByDn($data['member_dn']);
if ($member) {
$group->members()->detach($member);
}
$this->audit->log('group.member_remove', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $group->getDn(),
'reason' => $data['reason'] ?? null,
'context' => ['member_dn' => $data['member_dn']],
]);
return back()->with('status', __('groups.flash.member_removed'));
}
public function destroy(Request $request, string $cn): RedirectResponse
{
$data = $request->validate(['reason' => ['nullable', 'string', 'max:1000']]);
$group = $this->findByCn($cn);
$dn = $group->getDn();
$group->delete();
$this->audit->log('group.delete', [
'target_type' => 'group', 'target' => $cn, 'target_dn' => $dn,
'reason' => $data['reason'] ?? null,
]);
return back()->with('status', __('groups.flash.deleted', ['cn' => $cn]));
}
protected function findByCn(string $cn): Group
{
$group = Group::query()->where('cn', '=', $cn)->first();
if (! $group) {
throw new ModelNotFoundException("Nie znaleziono grupy: {$cn}");
}
return $group;
}
protected function groupTypeValue(string $type, string $scope): int
{
$value = match ($scope) {
'global' => self::SCOPE_GLOBAL,
'domain_local' => self::SCOPE_DOMAIN_LOCAL,
'universal' => self::SCOPE_UNIVERSAL,
};
if ($type === 'security') {
// Bitowa suma jako liczba całkowita ze znakiem (32-bit) - AD
// przechowuje groupType jako wartość ujemną dla grup bezpieczeństwa.
$value = $value | self::TYPE_SECURITY;
$value = $value > 0x7FFFFFFF ? $value - 0x100000000 : $value;
}
return $value;
}
protected function presentGroup(Group $group): array
{
$groupType = (int) ($group->getFirstAttribute('grouptype') ?? 0);
$isSecurity = ($groupType & self::TYPE_SECURITY) !== 0 || $groupType < 0;
$scopeKey = match (true) {
(abs($groupType) & self::SCOPE_DOMAIN_LOCAL) !== 0 => 'domain_local',
(abs($groupType) & self::SCOPE_UNIVERSAL) !== 0 => 'universal',
default => 'global',
};
$scopeLabel = match ($scopeKey) {
'domain_local' => 'Domain Local',
'universal' => 'Universal',
default => 'Global',
};
$memberCount = count($group->getAttribute('member') ?? []);
return [
'cn' => $group->getFirstAttribute('cn'),
'description' => $group->getFirstAttribute('description'),
'mail' => $group->getFirstAttribute('mail'),
'dn' => $group->getDn(),
'member_count' => $memberCount,
'type_key' => $isSecurity ? 'security' : 'distribution',
'type_label' => $isSecurity ? 'Security' : 'Distribution',
'scope_key' => $scopeKey,
'scope_label' => $scopeLabel,
];
}
}