M12-audit-and-settings.md 12 KB

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:

    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::createreporter.created, payload contains the reporter fields (no secrets — never the trust weight is fine; never write credentials/tokens).
  • TokensController::createtoken.created, payload contains kind, prefix, target ids; never the raw token.
  • TokensController::deletetoken.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

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:

    ## 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.