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
332 lines
11 KiB
PHP
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,
|
|
];
|
|
}
|
|
}
|