# Architecture ## Principles 1. **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. 2. **The SQL database is infrastructure only** — `sessions`, `cache`, `jobs`/`job_batches`/`failed_jobs`, the panel's own configuration (`panel_settings` for general settings, `panel_attributes` for the AD attribute / column definitions) and the change log (`panel_audit`). None of these mirror directory state. The default `users` / `password_reset_tokens` tables from Laravel's starter migration are unused. 3. **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, no `users` table. - `LoginController@login` binds to AD with the submitted credentials, then runs `App\Ldap\Rules\MemberOfLoginGroup`: the account must be a member of `AD_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 key `ad_admin`; every panel route group is behind `auth: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` + `cn` from the `ldap` guard; falls back to the `ad_admin` session profile, then `system`). - `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](deployment.md#data--backups) 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.css` and `public/js/main.js` are **hand-written and served directly** — there is no Vite build to run for the app to work. (`resources/js`, `resources/css` and `vite.config.js` exist for anyone who wants to add a bundled asset pipeline.) - Light/dark theme via `data-bs-theme` on ``, remembered in `localStorage`. ## Internationalisation - `lang/pl/*.php` and `lang/en/*.php`, structured by namespace (`nav`, `users`, `computers`, `groups`, `gpo`, `dashboard`, `config`, `common`, `auth`, `pages`, …). Access with `__('namespace.key')`. - `config/locales.php` is 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).