Description
Harden the dotCMS-side analytics proxy and credential flow so the system can talk to the dot-ca-event-manager microservice with per-tenant HMAC bearer tokens instead of plain Basic auth, and so backend authorization is consistent with the rest of dotCMS.
Parent epic: #35048 — Phase 3 (Security) work, with some Phase 4 (Reliability) overlap (proxy/health timeouts, structured failure modes).
What changes
Auth model
- Replace Basic auth on the proxy with bearer tokens; remove
DOT_ANALYTICS_PASSWORD entirely.
- New
ContentAnalyticsAppListener: when an admin saves the Content Analytics App with adminPassword populated, the listener exchanges the password for a bearer token via POST {DOT_ANALYTICS_BASE_URL}/v1/admin/token, persists the returned token as a hidden secret on the app config, and atomically clears the user-entered password. The password is never persisted.
- App YAML grows new fields (
adminPassword, bearerToken) with hints explaining the save-flow exchange.
Proxy hardening
- Path allowlist: only
event/* may be proxied. Traversal segments, encoded ?/#, and non-event prefixes are rejected with 400.
- Hard timeouts: 10s proxy / 2s health probe — prevents a slow upstream from exhausting the Jersey thread pool.
- Blank-body /
null JSON guard on the ingest POST.
project= query param deduped: caller-supplied wins over DOT_ANALYTICS_PROJECT.
- RFC-6750-compliant: when no token is available, the
Authorization header is omitted entirely instead of emitting a malformed Bearer (no token).
- Empty-body POSTs drop
Content-Type (per RFC 9110 §8.3).
Per-site permission gating on the GET catch-all
- Site is resolved as the logged-in user via
HostAPI.find(siteId, user, …) and HostWebAPI.getCurrentHost(request, user), so PermissionAPI gates access.
- A backend user without READ on the requested site gets
403 SITE_ACCESS_DENIED even when crafting ?siteId= by hand to bypass the (already-permissioned) site picker.
SecurityLogger.logInfo is called on the 403 path to mirror what the global DotForbiddenExceptionMapper would have logged — keeps denied-site accesses in the audit trail.
Health monitor
- Drops
DOT_ANALYTICS_PASSWORD from the configuration check.
- Null-safe site probe (monitor pings landing on
System Host no longer NPE).
- Tighter
/v1/health timeout so monitor probes fail fast.
Tests
ContentAnalyticsAppListenerTest: failure paths (missing tenant / missing base URL clear the password and notify the user) plus a direct happy-path test of persistTokenAndClearCredentials that asserts the atomic invariant — adminPassword absent, bearerToken replaced with the new value, unrelated secrets (siteAuth, etc.) preserved verbatim.
EventAnalyticsProxyHelperTest: path allowlist (accept/reject matrix including traversal, smuggled ?/#, non-event prefixes), buildUpstreamUrl (trailing-slash strip, project dedup, query forwarding), and buildAuthHeader covering the four security-critical branches (per-site wins, env fallback with trim, null host, both absent → empty Optional).
Out of scope on this PR
These are intentionally deferred and tracked in .scratch/reviews/feat-analytics-hmac-auth.md:
EventAnalyticsProxyResourceTest — resource-level test for the auth-decision branches (admin bypass, READ-permitted 200, READ-denied 403, session fallback). Auth model was in flux during the branch; folded into a follow-up once it settled.
- Promoting
DOT_ANALYTICS_TENANT to a per-site App field — multi-tenant-per-JVM is a real product question, not a refactor.
- Standardizing on
siteId vs host_id query param across analytics endpoints — cross-cutting product decision.
wrapUpstreamResponse branch test coverage — correctness branches, not security; lower priority than buildAuthHeader.
Acceptance Criteria
Priority
Medium
Additional Context
- Branch:
feat/analytics-hmac-auth (~16 commits, ~1016 LOC across 10 files)
Description
Harden the dotCMS-side analytics proxy and credential flow so the system can talk to the dot-ca-event-manager microservice with per-tenant HMAC bearer tokens instead of plain Basic auth, and so backend authorization is consistent with the rest of dotCMS.
Parent epic: #35048 — Phase 3 (Security) work, with some Phase 4 (Reliability) overlap (proxy/health timeouts, structured failure modes).
What changes
Auth model
DOT_ANALYTICS_PASSWORDentirely.ContentAnalyticsAppListener: when an admin saves the Content Analytics App withadminPasswordpopulated, the listener exchanges the password for a bearer token viaPOST {DOT_ANALYTICS_BASE_URL}/v1/admin/token, persists the returned token as a hidden secret on the app config, and atomically clears the user-entered password. The password is never persisted.adminPassword,bearerToken) with hints explaining the save-flow exchange.Proxy hardening
event/*may be proxied. Traversal segments, encoded?/#, and non-eventprefixes are rejected with 400.nullJSON guard on the ingest POST.project=query param deduped: caller-supplied wins overDOT_ANALYTICS_PROJECT.Authorizationheader is omitted entirely instead of emitting a malformedBearer(no token).Content-Type(per RFC 9110 §8.3).Per-site permission gating on the GET catch-all
HostAPI.find(siteId, user, …)andHostWebAPI.getCurrentHost(request, user), soPermissionAPIgates access.403 SITE_ACCESS_DENIEDeven when crafting?siteId=by hand to bypass the (already-permissioned) site picker.SecurityLogger.logInfois called on the 403 path to mirror what the globalDotForbiddenExceptionMapperwould have logged — keeps denied-site accesses in the audit trail.Health monitor
DOT_ANALYTICS_PASSWORDfrom the configuration check.System Hostno longer NPE)./v1/healthtimeout so monitor probes fail fast.Tests
ContentAnalyticsAppListenerTest: failure paths (missing tenant / missing base URL clear the password and notify the user) plus a direct happy-path test ofpersistTokenAndClearCredentialsthat asserts the atomic invariant —adminPasswordabsent,bearerTokenreplaced with the new value, unrelated secrets (siteAuth, etc.) preserved verbatim.EventAnalyticsProxyHelperTest: path allowlist (accept/reject matrix including traversal, smuggled?/#, non-eventprefixes),buildUpstreamUrl(trailing-slash strip, project dedup, query forwarding), andbuildAuthHeadercovering the four security-critical branches (per-site wins, env fallback with trim, null host, both absent → empty Optional).Out of scope on this PR
These are intentionally deferred and tracked in
.scratch/reviews/feat-analytics-hmac-auth.md:EventAnalyticsProxyResourceTest— resource-level test for the auth-decision branches (admin bypass, READ-permitted 200, READ-denied 403, session fallback). Auth model was in flux during the branch; folded into a follow-up once it settled.DOT_ANALYTICS_TENANTto a per-site App field — multi-tenant-per-JVM is a real product question, not a refactor.siteIdvshost_idquery param across analytics endpoints — cross-cutting product decision.wrapUpstreamResponsebranch test coverage — correctness branches, not security; lower priority thanbuildAuthHeader.Acceptance Criteria
DOT_ANALYTICS_PASSWORDno longer read anywherePermissionAPI; non-permitted requests return 403SITE_ACCESS_DENIEDSecurityLoggerfor audit-trail parity with the global exception mapperDOT_ANALYTICS_PASSWORD; null-safe site probebuildAuthHeadersecurity branchesopenapi.yamlreflects the new endpoint shapes (rebuild on merge via swagger-maven-plugin)Priority
Medium
Additional Context
feat/analytics-hmac-auth(~16 commits, ~1016 LOC across 10 files)