Explorar o código

docs: add operator user manual

Field-by-field walkthrough of every UI screen, first-time
configuration steps, decay-engine knobs, install procedure, and
backup / disaster recovery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa hai 1 semana
pai
achega
294bbb3974
Modificáronse 1 ficheiros con 798 adicións e 0 borrados
  1. 798 0
      doc/user-manual.md

+ 798 - 0
doc/user-manual.md

@@ -0,0 +1,798 @@
+# IRDB User Manual
+
+> Audience: operators using the IRDB web UI day-to-day. Covers
+> first-time setup, every screen and field, how the decay engine works,
+> installation, and backup / disaster recovery.
+
+This document is the operational guide. For architecture see
+[`architecture.md`](./architecture.md); for API contracts see
+[`api-overview.md`](./api-overview.md); for authentication wiring see
+[`auth-flows.md`](./auth-flows.md).
+
+---
+
+## 1. Installation and first run
+
+### 1.1 Prerequisites
+
+- A Linux host with Docker 24+ and the `docker compose` plugin.
+- Outbound HTTPS to download container images and (optionally) GeoIP
+  MMDBs from your chosen provider.
+- A DNS name and TLS certificate if you intend to expose the UI to
+  non-loopback clients (use a reverse proxy — see
+  [`README.md` § Reverse proxy](../README.md#reverse-proxy-in-production)).
+
+### 1.2 Boot sequence (≈5 minutes)
+
+```bash
+git clone <repo-url> irdb && cd irdb
+cp .env.example .env
+$EDITOR .env                      # fill in the secrets — see § 1.3
+docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
+```
+
+That brings up:
+
+| Container   | Port | Role                                                              |
+|-------------|------|-------------------------------------------------------------------|
+| `migrate`   | —    | Runs Phinx migrations + seeds, exits 0.                           |
+| `api`       | 8081 | JSON backend (reporters, consumers, admin endpoints, jobs).       |
+| `ui`        | 8080 | Browser-facing PHP+Twig BFF.                                      |
+| `scheduler` | —    | (Overlay) busybox `crond` posting `/internal/jobs/tick` once/min. |
+| `mysql`     | 3306 | (Optional) only if you uncomment the block and set `DB_DRIVER=mysql`. |
+
+Health checks:
+
+- `curl http://localhost:8080/healthz` → UI status (`api_reachable`).
+- `curl http://localhost:8081/healthz` → API status with DB + jobs
+  freshness.
+
+The api validates required env vars on boot, so misconfiguration
+crashes `docker compose up` rather than the first user click.
+
+### 1.3 Required secrets
+
+Every value in `.env` marked as a secret must be generated. The full
+recipe lives in [`README.md` § Generating secrets](../README.md#generating-secrets);
+the key ones are:
+
+| Variable                  | How to generate                                                                                  |
+|---------------------------|--------------------------------------------------------------------------------------------------|
+| `UI_SECRET`               | `openssl rand -hex 32` — signs UI session cookies.                                               |
+| `APP_SECRET`              | `openssl rand -hex 32` — signs api-side ETags and similar.                                       |
+| `INTERNAL_JOB_TOKEN`      | `openssl rand -hex 32` — Bearer for `/internal/jobs/*`.                                          |
+| `UI_SERVICE_TOKEN`        | `irdb_svc_…` — bridge between UI and API (see `.env.example` for the one-liner).                |
+| `LOCAL_ADMIN_PASSWORD_HASH` | `php -r "echo password_hash('your-pw', PASSWORD_ARGON2ID);"` — **double every `$` to `$$`** in the .env file so docker-compose doesn't eat it. |
+
+### 1.4 First-time configuration walkthrough
+
+After `docker compose up -d`, sign in to the UI at
+`http://localhost:8080/` with the local admin credentials you set in
+`.env`. The first session lands on the dashboard.
+
+Do these in order — each step depends on the previous one:
+
+1. **Verify defaults.** Open `Categories`. Five seeded categories
+   (`brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`)
+   should be present, each with `exponential` decay and a 14-day
+   half-life. Open `Policies`; three seeded policies (`strict`,
+   `moderate`, `paranoid`) should be present.
+2. **Create the categories you actually need.** If the seeded set
+   doesn't fit (e.g. you want `phishing`), use `Categories → New`.
+   You can deactivate seeded categories you won't use by opening their
+   detail page and unchecking "active".
+3. **Adjust policy thresholds.** Open `Policies → moderate → Edit`.
+   The threshold matrix has one row per category. Empty value = "this
+   category does not contribute to this policy". The default seed sets
+   every category to `1.0` for `moderate`, `2.5` for `strict`, `0.3`
+   for `paranoid`. Tune to taste; the live preview at the bottom of
+   the edit page shows the resulting blocklist size.
+4. **Create your first reporter.** `Reporters → New`. Pick a name
+   (e.g. `web-prod-01`) and leave `Trust weight` at `1.0`.
+5. **Issue a reporter token.** `Tokens → Issue`, kind = `reporter`,
+   pick the reporter you just made. **A modal shows the raw
+   `irdb_rep_…` token once.** Copy it into your reporter's config
+   immediately — refreshing the page discards it permanently.
+6. **Smoke-test ingestion.** From any host that can reach the api:
+   ```bash
+   curl -X POST http://<api-host>:8081/api/v1/report \
+     -H "Authorization: Bearer irdb_rep_..." \
+     -H "Content-Type: application/json" \
+     -d '{"ip":"203.0.113.42","category":"brute_force"}'
+   # → 202 with {"report_id": 1, ...}
+   ```
+   Refresh the UI dashboard; counters should go up.
+7. **Create your first consumer.** `Consumers → New`. Pick a policy
+   (`moderate` is a good default).
+8. **Issue a consumer token.** `Tokens → Issue`, kind = `consumer`,
+   pick the consumer.
+9. **Smoke-test distribution.**
+   ```bash
+   curl http://<api-host>:8081/api/v1/blocklist \
+     -H "Authorization: Bearer irdb_con_..."
+   # → text/plain, one IP/CIDR per line
+   ```
+10. **Set up scheduling.** If you used `compose.scheduler.yml`, the
+    sidecar is already firing `/internal/jobs/tick` once a minute.
+    Otherwise install the host crontab from `examples/scheduler/`.
+    Without a tick driver, scores never decay.
+11. **Configure GeoIP** (optional). Default provider is **DB-IP**
+    (no credential needed). Switch to MaxMind by setting
+    `GEOIP_PROVIDER=maxmind` + `MAXMIND_LICENSE_KEY`, or to IPinfo
+    with `GEOIP_PROVIDER=ipinfo` + `IPINFO_TOKEN`. Restart the api
+    and trigger `refresh-geoip` from `Settings`.
+12. **Map OIDC groups to roles** (if using Entra). Until the
+    dedicated admin UI ships, populate `oidc_role_mappings` directly:
+    ```bash
+    docker compose exec -T api sqlite3 /data/irdb.sqlite \
+      "INSERT INTO oidc_role_mappings(group_id, role) VALUES('<group-id>', 'admin');"
+    ```
+
+### 1.5 Reverse-proxy and TLS
+
+The default compose deployment serves plain HTTP. Production should
+sit behind a reverse proxy that terminates TLS and routes by
+hostname. A working Caddy config is in
+[`examples/reverse-proxy/Caddyfile`](../examples/reverse-proxy/Caddyfile).
+
+When deploying behind a proxy, set `APP_ENV=production` so HSTS,
+Secure-cookie, and CSRF behave correctly.
+
+---
+
+## 2. Roles and permissions
+
+Three roles are persisted on the user record (`viewer`, `operator`,
+`admin`). They are the source of truth — the UI hides buttons the
+current user can't use, but security is enforced server-side. If a
+viewer crafts a direct URL to a write endpoint, the api returns
+`403`.
+
+| Action                                    | viewer | operator | admin |
+|-------------------------------------------|:------:|:--------:|:-----:|
+| Browse IPs / scores / history             |   ✓    |    ✓     |   ✓   |
+| Browse audit log                          |   ✓    |    ✓     |   ✓   |
+| Add / remove manual blocks                |        |    ✓     |   ✓   |
+| Manage allowlist                          |        |    ✓     |   ✓   |
+| Manage policies / categories              |        |          |   ✓   |
+| Manage reporters / consumers              |        |          |   ✓   |
+| Manage tokens                             |        |          |   ✓   |
+| Trigger jobs from Settings                |        |          |   ✓   |
+| View Settings (config + jobs)             |        |          |   ✓   |
+
+The local admin always has `admin` role. OIDC users get their role
+from `oidc_role_mappings`; the default for unmapped users is
+`OIDC_DEFAULT_ROLE` (`viewer` by default; set to `none` to deny
+login outright).
+
+---
+
+## 3. UI screens — field-by-field reference
+
+The UI is laid out as a sticky top bar plus a left sidebar. The top
+bar carries the IRDB logo (link to `/app/me`), a theme toggle, and
+the user menu (`My identity` + `Sign out`). The sidebar lists every
+section; the active section is highlighted.
+
+All forms are CSRF-protected. Destructive actions (delete, revoke)
+open a confirmation modal before submitting.
+
+### 3.1 `/login` — Sign-in screen
+
+Shown when no session is active.
+
+| Element                    | Purpose                                                                           |
+|----------------------------|-----------------------------------------------------------------------------------|
+| **Sign in with Microsoft** | Initiates the OIDC authorization-code-with-PKCE flow against Entra. Visible when `OIDC_ENABLED=true`. |
+| **Use local sign-in**      | Toggle that reveals the username/password form. Visible when `LOCAL_ADMIN_ENABLED=true`. Hidden entirely otherwise. |
+| **Username** field         | Plaintext username; must match `LOCAL_ADMIN_USERNAME`.                            |
+| **Password** field         | Validated against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id) on the UI side.          |
+| **Sign in** button         | Submits the form. After 5 failures the form is locked for 30 s (per-process; restarting the UI clears the lockout — that is the documented "unlock the admin" path). |
+
+If neither method is enabled, the page shows an error pointing you
+at the env vars to set.
+
+### 3.2 `/no-access`
+
+Shown when an OIDC sign-in succeeded but the user's groups didn't
+match any row in `oidc_role_mappings` and `OIDC_DEFAULT_ROLE=none`.
+Has one button — **Back to sign-in** — and a hint to ask an admin
+to add a mapping.
+
+### 3.3 `/app/dashboard` — Dashboard
+
+Default landing page after login. Refreshes every 30 s on the api
+side (cached); reload the page to fetch fresh numbers.
+
+**Header** — title and a hint reading "Last 24 hours, refreshed every
+30 s. reference policy: moderate". The reference policy comes from
+the api and is currently fixed to `moderate`; it appears in the
+"Active blocks" caveat.
+
+**Counter cards** (four):
+
+| Card             | Source                                                                          |
+|------------------|----------------------------------------------------------------------------------|
+| Active blocks    | Distinct IPs in `ip_scores` with score > 0 plus single-IP `manual_blocks`. Approximation under the `moderate` reference policy. |
+| Manual blocks    | Total rows in `manual_blocks` across IPs and subnets.                            |
+| Allowlist entries| Total rows in `allowlist`.                                                       |
+| Reports (24h)    | Rows in `reports` with `received_at` in the last 24 h, all categories.           |
+
+**Reports per hour** chart — Chart.js bar chart, 24 buckets keyed by
+hour (UTC). Hover to read each bucket.
+
+**Top reporters (24h)** and **Top categories (24h)** — leader-board
+tables sorted by report count. Empty if no reports ingested in the
+last day.
+
+**Jobs status** — one row per registered job: name, last status
+pill (`success` / `failure` / `skipped_locked` / `running`), an
+amber **overdue** pill if the job hasn't completed within its
+declared interval, plus the `last_finished_at` timestamp. Read-only
+here; trigger buttons live on the Settings page.
+
+If the api is unreachable, a yellow banner appears at the top and
+the cards/charts are skipped — the UI degrades gracefully.
+
+### 3.4 `/app/ips` — IP search
+
+Paginated table of every IP with a row in `ip_scores`.
+
+**Filter form** (collapsed into a card):
+
+| Field         | Purpose                                                                                 |
+|---------------|------------------------------------------------------------------------------------------|
+| **IP / prefix** | Free text (`q`). Matches via `LIKE 'prefix%'` on `ip_text`; `203.0.113.` lists everything in that /24. Substring match (`%foo%`) works but is slower. |
+| **Category**  | Limits to IPs scored in the picked category (slug).                                     |
+| **Min score** | Floor on the IP's max score across categories. Decimal.                                  |
+| **Max score** | Ceiling on the IP's max score.                                                           |
+| **Country**   | Two-letter code. If the api has any enrichment data, becomes a dropdown of seen codes with counts; otherwise a free-text input. |
+| **ASN**       | Integer ASN. Filters on the enrichment table.                                            |
+| **Status**    | Dropdown — `clean`, `scored`, `manually_blocked`, `allowlisted`. Combines score row presence with the manual-block / allowlist tables. |
+| **Reset / Filter** | Reset clears the form; Filter submits.                                              |
+
+**Results table** — IP (clickable, links to detail), Country flag,
+ASN, top category, max score, last report timestamp, status pill.
+Pagination underneath when total > page size.
+
+**Notes / known limitations**
+
+- IPs that are *only* manually blocked or allowlisted (never reported
+  yet) won't appear in this list — they aren't in `ip_scores`. Use
+  the dedicated `Manual blocks` / `Allowlist` pages.
+- Manual subnets and allowlist subnets aren't expanded for search;
+  only single-IP entries from those tables intersect via the
+  `Status` filter.
+- Country / ASN columns are blank for IPs the enrichment job hasn't
+  processed yet; trigger `enrich-pending` from Settings to backfill.
+
+### 3.5 `/app/ips/{ip}` — IP detail
+
+Reached by clicking an IP from the list, or by direct URL (e.g.
+`/app/ips/203.0.113.42`). Both IPv4 and IPv6 work; Slim's segment
+matcher allows colons via the `{ip:.+}` route.
+
+**Header** — IP in monospace + status pill + back link.
+
+**Action buttons** (operator/admin only):
+
+| Button                      | Effect                                                                                |
+|-----------------------------|---------------------------------------------------------------------------------------|
+| **Add to allowlist** / **Remove from allowlist** | Toggles whether this single IP is on the allowlist. Allowlist always wins over scoring and manual blocks. |
+| **Manually block** / **Remove manual block**     | Toggles a single-IP `manual_blocks` row. The "Manually block" modal accepts an optional reason and an optional `expires_at` (datetime-local). |
+
+**Enrichment card** — country flag + code, ASN (linked to bgp.he.net
+in a new tab), AS organisation, `enriched_at` timestamp. If no row
+exists yet, says "not yet enriched". Footer carries the data-licence
+attribution required by DB-IP and IPinfo (MaxMind requires no
+attribution).
+
+**Override status card** — shows whether the IP is currently
+allowlisted or manually blocked, with the reason text and creation
+time. Empty otherwise.
+
+**Score per category** — one bar per category the IP has a non-zero
+score in. Bar width = score / max-score-on-this-IP. Suffix shows
+`<score>` with `(<n> in 30d)`.
+
+**History** — most recent 200 events (reports + manual-block adds +
+allowlist adds), newest first. Reports show category, reporter,
+weight, and any metadata (pretty-printed JSON). A footer note
+indicates if older events exist beyond the 200-row cap.
+
+### 3.6 `/app/manual-blocks` — Manual blocks
+
+Lists every row in `manual_blocks`. The sidebar entry "Subnets" goes
+to the same page with `?kind=subnet`.
+
+**Kind filter chips** — `All` / `IPs` / `Subnets`. Each filters the
+listing.
+
+**Add manual block** form (operator/admin only):
+
+| Field         | Values                                                                          |
+|---------------|----------------------------------------------------------------------------------|
+| **Kind**      | `Single IP` or `Subnet (CIDR)`. Toggling swaps the visible target field.         |
+| **IP**        | Visible when Kind = Single IP. Plain v4 or v6 address.                          |
+| **CIDR**      | Visible when Kind = Subnet. e.g. `192.0.2.0/24` or `2001:db8::/48`. Non-canonical input is silently normalised; the response echoes a `normalized_from` note when the input changed. |
+| **Reason**    | Free text. Stored verbatim in `manual_blocks.reason` and shown in audit + UI.   |
+| **Expires (optional)** | datetime-local. Empty = never expires. Past expiry, the read-time filter excludes the block from blocklists; the daily `cleanup-expired-manual-blocks` job hard-deletes it (one audit row per prune). |
+
+**Listing table** — Kind, target (IP or CIDR), reason, expiry,
+created-at. Operator/admin sees a **Remove** action that opens the
+confirmation modal.
+
+### 3.7 `/app/allowlist` — Allowlist
+
+Same shape as Manual blocks but green-themed. Allowlist entries
+suppress an IP from any blocklist regardless of score and regardless
+of manual-block presence. The `CidrEvaluator` logs a WARNING when
+allowlist and manual-blocks overlap; allowlist wins.
+
+**Add allowlist entry** form (operator/admin only):
+
+| Field    | Values                                                                              |
+|----------|--------------------------------------------------------------------------------------|
+| **Kind** | `Single IP` or `Subnet (CIDR)`.                                                     |
+| **IP** / **CIDR** | As per Manual blocks.                                                       |
+| **Reason** | Free text. There is no expiry on allowlist entries — remove them manually when no longer needed. |
+
+### 3.8 `/app/policies` — Policies
+
+A policy is a named set of (category, threshold) pairs plus a flag
+controlling whether manual blocks are merged in. The seeded
+`strict` / `moderate` / `paranoid` policies cover the common
+spectrum.
+
+**Listing** — policy name (clickable), description, count of
+configured thresholds, "manual blocks: yes/no". Admin sees Edit and
+Delete buttons. Delete is refused with `409` if any consumer
+references the policy.
+
+**New policy** form (admin only):
+
+| Field                       | Values                                                                          |
+|-----------------------------|----------------------------------------------------------------------------------|
+| **Name**                    | Unique. Freeform, but kebab-ish identifiers work best (used in audit logs).     |
+| **Description**             | Optional, free text.                                                             |
+| **include manual blocks**   | Checkbox; checked by default. When checked, every entry in `manual_blocks` (subject to allowlist) appears in this policy's blocklist. |
+
+Thresholds are configured on the edit page after creation.
+
+### 3.9 `/app/policies/{id}` — Policy editor
+
+**Metadata card** — Name, Description, "include manual blocks". The
+form is read-only for viewers.
+
+**Threshold matrix** — every active category with three columns:
+
+| Column        | Meaning                                                                          |
+|---------------|----------------------------------------------------------------------------------|
+| Category      | `<slug>` + the human name.                                                      |
+| Decay         | The category's decay function and parameter, for context.                       |
+| Threshold     | The minimum score for an IP to land in this policy's blocklist for that category. **Empty value = category not in this policy.** Saving with empty drops the row from `policy_category_thresholds`; saving with a number replaces the existing row. |
+
+The PATCH replaces the entire threshold set in one transaction;
+unchecked rows simply don't appear in the body.
+
+**Preview card** — calls `/app/policies/{id}/preview-proxy` (a UI
+proxy that adds the service token and forwards to
+`/api/v1/admin/policies/{id}/preview`). Renders the resulting count
+of entries plus a sample of the first 50. **Refresh** re-runs the
+query without saving the form. Useful to gauge the impact of a
+threshold change before saving.
+
+### 3.10 `/app/reporters` — Reporters
+
+Reporters are the upstream sources of abuse signal — one row per
+host or service that posts to `POST /api/v1/report`.
+
+**New reporter** form (admin only):
+
+| Field         | Values                                                                                                                    |
+|---------------|----------------------------------------------------------------------------------------------------------------------------|
+| **Name**      | Required. Unique. Used in audit logs and dashboard top-list.                                                              |
+| **Trust weight** | Decimal `0.00`–`2.00`, default `1.0`. Multiplies every report's contribution to score. `0.0` mutes the source; `2.0` doubles its influence. The value is snapshotted into `reports.weight_at_report` at ingest time, so changing trust later doesn't retroactively rewrite history. |
+| **Description** | Optional free text.                                                                                                     |
+
+**Listing** — name (clickable, edit page), trust weight, description,
+status pill (`active` / `inactive`). Admin sees Edit and (for active
+rows) **Deactivate**, which is a soft-delete. Deactivated reporters
+stop accepting new reports (their tokens get `401`); existing
+`reports` rows are preserved for the audit trail.
+
+**Edit page** — same fields plus an `active` checkbox.
+
+### 3.11 `/app/consumers` — Consumers
+
+Consumers are the downstream pull clients — one row per firewall /
+proxy / iptables instance that fetches `GET /api/v1/blocklist`.
+
+**New consumer** form (admin only):
+
+| Field         | Values                                                                          |
+|---------------|----------------------------------------------------------------------------------|
+| **Name**      | Required, unique. Used in audit + audit attribution.                            |
+| **Policy**    | Required. Pick from the existing policies; this is what shapes the consumer's blocklist. |
+| **Description** | Optional.                                                                     |
+
+**Listing** — name, policy, description, status. Admin sees Edit /
+Deactivate; Deactivate is a soft-delete (returns 401 on subsequent
+pulls).
+
+### 3.12 `/app/tokens` — API tokens
+
+Bearer tokens for every machine client and admin automation. Service
+tokens are deliberately not listed (the UI's own service token is
+held only in `.env` and `api_tokens`, never exposed in management
+views).
+
+**Issue token** form (admin only):
+
+| Field         | Values                                                                                                          |
+|---------------|------------------------------------------------------------------------------------------------------------------|
+| **Kind**      | `admin`, `reporter`, or `consumer`. Toggling swaps the visible secondary field.                                 |
+| **Role**      | (Visible when Kind = admin) `viewer`, `operator`, or `admin`. Persisted in `api_tokens.role`. Determines what `/api/v1/admin/*` endpoints the token may call. |
+| **Reporter**  | (Visible when Kind = reporter) Pick the reporter the token belongs to.                                          |
+| **Consumer**  | (Visible when Kind = consumer) Pick the consumer the token belongs to.                                          |
+
+**Token-just-created modal** — appears once after submission. Shows:
+
+- Kind + 8-char prefix (this is what's stored permanently).
+- The full raw token string in a read-only input + **Copy** button.
+- "I have stored it safely" closes the modal. **Refreshing this page
+  or navigating away discards the raw token permanently.** If lost,
+  revoke and re-issue.
+
+**Listing** — Kind, prefix, role-or-target column, last-used
+timestamp, status (`active` / `revoked`). Admin sees a **Revoke**
+button per active row; revocation is immediate and irreversible
+(the row stays for audit; clients start getting `401`).
+
+### 3.13 `/app/categories` — Abuse categories
+
+Each category is a column in the (IP × category) score matrix. The
+five seeded categories cover most use cases; add more for niche
+signal sources.
+
+**New category** form (admin only):
+
+| Field             | Values                                                                                  |
+|-------------------|------------------------------------------------------------------------------------------|
+| **Slug**          | Required, unique. Pattern `^[a-z][a-z0-9_]*$`. Used in API requests, blocklist filters, and audit logs. |
+| **Name**          | Required, human-readable.                                                                |
+| **Decay function**| `exponential` (half-life days) or `linear` (days to zero). See § 4 below.               |
+| **Decay param**   | Decimal ≥ 0.1, default 14 (interpreted as half-life when exponential, days-to-zero when linear). |
+| **Description**   | Optional.                                                                                 |
+
+**Listing** — slug, name, decay function + parameter, status pill.
+Admin sees Edit and Delete. **Delete returns 409** if any policy or
+report references the category — soft-delete by editing the
+category and unchecking `active` instead.
+
+**Edit page** — same fields + an `active` checkbox + a live
+**Decay preview** SVG plot (x-axis: 0–60 days, y-axis: 1.0 → 0.0).
+Toggling the function or changing the parameter redraws the curve
+without saving.
+
+### 3.14 `/app/audit` — Audit log
+
+Append-only record of every successful state-changing call through
+the admin and auth APIs. Visible to all signed-in roles.
+
+**Filter form**:
+
+| Field             | Values                                                                                                |
+|-------------------|--------------------------------------------------------------------------------------------------------|
+| **Actor kind**    | `user`, `admin-token`, `reporter`, `consumer`, `system`. Service-token + impersonation always records as `user` with the impersonated `user_id`. |
+| **Actor id**      | Integer FK into the relevant table for that actor kind.                                              |
+| **Action**        | Dropdown of known action strings (`reporter.created`, `policy.updated`, `manual_block.deleted`, `job.triggered`, etc.). |
+| **Entity type**   | Free text matching `audit_log.target_type` (the API/UI surface them as `entity_type`).               |
+| **Entity id**     | Free text — entity primary key as a string.                                                          |
+| **From / To (ISO)** | ISO-8601 timestamps to bound the time window (`2026-04-01T00:00:00Z`).                            |
+
+**Result table** — When (timestamp), Actor (kind pill + id), Action
+(coloured pill grouped by domain), Entity (type + id), Source IP,
+and a **View** button that expands a JSON pretty-print of the
+event's `details` payload below the row.
+
+Failed (4xx) calls are deliberately not audited — only successful
+state changes leave a trace. Token-creation events store the prefix,
+never the raw token. Pagination at the bottom for total > page size.
+
+### 3.15 `/app/settings` — Settings (admin only)
+
+Three sections:
+
+#### Configuration
+
+The api's effective config, grouped into sections (database, jobs,
+GeoIP, etc.). Secrets are masked: `***` for `INTERNAL_JOB_TOKEN`,
+`MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`,
+`APP_SECRET`. `UI_SERVICE_TOKEN` is shown as the first 8 characters
++ `…`. Empty values are rendered as `(empty)` so misconfiguration is
+visible.
+
+#### Jobs
+
+One row per registered job with its last run status, last
+finished-at, items-processed count, and an amber **overdue** pill
+when the job hasn't completed within its `default_interval_seconds`.
+
+| Job                                | Default interval | Purpose                                                                                  |
+|------------------------------------|------------------|------------------------------------------------------------------------------------------|
+| `recompute-scores`                 | 5 min            | Re-applies decay to every (IP, category) pair touched recently or stale beyond freshness window. |
+| `cleanup-audit`                    | 24 h             | Prunes `audit_log` rows older than `JOB_AUDIT_RETENTION_DAYS` (default 180).             |
+| `enrich-pending`                   | 5 min            | Looks up GeoIP/ASN for IPs without an `ip_enrichment` row.                              |
+| `cleanup-expired-manual-blocks`    | 24 h             | Hard-deletes manual-block rows past their `expires_at`. Emits one audit row per prune.   |
+| `refresh-geoip`                    | 7 days (`JOB_GEOIP_REFRESH_INTERVAL_DAYS`) | Downloads fresh MMDB files from the configured provider; atomic-replaces them on disk. |
+| `tick`                             | external         | The dispatcher invoked by host cron / sidecar; iterates the registry and runs whatever is overdue. |
+
+**Run now** button — admin-only. Posts to
+`POST /app/settings/jobs/trigger/<name>` which proxies to the api's
+admin endpoint. Runs synchronously — wait for the response. Does
+**not** appear next to `tick` (drive that externally). The
+`refresh-geoip` button returns `412` if the configured provider's
+credential is unset (DB-IP needs none; MaxMind needs
+`MAXMIND_LICENSE_KEY`; IPinfo needs `IPINFO_TOKEN`).
+
+#### GeoIP
+
+Read-only summary of the GeoIP configuration:
+
+| Field                | Source                                                                              |
+|----------------------|--------------------------------------------------------------------------------------|
+| Provider             | `GEOIP_PROVIDER` (`dbip` / `maxmind` / `ipinfo`).                                    |
+| Country DB / ASN DB  | On-disk paths (`/data/geoip/country.mmdb` and `/data/geoip/asn.mmdb`).              |
+| MaxMind key          | Status only: shows `(unset)` when empty.                                            |
+| IPinfo token         | Status only.                                                                        |
+
+To rotate provider or credentials, edit `.env` and restart the api
+container.
+
+### 3.16 `/app/me` — My identity
+
+Shows what `GET /api/v1/admin/me` returns for the current request:
+User ID, Display name, Email, Role pill, Source (`oidc` / `local` /
+`admin-token`). A **Sign out** button at the bottom posts to
+`/logout` (CSRF-protected). The page also surfaces an amber banner
+if the api was unreachable at render time.
+
+---
+
+## 4. The decay engine — how scores age out
+
+Every report contributes to a single `(IP, category)` score. The
+score is recomputed as the sum of every report's `weight_at_report`
+multiplied by a category-specific decay function of the report's
+age:
+
+```
+score(IP, category) = Σ over reports r:
+                       r.weight_at_report × decay(now − r.received_at, category)
+```
+
+Reports older than `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365) are
+excluded outright — the cutoff bounds memory and query time as the
+table grows.
+
+### 4.1 Two decay shapes
+
+Each category picks one of two functions, configured on the
+`Categories` page:
+
+| Function                | Formula                                            | `decay_param` interpretation             | When to pick                                                                                       |
+|-------------------------|----------------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------|
+| **Linear**              | `max(0, 1 − age_days / decay_param)`               | Days until contribution reaches zero.    | Hard cutoff: a report stops counting after exactly N days. Used when "contribution = 1 if recent, 0 if old" is the right model. |
+| **Exponential**         | `0.5 ^ (age_days / decay_param)` (half-life)       | Days for contribution to halve.          | Smooth decay: a report's contribution halves every N days, never reaches zero (until the hard cutoff). The default for all five seeded categories. |
+
+A few worked numbers, with `decay_param = 14`:
+
+| Age (days) | Linear | Exponential |
+|-----------:|-------:|------------:|
+| 0          | 1.000  | 1.000       |
+| 7          | 0.500  | 0.707       |
+| 14         | 0.000  | 0.500       |
+| 28         | 0.000  | 0.250       |
+| 90         | 0.000  | 0.012       |
+
+The `Categories` edit page renders the same curve live as you
+change the parameter.
+
+### 4.2 What you can tune
+
+| Knob                                        | Where                                                                       | Effect                                                                                                  |
+|---------------------------------------------|------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
+| **Per-category `decay_function`**           | `Categories → edit`                                                          | Switches between linear and exponential. Curve shape changes immediately on save; existing scores recompute on next tick. |
+| **Per-category `decay_param`**              | `Categories → edit`                                                          | Faster (smaller param) decay = scores fall off quickly = blocklist drops IPs sooner. Slower (larger) = sticky scores. |
+| **Per-reporter `trust_weight`**             | `Reporters → edit`                                                           | Multiplies the contribution of every new report from this source. Snapshotted into `reports.weight_at_report` at ingest, so changes don't rewrite history. Range `0.0`–`2.0`. |
+| **Hard cutoff `SCORE_REPORT_HARD_CUTOFF_DAYS`** | `.env` (`api`)                                                          | Floor on memory: reports older than this are excluded entirely. Default 365.                            |
+| **Recompute interval `SCORE_RECOMPUTE_INTERVAL_SECONDS`** | `.env` (`api`)                                                  | How often the bulk recompute job runs; lower = scores reflect decay sooner; higher = less load. Default 300 s. |
+| **Recompute batch size `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`** | `.env` (`api`)                                                 | Cap on rows refreshed per tick; tune if recompute starts overrunning its window. Default 5000.          |
+| **Recompute timeout `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`** | `.env` (`api`)                                                  | Hard ceiling. The lock auto-expires after this; concurrent calls return `skipped_locked`. Default 240 s. |
+| **Per-policy thresholds**                   | `Policies → edit`                                                            | The score floor for inclusion in a policy's blocklist. Higher = stricter inclusion = smaller list. Lower = looser. |
+
+### 4.3 How recompute actually works
+
+The job runs every `SCORE_RECOMPUTE_INTERVAL_SECONDS`:
+
+1. Acquires the `recompute-scores` lock with a max runtime of
+   `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`. Concurrent calls get `409
+   skipped_locked` immediately.
+2. Selects every `(ip, category)` pair touched by reports in the
+   recent interval, **plus** every row whose `recomputed_at` is
+   stale (older than the freshness window) — capped at
+   `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`.
+3. Recomputes the score by summing decayed contributions over all
+   in-cutoff reports for that pair.
+4. Upserts into `ip_scores`.
+5. Drops rows where `score < 0.01 AND last_report_at < now − 90
+   days` so the table doesn't grow unbounded.
+6. Writes a `job_runs` row and releases the lock.
+
+Single-pair updates also happen **synchronously** at report time
+(via `PairScorer`), so the dashboard and detail page reflect a new
+report immediately. The bulk recompute is what *ages* every score
+between reports.
+
+A full-table recompute is exposed as an admin-triggered job
+(`Settings → recompute-scores → Run now` with `full=true` body, or
+the CLI `php api/bin/console scores:rebuild`). Use after large
+policy / decay changes that should reflect everywhere immediately.
+
+### 4.4 Manual override semantics
+
+`manual_blocks` and `allowlist` are **not** folded into scores. They
+are evaluated at distribution time:
+
+- **Allowlist always wins.** An IP on the allowlist is excluded from
+  every blocklist regardless of score and regardless of any manual
+  block. Overlap is logged as a WARNING.
+- **Manual blocks** appear in a policy's blocklist if and only if
+  the policy's `include_manual_blocks` flag is set. Subnet manual
+  blocks emit as a single CIDR line; covered single-IP scored entries
+  are deduplicated.
+
+---
+
+## 5. Backup and disaster recovery
+
+### 5.1 What carries irreplaceable state
+
+| Resource                          | Where                       | Recovery                                  |
+|-----------------------------------|-----------------------------|-------------------------------------------|
+| Reports + scores                  | `reports`, `ip_scores`      | DB backup                                 |
+| Manual blocks + allowlist         | `manual_blocks`, `allowlist`| DB backup                                 |
+| Policies + thresholds             | `policies`, `policy_category_thresholds` | DB backup                       |
+| Categories                        | `categories`                | DB backup                                 |
+| Reporters / consumers / users     | `reporters`, `consumers`, `users` | DB backup                          |
+| API tokens                        | `api_tokens` (hashes only)  | DB backup; **raw values gone — re-issue** |
+| Audit log                         | `audit_log`                 | DB backup                                 |
+| OIDC role mappings                | `oidc_role_mappings`        | DB backup                                 |
+| Service token                     | `.env` (`UI_SERVICE_TOKEN`) | Back up `.env` or regenerate              |
+| Internal job token                | `.env` (`INTERNAL_JOB_TOKEN`) | Back up `.env` or regenerate            |
+| OIDC client secret                | `.env`                      | Re-fetch from Entra app registration      |
+| Local admin password hash         | `.env`                      | Regenerate via `password_hash()`          |
+| MaxMind / IPinfo credentials      | `.env`                      | Re-fetch from provider                    |
+| GeoIP MMDBs                       | `irdb-data` volume          | `refresh-geoip` job re-downloads          |
+| Browser sessions                  | UI container's writable layer | Discarded on restart — users re-login   |
+
+### 5.2 SQLite backup (default)
+
+The SQLite backup API quiesces WAL and produces a consistent
+snapshot online — no downtime:
+
+```bash
+docker compose exec api sh -c \
+  'sqlite3 /data/irdb.sqlite ".backup /data/irdb-backup.sqlite"'
+docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite
+```
+
+Schedule this from host cron alongside your existing backup
+infrastructure. The file is small — under 20 tables; even a
+year-old install is typically tens of MB.
+
+**Whole-volume tarball** (alternative, requires the api stopped):
+
+```bash
+docker compose stop api
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
+  tar czf /backup/irdb-backup.tar.gz -C /data .
+docker compose start api
+```
+
+### 5.3 SQLite restore
+
+```bash
+docker compose down
+docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
+  sh -c 'rm -rf /data/* && tar xzf /backup/irdb-backup.tar.gz -C /data'
+docker compose up -d
+```
+
+The `migrate` container reruns Phinx idempotently — safe to run on a
+restored DB that's already at HEAD; it exits immediately with "no
+migrations to run" if nothing's pending.
+
+### 5.4 MySQL backup and restore
+
+```bash
+docker compose exec mysql sh -c \
+  'mysqldump --single-transaction --routines --quick \
+    -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
+  > irdb-mysql-$(date +%F).sql
+```
+
+Restore (api must be stopped to avoid observing a half-loaded
+schema):
+
+```bash
+docker compose stop api migrate
+docker compose exec -T mysql sh -c \
+  'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
+  < irdb-mysql-2026-04-29.sql
+docker compose up -d migrate api
+```
+
+### 5.5 Disaster-recovery checklist
+
+Use this when restoring on a fresh host, e.g. after a hardware
+failure or migration:
+
+1. **Provision** the host: install Docker, clone the repo, restore
+   `.env` from your secret store (or generate fresh — the service
+   token can be rotated, the local-admin hash regenerated).
+2. **Restore the database** via the relevant block above. SQLite:
+   drop the file into the `irdb-data` volume. MySQL: pipe the dump
+   through `mysql`.
+3. **Bring up the stack**: `docker compose up -d`. The `migrate`
+   container is idempotent.
+4. **Verify health.** `/healthz` on both containers; log in to the
+   UI; visit `/app/dashboard` and `/app/settings`.
+5. **Trigger `refresh-geoip`** from Settings (or wait for the
+   scheduled run) so enrichment is repopulated.
+6. **Re-issue tokens** for any reporter or consumer whose raw token
+   was lost (the database has the SHA-256 hash, but the original
+   string isn't recoverable). Until re-issued, those clients will
+   see `401`.
+7. **Notify users** that browser sessions are gone — they must
+   sign in again.
+
+### 5.6 What's explicitly NOT supported
+
+- **Encryption at rest of the SQLite file** — host-level disk
+  encryption is the right layer; no application-level encryption.
+- **Audit-log signing or tamper-evidence** — `audit_log` is
+  append-only at the application layer, but a sufficiently
+  privileged attacker with database access can rewrite history.
+- **Cross-region replication** — Docker volumes are local. High
+  availability requires managed MySQL with replication plus an
+  `api` topology that exploits the stateless mode.
+- **Backup of secrets** (service token, OIDC client secret, etc.)
+  is out of scope for the database backup. Back up `.env`
+  separately or regenerate on restore.
+
+### 5.7 Operational sanity checks
+
+Things that often go wrong, and where to look:
+
+| Symptom                                                   | Likely cause                                                                                  |
+|-----------------------------------------------------------|----------------------------------------------------------------------------------------------|
+| Scores never decay; `recompute-scores` shows "never run" | Scheduler isn't running. Check `compose.scheduler.yml` is included or host cron is firing.   |
+| Dashboard "API unreachable" banner                        | The `api` container is down or the UI can't resolve `API_BASE_URL`.                          |
+| All reporter ingest returns `401`                         | Token revoked, expired, or wrong kind. Check `Tokens` and `Audit`.                           |
+| `refresh-geoip` returns 412                               | Selected provider's credential is unset. Switch provider or set the key.                     |
+| Local-admin login is locked out                           | Five failed attempts in succession. Restart the `ui` container to clear the throttle.        |
+| `migrate` keeps restarting                                | Database connection failed. Check `DB_DRIVER`, `DB_MYSQL_*`, and the volume mount.           |
+| Disk filling up                                           | `audit_log` growing without bounds. Check `cleanup-audit` is running and `JOB_AUDIT_RETENTION_DAYS` is set. |
+
+---
+
+## 6. Where to go next
+
+- [`README.md`](../README.md) — quickstart, generating secrets, scheduling.
+- [`architecture.md`](./architecture.md) — container topology and stable surfaces.
+- [`auth-flows.md`](./auth-flows.md) — Entra setup, every authentication path.
+- [`api-overview.md`](./api-overview.md) — public API surface with worked examples.
+- [`security.md`](./security.md) — security posture, hardening notes, known limitations.
+- `/api/docs` (live, served by the api container) — interactive OpenAPI viewer.