# M12 — Audit Log & Settings Page > Fresh Claude Code agent prompt. M10 and M11 must be complete and committed. > Estimated effort: medium. ## Mission Wire the audit emitter into every write path on the api. Add a filterable Audit page on the UI. Build the Settings page (effective config with secrets masked, per-job status with overdue badges, manual job triggers for admins). Optionally, complete the user management UI if it was deferred from M10. ## Before you start 1. Verify M10 and M11: ```bash git log --oneline -11 cd api && composer test && cd .. cd ui && composer test && cd .. ``` 2. Read `SPEC.md` §4 (`audit_log` table), §6 (`POST /api/v1/admin/jobs/trigger/{name}`, `GET /api/v1/admin/audit-log`, `GET /api/v1/admin/jobs/status`, `GET /api/v1/admin/config`), §7 (Audit page, Settings page). 3. Confirm clean tree. ## Tasks ### 1. Audit emitter In `api/src/Domain/Audit/`: - `AuditEmitter.php` interface: `emit(string $action, string $entityType, ?int $entityId, array $payload, AuditContext $ctx): void`. - `AuditContext.php` — captured from the current request: `actorKind: "user"|"reporter"|"consumer"|"admin-token"|"system"`, `actorId: ?int`, `actorName: ?string`, `requestId`, `ip` (the source IP making the call). - `AuditAction.php` — enum or string constants: `reporter.created`, `reporter.updated`, `reporter.deleted`, `consumer.*`, `token.created`, `token.revoked`, `policy.*`, `category.*`, `manual_block.*`, `allowlist.*`, `user.role_changed`, `oidc_role_mapping.*`, `job.triggered`. In `api/src/Infrastructure/Audit/AuditRepository.php`: - Insert with all fields, including a stable JSON-encoded payload. Wire `AuditEmitter` into the DI container; resolve `AuditContext` from the current request's principal in a request-scoped middleware. **Service-token + impersonation calls record `actorKind="user"` and `actorId=user_id` — NOT the service token.** ### 2. Wire audit calls Every state-changing admin endpoint emits exactly one audit entry on success. Patterns: - `ReportersController::create` → `reporter.created`, payload contains the reporter fields (no secrets — never the trust weight is fine; never write credentials/tokens). - `TokensController::create` → `token.created`, payload contains `kind`, `prefix`, `target` ids; **never** the raw token. - `TokensController::delete` → `token.revoked`. - All `manual_blocks`, `allowlist`, `policies`, `categories`, `consumers`, `users`, `oidc_role_mappings` endpoints — `.created/updated/deleted`. - `JobsController::trigger` (new this milestone, see below) → `job.triggered`. Failure paths (4xx/5xx) **do not** emit. Only successful state changes. ### 3. Audit list endpoint and UI `GET /api/v1/admin/audit-log` (Viewer role): - Query params: `actor_kind`, `actor_id`, `action`, `entity_type`, `entity_id`, `from` (ISO datetime), `to`, `page`, `page_size` (default 50, max 200). - Returns `{items, page, page_size, total}`. Each item: id, occurred_at, actor (kind+name+id), action, entity_type, entity_id, payload (raw JSON), source_ip. UI page `pages/audit/index.twig`: - Sidebar link "Audit". - Filter bar at top (form with all the params). - Table: time (relative + absolute on hover), actor, action (color-coded by category), entity, "view payload" button → modal showing the payload JSON pretty-printed. - Pagination at bottom. - All roles can see audit (Viewer+). ### 4. Manual job trigger endpoint `POST /api/v1/admin/jobs/trigger/{name}` (Admin role): - Accepts the same body shape as `/internal/jobs/{name}` (e.g. `{full: true}` for recompute-scores). - Server-side: emits `job.triggered` audit, then calls the corresponding internal handler (NOT via HTTP — call the same Job class directly). Pass `triggered_by="manual"` in the runner's context. - Returns the job's response envelope (same as the internal endpoint). - Errors: 404 if `name` isn't registered; 412 for refresh-geoip without license key. Do **not** expose `/internal/jobs/*` to the UI. The UI uses this admin endpoint as a thin wrapper. The internal endpoint remains scheduler-only. ### 5. Jobs status endpoint and Settings page `GET /api/v1/admin/jobs/status` (Viewer role): proxies `/internal/jobs/status`'s data — but reachable without the internal token. Returns the same shape: latest run per job, overdue flag, lock state. `GET /api/v1/admin/config` (Admin role only): - Returns the effective config the api is using, **with secrets masked**: - `***` for: `INTERNAL_JOB_TOKEN`, `MAXMIND_LICENSE_KEY`, `DB_MYSQL_PASSWORD`. - First 8 chars + `...` for: `UI_SERVICE_TOKEN`. - Plain values for: `DB_DRIVER`, `LOG_LEVEL`, `SCORE_RECOMPUTE_INTERVAL_SECONDS`, `JOB_AUDIT_RETENTION_DAYS`, `GEOIP_*` paths (not the license key), `API_RATE_LIMIT_PER_SECOND`, etc. - Returns config grouped by section. UI page `pages/settings/index.twig` (Admin role only — Operator and Viewer get a "no access" page): - Three sections: Configuration (read-only display from `/admin/config`), Jobs (status table from `/admin/jobs/status` with overdue red badges and manual-trigger buttons), GeoIP (DB presence, last refresh times — pull from healthz or extend `/admin/config`). - Manual-trigger buttons: confirm modal → POST to `/api/v1/admin/jobs/trigger/{name}` → flash success/failure with the run details. ### 6. (Optional) User management UI If deferred from M10, build it now: - `pages/users/index.twig` — list users; columns: email, display_name, role, source (oidc/local), last_login_at. Edit role inline (admin only); cannot edit local admin's role (always admin). - `pages/oidc-mappings/index.twig` — list `oidc_role_mappings`. Create form: group object id (paste from Entra), role, optional description. Delete with confirmation. - API endpoints already exist (M03 declared the schema; this milestone wires the handlers if not already done): - `GET/POST/PATCH/DELETE /api/v1/admin/users` (admin only; cannot delete the local admin) - `GET/POST/DELETE /api/v1/admin/oidc-role-mappings` If those endpoints aren't yet implemented, add them this milestone. ## Implementation notes - **Audit emitter must not block writes**: wrap the emit in a try/catch. If audit insertion fails, log loudly but don't fail the originating request. The audit row is observability, not a transactional invariant. - **Audit context resolution**: a small middleware that captures the principal + request_id + source ip into a request-scoped `AuditContext`. Inject the emitter where needed; controllers don't have to manually pass the context. - **Audit retention**: M05's `CleanupAuditJob` already deletes old entries based on `JOB_AUDIT_RETENTION_DAYS`. Verify it works with real data now that data exists. - **Audit indexes**: ensure indexes on `(occurred_at DESC)`, `(action)`, `(entity_type, entity_id)`, `(actor_kind, actor_id)` for the filter performance. Add a migration if needed. - **Manual trigger UX**: triggers can take seconds. Use htmx with a loading indicator, OR submit the form and redirect to a flash result. Either way: don't double-submit; disable the button immediately on click. - **Service token in audit**: when the api is called by the UI BFF, the service token is irrelevant for audit. Always record the impersonated user. When called by an admin token directly, record `actor_kind="admin-token"`, `actor_id=`. - **`job.triggered` payload**: include `name`, `params`, `triggered_by="manual"`. Don't include the raw response (which can be large). ## Out of scope (DO NOT) - Audit log to external SIEM. The DB row is the audit trail this milestone. - Audit rate limiting / sampling. All writes audit; volume is low. - Live-tail audit on the UI. Pagination is fine. - Triggering reporter or consumer endpoints from the admin UI. - Per-user audit dashboards. The filterable list is sufficient. ## Acceptance ```bash cd api && composer cs && composer stan && composer test && cd .. cd ui && composer cs && composer stan && composer test && cd .. docker compose down -v cp .env.example .env docker compose up -d sleep 20 ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet) # Create a manual block to generate an audit entry curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"kind":"ip","ip":"203.0.113.99","reason":"audit test"}' \ http://localhost:8081/api/v1/admin/manual-blocks > /dev/null # Audit endpoint contains the entry; actor_kind=admin-token because we used a raw admin token curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ "http://localhost:8081/api/v1/admin/audit-log?action=manual_block.created" | grep -q '"actor_kind":"admin-token"' # Now via the UI (service token + impersonation): audit entry attributed to user COOKIE_JAR=$(mktemp) CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4) curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&username=admin&password=test1234" \ http://localhost:8080/login/local -L > /dev/null CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/manual-blocks | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1) curl -s -b $COOKIE_JAR -X POST \ -d "csrf_token=$CSRF&kind=ip&ip=203.0.113.100&reason=via-ui" \ http://localhost:8080/app/manual-blocks -L > /dev/null curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ "http://localhost:8081/api/v1/admin/audit-log?entity_type=manual_block&page_size=10" \ | grep -A2 "via-ui" | grep -q '"actor_kind":"user"' # Manual job trigger (admin endpoint, not internal) RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores) echo "$RESP" | grep -q '"status":"success"' # Audit entry recorded curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ "http://localhost:8081/api/v1/admin/audit-log?action=job.triggered" | grep -q "recompute-scores" # Operator role cannot trigger jobs OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet) test "$(curl -s -o /dev/null -w '%{http_code}' \ -X POST -H "Authorization: Bearer $OP_TOKEN" \ http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)" = "403" # /admin/config has secrets masked RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/config) echo "$RESP" | grep -q '"INTERNAL_JOB_TOKEN":"\*\*\*"' echo "$RESP" | grep -q '"DB_DRIVER":"sqlite"' # UI Settings page renders for admin curl -sf -b $COOKIE_JAR http://localhost:8080/app/settings | grep -qi "configuration" # Operator gets no-access page # (manual: log out and back in as an operator-mapped user, verify /app/settings shows no-access) # Audit list page filters curl -sf -b $COOKIE_JAR "http://localhost:8080/app/audit?action=manual_block.created" > /dev/null docker compose down -v ``` ## Handoff 1. Commit: ``` feat(M12): audit log emitter, filterable audit UI, settings page - AuditEmitter wired into every write path - service-token+impersonation audits attribute to user, not service token - GET /api/v1/admin/audit-log with filters, pagination - POST /api/v1/admin/jobs/trigger/{name} as admin wrapper around internal jobs - GET /api/v1/admin/config (secrets masked) and jobs/status - UI Audit and Settings pages - [if applicable] user management UI + oidc role mappings UI ``` 2. Append to `PROGRESS.md`: ```markdown ## M12 — Audit & settings (done) **Built:** audit emission, audit UI, manual job trigger admin endpoint, settings page, [optional: user mgmt UI]. **Notes for next milestone:** - Audit failures are logged but never fail the originating request. - /api/v1/admin/jobs/trigger/{name} is the only path the UI uses to invoke jobs; /internal/jobs/* remains scheduler-only. - Secrets-masked /admin/config is the source of truth for the settings page; M13 documentation references this endpoint. **Deviations from SPEC:** none. **Added dependencies:** none. ``` 3. **Stop.** Do not start M13.