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
7.7 KiB
7.7 KiB
Architecture
Principles
- The directory is the database. No table mirrors AD. Controllers call LdapRecord which talks to a DC per request. Nothing to sync, nothing to reconcile, no stale copy.
- The SQL database is infrastructure only —
sessions,cache,jobs/job_batches/failed_jobs, the panel's own configuration (panel_settingsfor general settings,panel_attributesfor the AD attribute / column definitions) and the change log (panel_audit). None of these mirror directory state. The defaultusers/password_reset_tokenstables from Laravel's starter migration are unused. - Thin services, fat controllers-per-module. One controller per screen; a handful of small services for the cross-cutting bits.
Request lifecycle
Browser
│ cookie session (file) ──► ad_admin profile
▼
routes/web.php (English URL segments and route names)
│ middleware: auth:ldap + App\Http\Middleware\SetLocale
▼
App\Http\Controllers\Admin\*Controller
│ LdapRecord query / model save
▼
Active Directory (LDAPS) Cache::store('file') ── OU tree / dashboard / org chart
Authentication
- Guard
ldap(config/auth.php) — LdapRecord "plain bind", no Eloquent user provider, nouserstable. LoginController@loginbinds to AD with the submitted credentials, then runsApp\Ldap\Rules\MemberOfLoginGroup: the account must be a member ofAD_LOGIN_GROUP(accepts full DN or CN; walks nested groups; includes the primary group).- On success the sanitised profile from
App\Ldap\User::toPanelProfile()is written to the session keyad_admin; every panel route group is behindauth:ldap. - Logout clears the session. Sessions are
file-driver by default.
Key classes
| Class | Responsibility |
|---|---|
App\Ldap\User |
AD user model. attributeMap() (config/ldap.php → field names), panelAttributes() (the attribute list every user query selects), toPanelProfile(). |
App\Ldap\Rules\MemberOfLoginGroup |
Login authorisation. |
App\Services\PanelSettingsService |
The one entry point for panel config. General settings ← panel_settings (JSON row panel, merged with defaults()); attribute / column definitions ← panel_attributes (one row each, ordered by position, merged with defaultAttributes()), normalised with the locked rows enforced. |
App\Models\PanelSetting / App\Models\PanelAttribute |
Eloquent models for the two config tables. Don't read them from controllers — go through PanelSettingsService. |
App\Services\AttributeCipher |
Laravel-Crypt-compatible encrypt/decrypt for attributes typed encrypted; key from the panel (or a pasted APP_KEY). |
App\Services\LdapAttributeHelper |
Decodes userAccountControl bit flags, Windows FileTime ↔ Carbon, accountExpires / pwdLastSet "never" sentinels, derived status badge, passwordExpiresAt(). |
App\Services\OuTreeService |
Builds the sidebar OU tree (with object counts), cached on the file store; flush() on demand. |
App\Services\AuditLogger |
Writes one panel_audit row per panel action (actor from the ldap guard, action slug, target, optional reason + changes/context JSON). Best-effort: a write failure is report()ed, never propagated, so auditing can't break an AD operation. |
App\Http\Middleware\SetLocale |
Locale from session → cookie → APP_LOCALE. |
Modules
| Route prefix | Controller | Notes |
|---|---|---|
/dashboard |
DashboardController |
Streams every user with chunk(), aggregates in PHP, caches the result 5 min (dashboard:summary on the file store) with a manual refresh. |
/users |
UserController |
List + OU tree + search; CRUD; password reset / OU move / expiry — single and bulk; filter= query for the dashboard tiles; CSV export of the matching list (GET /users/export, streamed, UTF-8 BOM, one column per configured attribute); offboarding (offboardOne / bulkOffboard → runOffboard: attribute changes → group removal → OU move, in that order because the move changes the DN). |
/computers |
ComputerController |
List + OU tree; details include LAPS (legacy ms-Mcs-AdmPwd and Windows LAPS msLAPS-Password) and BitLocker (msFVE-RecoveryInformation); trust-account reset; enable/disable; delete. |
/groups |
GroupController |
List with type/scope filter; create; edit description & mail; membership add/remove; delete. |
/organisation |
OrgChartController |
manager → directReports; rendered client-side with d3-org-chart (CDN); cached 10 min. |
/gpo |
GpoController |
Read-only: groupPolicyContainer objects, gPLink on OUs, simplified RSoP. |
/audit |
AuditController |
Read-only view of panel_audit with filters (actor / action / object / date range). No write route. |
/settings |
ConfigController |
General settings + attribute definitions → PanelSettingsService. |
Audit log
Every write the panel performs against AD calls AuditLogger::log($action, $opts), which appends a row to panel_audit:
actor/actor_name— the signed-in admin (sAMAccountName+cnfrom theldapguard; falls back to thead_adminsession profile, thensystem).action— a stable slug:user.disable,user.reset_password,user.offboard,user.bulk_move,computer.laps_view,group.member_add,config.settings, … (viewing a LAPS / BitLocker secret is audited too).target_type/target/target_dn— the object acted on.reason— free text. Optional by default; the Audit log settings block has a "require a reason for destructive actions" switch (panel_settings→audit.require_reason) that makes it mandatory for disable / delete / move / offboard. A row is written either way.changes/context— JSON: a before/after diff, and extras such as the number of accounts in a bulk action or the list of offboarding steps run.ip,created_at.
The table is read only by AuditController (filter + paginate) and never by the
rest of the app. A failed insert is report()ed and swallowed — auditing can
never abort an AD operation. Nothing prunes the table; see
deployment.md for retention.
Caching
App-level caches use Cache::store('file') explicitly (independent of
CACHE_STORE):
| Key | TTL | Refresh |
|---|---|---|
ou_tree:{scope} |
10 min | ?refresh_ou=1 in a module, or OuTreeService::flush() |
dashboard:summary (+ …_ts) |
5 min | "Recalculate now" button (POST /dashboard/refresh) |
org_chart:v3 (+ …_ts) |
10 min | "Refresh" on the org chart |
Some user mutations also bust org_chart:v3 and dashboard:summary.
Front-end
- Bootstrap 5 + Bootstrap Icons from a CDN.
public/css/style.cssandpublic/js/main.jsare hand-written and served directly — there is no Vite build to run for the app to work. (resources/js,resources/cssandvite.config.jsexist for anyone who wants to add a bundled asset pipeline.)- Light/dark theme via
data-bs-themeon<html>, remembered inlocalStorage.
Internationalisation
lang/pl/*.phpandlang/en/*.php, structured by namespace (nav,users,computers,groups,gpo,dashboard,config,common,auth,pages, …). Access with__('namespace.key').config/locales.phpis the single list of available languages.- Switchers on the login page, the navbar and the footer;
GET /language/{locale}(LocaleController@switch) persists the choice.
What is intentionally not here
- No local mirror / cache of AD objects.
- No GPO content editing (SYSVOL over SMB).
- No background jobs or scheduler.
- No REST API (server-rendered Blade only; some endpoints return partial HTML or JSON for the live-search / typeahead helpers).