# 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 — `<entity>.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=<token_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.
