Fresh Claude Code agent prompt. M10 and M11 must be complete and committed. Estimated effort: medium.
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.
Verify M10 and M11:
git log --oneline -11
cd api && composer test && cd ..
cd ui && composer test && cd ..
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).
Confirm clean tree.
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:
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.
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.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.
GET /api/v1/admin/audit-log (Viewer role):
actor_kind, actor_id, action, entity_type, entity_id, from (ISO datetime), to, page, page_size (default 50, max 200).{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:
POST /api/v1/admin/jobs/trigger/{name} (Admin role):
/internal/jobs/{name} (e.g. {full: true} for recompute-scores).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.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.
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):
*** for: INTERNAL_JOB_TOKEN, MAXMIND_LICENSE_KEY, DB_MYSQL_PASSWORD.... for: UI_SERVICE_TOKEN.DB_DRIVER, LOG_LEVEL, SCORE_RECOMPUTE_INTERVAL_SECONDS, JOB_AUDIT_RETENTION_DAYS, GEOIP_* paths (not the license key), API_RATE_LIMIT_PER_SECOND, etc.UI page pages/settings/index.twig (Admin role only — Operator and Viewer get a "no access" page):
/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)./api/v1/admin/jobs/trigger/{name} → flash success/failure with the run details.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.GET/POST/PATCH/DELETE /api/v1/admin/users (admin only; cannot delete the local admin)GET/POST/DELETE /api/v1/admin/oidc-role-mappingsIf those endpoints aren't yet implemented, add them this milestone.
AuditContext. Inject the emitter where needed; controllers don't have to manually pass the context.CleanupAuditJob already deletes old entries based on JOB_AUDIT_RETENTION_DAYS. Verify it works with real data now that data exists.(occurred_at DESC), (action), (entity_type, entity_id), (actor_kind, actor_id) for the filter performance. Add a migration if needed.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).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
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
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.
Stop. Do not start M13.