# CMS Security Settings — Field Reference

Configured from **CMS → Security** (admins need the `Security` privilege — see `migrations/2026-08-11_security_settings.sql`). Backed by the singleton `security_settings` table (one row, `id = 1`) plus one related column on `admins`.

## `security_settings` fields

### `jwt_lifetime_minutes`
- **Default:** 60
- **What it controls:** How long an admin's login session (the JWT token issued at login) stays valid before they're forced to log in again. Checked at *login time* — it sets the token's expiry, it doesn't change tokens already issued.
- **Example:** Your content team logs in each morning and works in the CMS for hours. Set this to `480` (8 hours) so they're never kicked out mid-edit. Conversely, if CMS access is considered high-risk, dropping it to `15` means even a copied/leaked token is only useful for 15 minutes.

### `login_lockout_max_attempts`
- **Default:** 5
- **What it controls:** How many wrong-password attempts are allowed (per email) within the lockout window before further attempts are rejected outright — even the *correct* password won't work during a lockout. Set to `0` to disable lockout entirely.
- **Example:** A bot tries to brute-force `admin@masdar.com`'s password. After 5 wrong guesses, attempt #6 gets "Too many failed login attempts. Please try again in N minute(s)." — regardless of whether attempt #6 was actually correct.

### `login_lockout_window_minutes`
- **Default:** 15
- **What it controls:** The rolling time window in which failed attempts are counted toward the max. Failures older than this window don't count.
- **Example:** With `max_attempts = 5` and `window = 15`: 5 failed logins spread out over 45 minutes never trigger a lockout (each old failure "ages out"), but 5 failures inside any 15-minute stretch do.

### `login_lockout_duration_minutes`
- **Default:** 15
- **What it controls:** Once a lockout triggers, how long it lasts before the account can try again.
- **Example:** An admin forgets their password and fat-fingers it 5 times in a row. They're locked out for 15 minutes — annoying, but self-resolving, no need to call IT to unlock the account.

### `password_min_length`
- **Default:** 8
- **What it controls:** Minimum character count enforced in `cms/admins/create.php` and `update.php` whenever a password is set or changed.
- **Example:** Raise it to `12`. A super-admin tries to create a new admin with the password `abc123` and gets "Password must be at least 12 characters long." — the account simply isn't created until the password meets the bar.

### `cms_lockdown`
- **Default:** `0`
- **What it controls:** An emergency kill switch. When `1`, **every** CMS login attempt is rejected with "CMS is temporarily locked for maintenance," regardless of how correct the credentials are.
- **Example:** You discover a vulnerability is being actively probed, or suspect an ex-employee's credentials weren't fully revoked. Flip this to `1` immediately to freeze all *new* CMS logins while you investigate, then set it back to `0` once resolved.
- **Important nuance:** Lockdown only blocks the login step. Admins who are already logged in (holding a still-valid JWT) keep working normally until their token naturally expires — lockdown doesn't retroactively kill active sessions. For that, see `token_valid_from` below.

### `ip_allowlist`
- **Default:** empty (`NULL`/`""` = allow logins from any IP)
- **What it controls:** A comma-separated list of IP addresses allowed to log in. Non-empty means every other IP is rejected at login.
- **Example:** Your CMS admins only ever work from the office or a company VPN with a fixed exit IP, `203.0.113.4`. Add that single IP to the allowlist — a stolen password is now useless from a coffee shop or another country; the attempt fails with "Access denied from this network" before the password is even checked.

### `idle_timeout_minutes`
- **Default:** 30 (`0` disables it)
- **What it controls:** Client-side-only auto-logout after N minutes with no mouse/keyboard/scroll activity in the CMS browser tab. This is delivered to the frontend at login time and enforced by `masdar-oms` itself — there's no server-side session store to enforce it against.
- **Example:** An admin steps away for a coffee break and forgets to lock their screen. After 30 idle minutes, the CMS tab automatically logs them out, so a passerby can't just start editing content on the still-open laptop.
- **Important nuance:** This is *soft* enforcement. It protects the browser tab, not the raw token — a copied access token used directly (e.g. via Postman or a script) keeps working until `jwt_lifetime_minutes` expires, idle or not.

### `log_retention_days`
- **Default:** 90 (`0` disables automatic purging)
- **What it controls:** How long rows in the audit log (`user_log` — every login, logout, and create/update/delete action) are kept before being deleted. Purging happens automatically whenever the Security page is saved ("lazy purge"), or on demand via the **Purge Now** button.
- **Example:** Compliance requires keeping 90 days of admin activity history but no longer. You never have to remember to clean the table manually — every time anyone touches the Security settings, anything older than 90 days is quietly deleted. Need to free up space right now instead of waiting? Click **Purge Now**.

## Related: `admins.token_valid_from`

Not part of `security_settings` — a per-admin column, `NULL` by default. This is what actually makes password changes and deactivations take effect *immediately* instead of waiting out the JWT's remaining lifetime.

- **Set automatically to "now"** whenever an admin's password is changed, or an admin is deactivated (`is_active` flips from `1` to `0`) via `cms/admins/update.php`.
- **Checked on every authenticated request** (`AuthHelper::verifyToken`): if the token's issue time (`iat`) is *before* `token_valid_from`, the token is rejected — a fresh login is required, even if the token hasn't hit its normal expiry.
- **Example:** An admin's account looks compromised. You reset their password right away. Even though their old JWT (now in an attacker's hands) technically had 40 minutes left before it would naturally expire, the very next request made with that old token gets rejected instantly — the attacker doesn't get to ride out the remaining session.

## Putting it together: an incident-response walkthrough

Suppose you get a report that an admin's laptop was stolen while they were logged into the CMS. A reasonable response using these settings, in order:

1. **Deactivate the admin** in CMS → Admins. This alone sets `token_valid_from`, instantly killing their current session on the stolen laptop.
2. If you're not sure *which* account(s) might be affected, or need a broader pause, flip **`cms_lockdown`** to `1` to stop any new logins platform-wide while you sort it out.
3. Check the audit log (CMS → Logs) for recent activity from that admin's account — `log_retention_days` is what determines how far back that history goes.
4. Once the situation is contained, turn `cms_lockdown` back to `0`, reactivate the admin with a new password (which itself re-triggers `token_valid_from`), and optionally tighten `ip_allowlist` going forward so logins only work from expected networks.

## Quick reference

| Field | Type | Default | Scope |
|---|---|---|---|
| `jwt_lifetime_minutes` | int | 60 | server, at login |
| `login_lockout_max_attempts` | int | 5 | server, at login |
| `login_lockout_window_minutes` | int | 15 | server, at login |
| `login_lockout_duration_minutes` | int | 15 | server, at login |
| `password_min_length` | int | 8 | server, admin create/update |
| `cms_lockdown` | 0/1 | 0 | server, at login |
| `ip_allowlist` | text (CSV) | empty | server, at login |
| `idle_timeout_minutes` | int | 30 | client only (masdar-oms) |
| `log_retention_days` | int | 90 | server, purge on save / manual |
| `admins.token_valid_from` | datetime | NULL | server, every authenticated request |
