You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/08/11 19:39:03 UTC
(unomi) 08/08: UNOMI-972: document the client-facing changes and the migration impact
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch UNOMI-972-credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 532d855d567db184a79434d4cda5e7aa2ce95548
Author: Serge Huber <sh...@apache.org>
AuthorDate: Mon Aug 10 09:27:53 2026 +0200
UNOMI-972: document the client-facing changes and the migration impact
The hardening changes behaviour that existing clients and operators depend on, so the
documentation has to change with it or it teaches something the server now refuses.
- a client-facing hardening table in the 3.0-to-3.1 migration guide, cross-referenced from
the pages that describe the affected behaviour
- public context examples switched from a body profileId to the context-profile-id cookie
in request-examples and multitenancy; the remaining profileId occurrences elsewhere are
response bodies or already cookie-based
- credentials in examples no longer show karaf:karaf, and the quickstart, getting-started,
configuration and Docker pages require the operator to choose passwords deliberately
- the threat model records what the Groovy action endpoint is (equivalent to shell access,
system administrator only, no sandbox planned and why), so a report of "Groovy is not
sandboxed" triages consistently rather than being re-litigated
Co-Authored-By: Claude Opus 5 (1M context) <no...@anthropic.com>
---
SECURITY.md | 22 ++-
THREAT_MODEL.md | 154 ++++++++++++---------
manual/src/main/asciidoc/5-min-quickstart.adoc | 28 ++--
manual/src/main/asciidoc/builtin-event-types.adoc | 8 +-
manual/src/main/asciidoc/configuration.adoc | 62 +++++----
.../asciidoc/connectors/salesforce-connector.adoc | 11 +-
manual/src/main/asciidoc/getting-started.adoc | 25 +++-
manual/src/main/asciidoc/graphql-examples.adoc | 9 +-
.../main/asciidoc/how-profile-tracking-works.adoc | 68 +++++----
.../main/asciidoc/javascript-tracker-guide.adoc | 88 ++++++------
.../main/asciidoc/jsonSchema/json-schema-api.adoc | 2 +-
.../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 112 ++++++++++++---
.../src/main/asciidoc/migrations/migrations.adoc | 2 +-
.../asciidoc/migrations/v2-compatibility-mode.adoc | 6 +-
.../asciidoc/migrations/v2-v3-compatibility.adoc | 24 ++--
manual/src/main/asciidoc/multitenancy.adoc | 36 +++--
manual/src/main/asciidoc/privacy.adoc | 2 +-
manual/src/main/asciidoc/recipes.adoc | 17 +--
manual/src/main/asciidoc/request-examples.adoc | 20 ++-
manual/src/main/asciidoc/scheduler.adoc | 16 +--
manual/src/main/asciidoc/security.adoc | 4 +-
manual/src/main/asciidoc/shell-commands.adoc | 2 +-
manual/src/main/asciidoc/tutorial.adoc | 4 +-
manual/src/main/asciidoc/whats-new.adoc | 10 +-
.../src/main/webapp/WEB-INF/web.xml | 24 ----
.../login-integration/src/main/webapp/index.html | 70 ----------
.../src/main/webapp/javascript/login-example.js | 139 -------------------
27 files changed, 475 insertions(+), 490 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index 4a7e188b7..1fb715e55 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -19,8 +19,26 @@ limitations under the License.
## Reporting a Vulnerability
`apache/unomi` follows the [Apache Software Foundation security process](https://www.apache.org/security/). Please report suspected
-vulnerabilities privately to `security@apache.org` (the ASF security team routes Unomi reports to the project's private list, `private@unomi.apache.org`); do not open public
-GitHub issues or pull requests for security reports.
+vulnerabilities privately to `security@apache.org` (the ASF Security Team routes Unomi reports to the project's private PMC list,
+`private@unomi.apache.org`); do not open public GitHub issues or pull requests for security reports.
+
+## How the PMC handles reports
+
+Unomi follows the default ASF process in
+[ASF Project Security for Committers — Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling).
+Unomi does not currently maintain a dedicated `security@unomi.apache.org` list, so further private mail about an undisclosed issue
+should be copied to `security@apache.org` as that guide requires.
+
+Summary of the steps the PMC applies:
+
+1. **Work in private** — no public Jira/GitHub issues; commit messages must not call out the security nature of the fix until announcement.
+2. **Acknowledge** — email the reporter (cc `security@apache.org` / `private@unomi.apache.org`).
+3. **Investigate** — triage against [THREAT_MODEL.md](./THREAT_MODEL.md); **accept** or **reject** each distinct finding (a multi-issue report may be split).
+4. **If rejected** — write to the reporter explaining why (cc security lists). Rejection reasons include out-of-model / by-design findings and issues that affect **only unreleased** development code with no released-line impact (still fix before the next GA when appropriate).
+5. **If accepted** — tell the reporter we are working on a fix; request CVE ID(s) via [cveprocess.apache.org](https://cveprocess.apache.org) or `security@apache.org` (ASF Security can advise on splitting/merging CVEs).
+6. **Resolve** — agree the fix privately; document on the ASF CVE portal; share fix + draft announcement with the reporter; commit without security references; ship a release that includes the fix.
+7. **Announce** — with or after the release announcement (reporter, project lists, `security@apache.org`, `oss-security@lists.openwall.com`).
+8. **Complete** — update [unomi.apache.org/security/](https://unomi.apache.org/security/) and CVE references.
## Threat Model
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index f536032ef..2256af7f7 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -19,160 +19,186 @@ limitations under the License.
## §1 Header
- **Project:** Apache Unomi (`apache/unomi`), `master` branch, against which this draft was written. This model covers the **apache/unomi** server; `unomi-tracker` (browser tracking client) and `unomi-site` (website) are in the engagement scope but are treated here as satellites (see §2/§3).
-- **Date:** 2026-06-02. **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta threat-model rubric), for PMC ratification.
+- **Date:** 2026-06-02; **amended:** 2026-08-06 (PMC triage amendments — role split, GraphQL transports, tenant isolation, closed §14 Q1 / Q8a; default-password: documented today + 3.1 hardening commitment); **amended:** 2026-08-07 (UNOMI-972 — the 3.1 default-password retirement shipped; §5a layer 2, §9, §10, §11, §11a, §12, §13 and §14 Q1 revised from commitment to shipped state). **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta t [...]
- **Version binding:** versioned with the project; a report against version *N* is triaged against the model as it stood at *N*.
-- **Reporting cross-reference:** §8-property violations → report privately per ASF process (`security@apache.org` → `private@unomi.apache.org`); §3/§9 findings are closed citing this document.
+- **ASF vulnerability handling:** the PMC follows [Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling) (see also `SECURITY.md`): work in private → acknowledge → investigate → **accept or reject** (with written reasons) → for accepted issues: CVE via `cveprocess.apache.org` / `security@apache.org` → private fix → release → announce → update project security pages. Unomi has no dedicated `security@unomi.apache.org`; copy private vulnerabil [...]
+- **Accept vs reject (released vs unreleased):** **Accept** (and continue through CVE/fix/release/announce) findings that are in-model vulnerabilities in **released** Unomi lines (e.g. 2.x, 3.0.x). **Reject** as a security vulnerability in released Apache software — with a clear explanation to the reporter — findings that (a) are `OUT-OF-MODEL` / `BY-DESIGN` / `KNOWN-NON-FINDING` per this document, or (b) exist **only** on unreleased development code (e.g. `master` / `3.1.0-SNAPSHOT` bef [...]
+- **Reporting cross-reference:** suspected §8 violations → `security@apache.org` → `private@unomi.apache.org`; dispositions cite this document.
- **Provenance legend:** *(documented)* = Unomi's own docs/repo/CVE advisories; *(maintainer)* = confirmed by an Unomi PMC member through this process; *(inferred)* = reasoned from architecture/history, not yet confirmed — each has a matching §14 open question.
-- **Draft confidence:** ~16 documented / 0 maintainer / ~30 inferred.
-- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs. It persists to Elasticsearch/OpenSearch. *(documented — README, manual)*
+- **Draft confidence:** ~16 documented / several maintainer (Q1, Q2, Q7, Q8a) / remaining inferred.
+- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs (HTTP and WebSocket). It persists to Elasticsearch/OpenSearch. *(documented — README, manual)*
## §2 Scope and intended use
- **Primary use:** an operator-deployed **context server** that ingests visitor events over the network and serves profile/segmentation data to web properties and back-office tools. *(documented — manual)*
- **Caller roles** (network service — the role splits):
- - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/context.json` and `/eventcollector` — `EventsCollectorServlet`) **unauthenticated**, from the open internet. The highest-value untrusted surface. *(inferred — confirm the public-endpoint exposure model)*
- - **integrator / API client** — calls the REST / GraphQL APIs, authenticated; may author conditions, rules, segments, scopes. **Trusted to its credential's authority.** *(inferred)*
- - **operator/admin** — controls config, the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. **Trusted.** *(inferred)*
+ - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/cxs/context.json` and `/cxs/eventcollector`) with a tenant **public** API key (`X-Unomi-Api-Key`), from the open internet. Highest-value untrusted surface. (Temporary exception: V2 compatibility mode may omit the public key while migrating.) *(documented — `security.adoc`)*
+ - **tenant administrator** — authenticates with Basic `tenantId:privateApiKey`. Trusted **only within that tenant’s data plane** (profiles, rules, segments, schemas for that tenant). **Not** trusted for host-level side effects (unsandboxed script execution, arbitrary filesystem or unconstrained Camel endpoints) or for other tenants’ data. *(maintainer — §14 Q8a)*
+ - **system administrator / operator** — JAAS (e.g. `karaf`) and control of the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. Trusted for tenant CRUD, key minting, and host-impacting configuration. *(documented — manual; maintainer)*
+ - **integrator / API client** — any authenticated REST/GraphQL caller; **trusted only to its credential’s authority** (public key, tenant private key, or system admin). *(maintainer)*
- **cluster peer** — another Unomi node. *(inferred)*
**Component-family table:**
| Family | Entry point | Touches outside process | In model? |
| --- | --- | --- | --- |
-| Public context ingestion | `/context.json` + `/eventcollector` (`EventsCollectorServlet`, `wab`) | network (public listen) | **In — primary boundary** *(inferred)* |
+| Public context ingestion | `/cxs/context.json` + `/cxs/eventcollector` | network (public listen) | **In — primary boundary** *(documented)* |
| Rule / condition / segment engine + scripting | `services`, `scripting` (MVEL/OGNL expression eval) | evaluates expressions | **In — historically the RCE surface (§11)** *(documented: CVEs)* |
| JSON-Schema event validation | schema validation of incoming events | — | **In — the input-validation defense** *(documented: manual `jsonSchema`)* |
-| Admin REST + GraphQL APIs | `rest`, `graphql` | network (authenticated) | **In** *(documented: modules)* |
+| Admin REST APIs | `rest` | network (authenticated) | **In** *(documented: modules)* |
+| GraphQL HTTP + WebSocket | `/graphql` (queries/mutations over HTTP; subscriptions over WebSocket) | network (authenticated) | **In — auth must apply to every transport, including WebSocket upgrade/subscribe** *(maintainer)* |
+| Groovy actions extension | script upload/compile/dispatch | process (script exec) | **In — host-impacting ops require system administrator** *(maintainer — §14 Q8a)* |
+| Router import/export | Camel source/destination URIs | filesystem / remote endpoints | **In — path/host confinement required for multi-tenant safety** *(maintainer — §14 Q8a)* |
| Persistence | `persistence-elasticsearch` / `persistence-opensearch` | network → ES/OS backend | **In (Unomi's use of it); the backend's own security is operator's** *(inferred)* |
| Plugins / extensions / connectors | `plugins`, `extensions`, `connectors` | varies | **In core ones; third-party/`samples` out** *(inferred)* |
-| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(inferred)* |
-| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3)* |
+| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(maintainer — §14 Q2)* |
+| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3; maintainer — §14 Q2)* |
## §3 Out of scope (explicit non-goals)
- **Attackers who already control the host, the Karaf container, the config, the plugins, or the Elasticsearch/OpenSearch backend.** Operator-trusted. *(inferred)*
-- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(inferred)*
+- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(maintainer — §14 Q2)* A **core** action or endpoint that is unsafe when an operator follows a documented sample pattern can still be `VALID` / `VALID-HARDENING`; only the sample artifact itself is out of scope.
- **Confidentiality of profile data at rest / in the search backend** when the operator has not secured Elasticsearch/OpenSearch and the network — that is deployment hardening, not an Unomi code property, unless Unomi claims otherwise. *(inferred)*
-- **Arbitrary expression evaluation by a *trusted admin*** who authors a malicious condition/rule — an authenticated privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input can reach expression evaluation (see §8/§11). *(inferred — confirm)*
+- **Arbitrary expression evaluation by a *system administrator*** who authors a malicious condition/rule — an authenticated system-privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input, or a *lower-privilege* credential (e.g. tenant administrator), can reach that power (see §8/§11). *(maintainer — §14 Q7 / Q8a)*
## §4 Trust boundaries and data flow
-- **Primary boundary: the public context endpoint.** Event payloads arriving unauthenticated from browsers are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step is the gate. *(inferred; schema validation documented)*
-- **Secondary boundary: the authenticated REST/GraphQL admin surface**, where conditions/rules/scopes are defined — trusted to the credential. *(inferred)*
+- **Primary boundary: the public context endpoint.** Event payloads arriving from browsers (public API key at most) are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step and the public-event allow-list are the gates. *(documented; schema validation documented)*
+- **Secondary boundary: the authenticated REST/GraphQL surface** (HTTP **and** WebSocket), where conditions/rules/scopes are defined and subscriptions are opened — trusted only to the credential’s authority. A transport that skips the auth gate that other transports enforce is an in-model break. *(maintainer)*
+- **Tertiary boundary: tenant credential ↔ host / cross-tenant.** A tenant-administrator subject must not escape to host process powers (unsandboxed script compile/exec, absolute filesystem read/write, unconstrained Camel endpoints) or to another tenant’s data. *(maintainer — §14 Q8a)*
- **The historical break (load-bearing):** the public surface must **not** allow attacker-controlled input to reach OGNL/MVEL expression evaluation that can instantiate/call arbitrary Java — that was CVE-2020-11975 / CVE-2020-13942 / CVE-2021-31164, fixed by constraining the public surface. The model treats a regression of this kind as `VALID`/critical. *(documented — CVE advisories)*
-- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/unauthenticated** input (or from a lower privilege than the operation requires). Expression power available only to a trusted authenticated author is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(inferred)*
+- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/untrusted** input **or from a lower privilege than the operation requires** (e.g. tenant admin reaching system-admin-only host power). Expression power available only to a system administrator is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(maintainer)*
## §5 Assumptions about the environment
- **Runtime:** JVM; runs in an Apache Karaf / OSGi container. *(documented — kar/package/manual)*
- **Backend:** Elasticsearch or OpenSearch, assumed deployed on a trusted network and secured by the operator. *(inferred)*
-- **The public endpoint is internet-facing by design** (browsers post events directly); the admin APIs are assumed *not* public. Confirm. *(inferred)*
-- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path. *(inferred)*
+- **The public endpoint is internet-facing by design** (browsers post events directly); the admin REST/GraphQL APIs are assumed *not* public and are authenticated. *(maintainer — §14 Q1)*
+- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path; optional extensions (Groovy, router) can touch the filesystem and remote endpoints. *(inferred)*
## §5a Build-time and configuration variants
-Security-relevant configuration knobs *(all inferred — confirm names/defaults against `configuration.adoc`):*
+Security-relevant configuration knobs:
-- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept)*
-- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented)*
+- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept; see §14 Q5)*
+- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented; see §14 Q4)*
- **Expression/scripting allow-list** (post-CVE) — what restricts which classes/methods conditions may reference, and is it on by default? *(inferred; the CVE fixes introduced restrictions)*
-- **Authentication on the admin REST/GraphQL APIs** — default credentials? bound to localhost vs all interfaces by default? *(inferred — the insecure-default question; wave 1)*
+- **Authentication on the admin REST/GraphQL APIs** — packaged JAAS user `karaf` whose password comes from `org.apache.unomi.security.root.password`, which as of 3.1 resolves to `${env:UNOMI_ROOT_PASSWORD}` **with no fallback value**; `users.properties` carries no default either. The health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. **No known default password ships**. `bin/karaf` and the Docker entrypoint refuse to start when either variable is unset; note that `kar [...]
+- **Profile id cookie flags** — `contextserver.profileIdCookieHttpOnly` defaults to `true` unless overridden. *(documented — `org.apache.unomi.web.cfg` / `custom.system.properties`)*
+- **Router allowed endpoint schemes** — default allowlist includes `file,ftp,sftp,ftps`. *(documented — `org.apache.unomi.router.cfg`)*
-**Insecure-default check:** if any of (public-endpoint protection, schema validation, scripting allow-list, admin auth) ships *off* or with a default credential, a report against that default is `VALID` unless the PMC designates it a documented must-configure (`OUT-OF-MODEL: non-default-build`). This is a wave-1 ruling (§14).
+**Insecure-default check (PMC ruling) — two layers:**
+
+1. **Triage of reports against pre-3.1 builds:** on releases before 3.1, leaving the documented default JAAS password unchanged was a **documented must-configure** operator duty. A report that only shows “the shipped default admin password still works on a fresh pre-3.1 install” is `OUT-OF-MODEL: non-default-build` / `BY-DESIGN: property-disclaimed` (§9). Auth bypasses or missing gates on an admin surface (independent of the password value) remain `VALID`. *(maintainer — §14 Q1)*
+2. **Product commitment for the 3.1 release — shipped:** the PMC treated the shipped known default as accepted technical debt to retire in 3.1, and **3.1 retires it**. `org.apache.unomi.security.root.password` now resolves only from `UNOMI_ROOT_PASSWORD` with no fallback, `users.properties` carries no default, and the shell launchers fail fast when the variable is unset (advisory only on Windows, see §5a above); the health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. A [...]
## §6 Assumptions about inputs
-Per-surface trust table *(all inferred unless noted):*
+Per-surface trust table:
| Surface | Input | Attacker-controllable? | Caller/operator must enforce |
| --- | --- | --- | --- |
-| Public context endpoint | event JSON, profile/session refs, scope | **yes (unauthenticated, public)** | JSON-schema validation on; public-event allow-list; no expression reach |
-| REST / GraphQL admin | conditions, rules, segments, queries | **yes, within the authenticated credential's authority** | authn + authz; restrict who may author expressions |
-| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; admin: yes-but-trusted** | keep expression authoring on the trusted side |
-| Persistence queries | derived from the above | indirectly | backend hardening; query/scope isolation |
+| Public context endpoint | event JSON, profile/session refs, scope | **yes (public client)** | JSON-schema validation on; public-event allow-list; no expression reach; profile/session refs treated as **bearer identifiers** (see §8) |
+| REST admin | conditions, rules, segments, queries, extension config | **yes, within credential authority** | authn + authz; system admin for host-impacting ops |
+| GraphQL HTTP | queries / mutations | **yes, within credential authority** | same auth gate as documented for GraphQL |
+| GraphQL WebSocket | subscription init + subscribe payloads | **yes** | **same auth as GraphQL HTTP**; no Subject → reject upgrade/subscribe |
+| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; system admin: yes-but-trusted** | keep expression authoring on the system-trusted side |
+| Public events → identity actions | merge/update target from event properties | **yes, when event type is public** | do not wire unverified identity claims to merge/update actions (§11) |
+| Persistence queries | derived from the above | indirectly | backend hardening; tenant/scope isolation |
| Plugins / connectors config | operator-supplied | no — operator-trusted | vet third-party plugins |
- **Size/shape/rate:** whether the public endpoint bounds event size / batch count / request rate against a flood is open (see §8 resource line). *(inferred)*
## §7 Adversary model
-- **Primary adversary:** an unauthenticated party who can reach the **public context endpoint** from the internet — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, inject events to corrupt segmentation, or exhaust resources. *(documented threat history; framing inferred)*
-- **Secondary:** an authenticated API client trying to exceed its authority (read other scopes' data, escalate). *(inferred)*
-- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads. **Not** assumed: control of the admin credential, the container, or the backend. *(inferred)*
-- **Out of scope:** trusted admins authoring powerful (even dangerous) conditions; attackers with host/backend control. *(inferred)*
+- **Primary adversary:** an unauthenticated or public-API-key party who can reach the **public context endpoint** (and any other publicly reachable surface) — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, open privileged GraphQL subscriptions without credentials, inject events to corrupt segmentation or rebind identity, or exhaust resources. *(documented threat history; maintainer framing)*
+- **Secondary:** an authenticated **tenant** API client trying to exceed its authority — cross-tenant read/write, host RCE, arbitrary filesystem or internal-network reach via import/export, uploading unsandboxed scripts. *(maintainer — §14 Q8a)*
+- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads; attempt WebSocket upgrade without credentials; use a stolen or issued tenant private key within (and beyond) its tenant. **Not** assumed: control of the system-admin credential, the container, or the backend. *(maintainer)*
+- **Out of scope:** system administrators authoring powerful (even dangerous) conditions; attackers with host/backend control. *(maintainer)*
## §8 Security properties the project provides
-*(All inferred pending PMC confirmation; the CVE-fix posture is documented history.)*
+*(CVE-fix posture is documented history; tenant and GraphQL transport lines are maintainer-confirmed.)*
-- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated request. *Severity:* security-critical. *(documented that this class was fixed; the standing guarantee is the claim to confirm)*
-- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness to confirm)*
-- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. *Violation symptom:* cross-profile / cross-scope data access. *Severity:* security-critical (data exposure — PII). *(inferred)*
+- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated or public-key-only request. *Severity:* security-critical. *(documented)*
+- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness — §14 Q4)*
+- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. On the public endpoints, `profileId` / `sessionId` are **bearer identifiers** (typically the `context-profile-id` cookie): possession of the identifier is the authorization model. *Violation symptom:* cross-profile / cross-scope data access **without** possessing that bearer (e.g. body `profileId` accepted when it [...]
+- **Authentication on all GraphQL transports.** HTTP queries/mutations and WebSocket upgrade/subscribe share the same authentication requirements; subscriptions are not a public operation. *Violation symptom:* unauthenticated client completes upgrade and reaches `graphQL.execute` for a subscription. *Severity:* security-critical → high. *(maintainer)*
+- **Tenant isolation.** Credentials for tenant A cannot read or modify tenant B’s profiles, segments, rules, or keys. Unomi enforces this boundary in the data plane. *Violation symptom:* cross-tenant data access. *Severity:* security-critical. *(maintainer — §14 Q8a / UNOMI-139)*
+- **No privilege escalation beyond credential class.** Host-impacting operations (unsandboxed script upload/compile/exec, absolute filesystem access, unconstrained remote Camel endpoints) require **system administrator**, not tenant administrator. *Violation symptom:* tenant private key achieves host RCE, arbitrary file read, or equivalent. *Severity:* security-critical. *(maintainer — §14 Q8a)*
- **Resource bounds — UNSPECIFIED.** Whether a public event flood or an expensive segment/condition is a bug or expected-and-operator-managed is open. *(inferred)*
## §9 Security properties the project does *not* provide
-- **No protection if the admin REST/GraphQL APIs are exposed unauthenticated / with default creds** — keeping the admin surface off the public network + authenticated is the operator's job (pending §5a ruling). *(inferred)*
+- **No protection if the admin REST/GraphQL APIs are exposed to the public network** — keeping the admin surface off the public network is the operator's job (§10). On **pre-3.1** builds this disclaimer also covered leaving the documented default JAAS password unchanged (§5a layer 1). It does **not** extend to 3.1 and later: 3.1 ships no known default, so a credential that works without the operator having configured one is a defect there, not a disclaimed property (§5a layer 2). *(maint [...]
- **No confidentiality/integrity for the ES/OS backend or its network** — Unomi assumes a secured backend; it does not defend an exposed Elasticsearch. *(inferred)*
-- **Not a sandbox for admin-authored expressions/plugins** — a trusted author with condition/scripting authority can run server-side logic by design; that power is not contained. *(inferred)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe.
+- **Not a sandbox for system-administrator-authored expressions/plugins** — a system admin with condition/scripting authority can run server-side logic by design; that power is not contained. *(maintainer — §14 Q7)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe. **This disclaimer does not cover tenant-administrator script upload or Camel config that reaches host [...]
+- **No sandbox for uploaded Groovy actions — uploading one is equivalent to shell access on the host.** A Groovy action is compiled and dispatched unrestricted inside the server JVM, with the server's user, classpath and network reach, and it is persisted and re-run. Treat `POST /cxs/groovyActions` as remote code execution *by design*, and the credential that reaches it as a host credential. From 3.1 the endpoint requires the **system** `ADMINISTRATOR` role — a tenant administrator (tena [...]
+- **No guarantee that possession of a visitor `profileId` UUID is hard** — the id is a bearer token; confidentiality of the cookie (and flags such as HttpOnly) is largely an operator/frontend concern, though unsafe defaults may be `VALID-HARDENING`. *(maintainer)*
- **No guarantee of correctness of analytics/segmentation under adversarial event injection** beyond the access-control boundary. *(inferred)*
-- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, and DoS via event floods. *(documented history; framing inferred)*
+- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, DoS via event floods, and deploying sample identity-merge rules without verified identity (§11). *(documented history; maintainer framing)*
## §10 Downstream responsibilities (operator/deployer)
-*(All inferred — confirm.)*
-
-- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; change any default credentials. *(inferred)*
+- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; on 3.1 and later, **set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` explicitly** before starting the server — there is no shipped default and the shell launchers will not start without them — on Windows, verify the password took effect rather than relying on the check. On pre-3.1 builds, change the documented default JAAS credentials before production use. *(maintainer — §14 Q1)*
- Keep JSON-Schema validation and the public-event allow-list **enabled**; register schemas for the events you accept. *(inferred)*
- Secure the Elasticsearch/OpenSearch backend + its network. *(inferred)*
-- Restrict who holds condition/rule/scripting authoring authority — it is equivalent to server-side code definition. *(inferred)*
+- Restrict who holds **system-administrator** condition/rule/scripting and extension-config authority — it is equivalent to server-side code definition. Treat **tenant private keys** as high privilege within a tenant, not as host-admin equivalents. *(maintainer)*
+- Do not deploy sample login-merge (or similar) rules that bind public events to identity-merge/update actions without a **verified** identity step. *(maintainer)*
+- Prefer `profileId` cookie `HttpOnly=true` (and Secure where appropriate) in production. *(maintainer)*
- Put the public endpoint behind rate-limiting / a CDN/WAF appropriate to public exposure. *(inferred)*
## §11 Known misuse patterns
-*(Draft one-liners — expand before publishing.)*
-
-- Exposing the admin REST/GraphQL APIs to the internet (or leaving default creds). *(inferred)*
+- Exposing the admin REST/GraphQL APIs to the internet; on pre-3.1 builds, leaving the documented default JAAS credentials unchanged in production; on 3.1 and later, reusing one shared, well-known `UNOMI_ROOT_PASSWORD` across deployments instead of provisioning a per-deployment secret. *(maintainer)*
- Disabling JSON-Schema validation or the public-event allow-list "to make integration easier", re-opening the public surface. *(inferred)*
- Treating the scripting/expression allow-list as a sandbox for admin-authored conditions. *(inferred)*
- Exposing Elasticsearch/OpenSearch alongside Unomi without backend auth. *(inferred)*
+- Wiring `mergeProfilesOnPropertyAction` / `updatePropertiesAction` (or equivalents) to **public** event types using unverified `eventProperty::` identity claims in production (the login-integration **sample** demonstrates the correct **server-side trusted** pattern under `/login/authenticate`; do not copy a browser→Unomi login POST). *(maintainer)*
+- Granting tenant administrators Groovy upload or router `file`/`ftp`/`sftp` configuration without path/host confinement. *(maintainer)*
+- Assuming GraphQL WebSocket is covered by HTTP-only auth checks. *(maintainer)*
## §11a Known non-findings (recurring false positives)
-*(Seed list — PMC confirmation here is the highest-leverage scan-suppression input.)*
+*(PMC confirmation here is the highest-leverage scan-suppression input.)*
-- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **trusted admin-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/unauthenticated** input reaching expression evaluation; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes)*
-- "Scripting / reflection present in `scripting` module" — needs the public-reachability test (§4) before it is a finding. *(inferred)*
-- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design**; the protection is schema validation + the event allow-list, not authentication. *(inferred)*
+- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **system-administrator-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/untrusted** input, or a **lower privilege than required**, reaching expression evaluation / host script power; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes; maintainer — §14 Q7)*
+- "Scripting / reflection present in `scripting` module" — needs the public-reachability (or privilege-escalation) test (§4) before it is a finding. *(inferred)*
+- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design** (aside from tenant public API key resolution); the protection is schema validation + the event allow-list, not visitor login. *(documented; maintainer)*
+- "Public `/context.json` returns profile data for a supplied `profileId`" — **by design** when that id is the caller’s bearer (cookie / equivalent). Not automatically `VALID` as “IDOR” merely because the attacker knows the UUID. `VALID` / `VALID-HARDENING` when the body id is accepted **without** matching the bearer cookie, when session load switches profile without ownership, or when cookie flags make XSS→id theft trivial by unsafe default. *(maintainer)*
- "Elasticsearch reachable / no TLS" — operator deployment responsibility (§9/§10). *(inferred)*
-- "Admin can run dangerous operation X" — out-of-model: admin is trusted (§7). *(inferred)*
+- "System administrator can run dangerous operation X" — out-of-model: system admin is trusted (§7). **Does not apply** to tenant administrator achieving host RCE, arbitrary file read, or cross-tenant access — those are §8 violations. *(maintainer — §14 Q8a)*
+- "Uploaded Groovy actions are not sandboxed / run arbitrary commands" — out-of-model against **3.1 and later**: upload requires the system `ADMINISTRATOR` role, and unrestricted execution is the documented, intended property of the feature (§9). `VALID` only if it shows a **lower privilege than system administrator** reaching upload (tenant private key, public key, unauthenticated), execution occurring at **upload/compile time** rather than at dispatch, or the role gate being bypassable [...]
+- "The shipped default admin password works on a fresh install" — against **pre-3.1** builds: documented must-configure (§5a layer 1); not `VALID` solely on that basis. That class of report was accepted as **`VALID-HARDENING` motivation** for the 3.1 default-password retirement (§5a layer 2), **which shipped in 3.1**. Against **3.1 and later** it is no longer a non-finding: a known working default, or any fallback admitting a credential the operator never configured, is `VALID`. *(mainta [...]
## §12 Conditions that would change this model
- A change to the public-endpoint protection model, the JSON-Schema-validation default, the scripting allow-list, or admin-auth defaults. *(inferred)*
-- A new public surface or a new expression/scripting capability reachable from untrusted input. *(inferred)*
+- **Shipped:** 3.1 retired the known default JAAS password (§5a layer 2). §5a, §9, §11a and §13 have been revised accordingly — a working known default is no longer `BY-DESIGN` / must-configure on 3.1+, and a regression reintroducing one is `VALID`. Reintroducing *any* implicit credential fallback would require revisiting this model again. *(maintainer)*
+- A new public surface or a new expression/scripting capability reachable from untrusted input or from tenant credentials. *(maintainer)*
- Promoting a `samples/` or third-party connector into core. *(inferred)*
+- A change to the tenant-isolation or privilege-escalation guarantees (§8). *(maintainer)*
- A report that cannot be routed to one §13 disposition → revise the model.
## §13 Triage dispositions
| Disposition | Meaning | Licensed by |
| --- | --- | --- |
-| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; pre-auth crash). | §8, §6, §7 |
-| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden. | §11 |
-| `OUT-OF-MODEL: trusted-input` | Requires admin/authenticated authority (e.g. an admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 |
+| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; GraphQL auth bypass on any transport; tenant isolation break; tenant→host privilege escalation; pre-auth crash). On 3.1+, also: reintroduction of a known working default admin credential, or any fallback admitting a credential the operator never configured (§5a layer 2). | §8, §6, §7, §5a |
+| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden (e.g. HttpOnly default). Historically covered the 3.1 retirement of the known default JAAS password, which has since shipped and is now `VALID` on regression (§5a layer 2). | §11, §5a |
+| `OUT-OF-MODEL: trusted-input` | Requires **system-administrator** authority (e.g. a system-admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 |
| `OUT-OF-MODEL: adversary-not-in-scope` | Requires host/container/backend control or another excluded capability. | §7 |
-| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors. | §3 |
-| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting. | §5a |
-| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no admin-expression sandbox, unauthenticated-by-design context endpoint, backend security). | §9 |
+| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors (sample-only; core behaviour followed from samples may still be `VALID`). | §3 |
+| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting, or solely under an unchanged documented default credential on a **pre-3.1** build (§5a layer 1). | §5a |
+| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no system-admin expression sandbox, unauthenticated-by-design context endpoint, backend security, documented default creds on **pre-3.1** builds). | §9 |
| `KNOWN-NON-FINDING` | Matches a §11a entry. | §11a |
| `MODEL-GAP` | Cannot be cleanly routed — triggers §12. | §12 |
## §14 Open questions for the maintainers
**Wave 1 — scope & default posture:**
-1. Confirm the trust split: the **context endpoint is public/unauthenticated by design**, while the **REST/GraphQL admin APIs are not public and are authenticated**. What are the defaults (bind address, default credentials)? Is exposing the admin API or leaving a default credential a `VALID` report or a documented must-configure? → §2/§5a/§7.
-2. Confirm `unomi-tracker` is modeled as a client-side satellite and `unomi-site`/`samples`/`itests` are out of scope. → §2/§3.
+1. ~~Confirm the trust split… default credentials?~~ **Answered (2026-08):** Context endpoint is public (public API key) by design; admin REST/GraphQL are authenticated and not intended to be public. **Triage (pre-3.1):** the packaged default JAAS admin password was a **documented must-configure** → reports that only show the default still works on those lines are `OUT-OF-MODEL: non-default-build` / `BY-DESIGN`. **Product (3.1) — shipped (UNOMI-972):** the known working default has been [...]
+2. ~~Confirm `unomi-tracker`… samples/`itests` out of scope.~~ **Answered (2026-08):** Tracker is a client-side satellite; `unomi-site` / `samples` / `itests` are out of scope (§3). → §2/§3.
3. The model covers the apache/unomi server; should `unomi-tracker` get its own (lighter) model later, or a discoverability pointer to this one? → §1.
**Wave 2 — the public boundary & its defenses:**
@@ -181,11 +207,11 @@ Per-surface trust table *(all inferred unless noted):*
6. Are there bounds on public event size / batch / rate, or is flood protection the operator's (WAF/rate-limit) concern? → §8/§11a.
**Wave 3 — expressions, scopes, backend:**
-7. Confirm that OGNL/MVEL expression power is **by-design for trusted admin-authored** conditions and is **not** a sandbox — so a finding is `VALID` only when *public/untrusted* input reaches it. → §9/§11a.
-8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8.
-8a. **Tenant isolation (UNOMI-139):** can an API client with credentials for tenant A read or modify tenant B's profiles, segments, or rules — and is that boundary enforced by Unomi (`TenantService` / `SecurityService.getTenantEncryptionKey`) or delegated to the integrator? → §8/§10. *(maintainer-flagged — sergehuber)*
+7. ~~Confirm that OGNL/MVEL…~~ **Answered (2026-08):** Expression power is by-design for **system-administrator-authored** conditions and is not a sandbox. `VALID` when public/untrusted input reaches it, or when a lower privilege (tenant admin) reaches host script power that should require system admin. → §9/§11a.
+8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8. *(partially addressed by bearer-id clarification; scope semantics still open)*
+8a. ~~**Tenant isolation (UNOMI-139):**…~~ **Answered (2026-08):** Unomi **enforces** tenant isolation in the data plane. Tenant A credentials must not read/modify tenant B. Tenant administrator must **not** obtain host RCE, arbitrary filesystem access, or unconstrained Camel reach — those require system administrator. Violations are `VALID`. → §8/§10. *(maintainer — sergehuber)*
9. Is the Elasticsearch/OpenSearch backend assumed trusted/secured-by-operator (so backend-exposure reports are out-of-model)? → §3/§9.
**Wave 4 — meta & non-findings:**
10. Any other recurring scanner/fuzzer false positives to seed §11a (e.g. the `scripting` module, reflection, OSGi dynamic loading)? → §11a.
-11. **Meta:** Unomi has no in-repo `SECURITY.md`/`AGENTS.md` today; this engagement adds `SECURITY.md` + `THREAT_MODEL.md` and wires `AGENTS.md → SECURITY.md → THREAT_MODEL.md`. The website publishes CVE advisories at `unomi.apache.org/security/`. Confirm the in-repo model is canonical and how it should reference the website advisories; confirm revision ownership. → §1.
+11. **Meta:** Confirm the in-repo model is canonical and how it should reference website advisories at `unomi.apache.org/security/`; confirm revision ownership. (In-repo `SECURITY.md` / `AGENTS.md` wiring may already exist on current `master` — verify at ratification.) → §1.
diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc
index c42a9404a..002e304c3 100644
--- a/manual/src/main/asciidoc/5-min-quickstart.adoc
+++ b/manual/src/main/asciidoc/5-min-quickstart.adoc
@@ -35,6 +35,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1
+ - UNOMI_ROOT_PASSWORD=choose-a-strong-password
+ - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
ports:
- 8181:8181
- 9443:9443
@@ -84,6 +86,8 @@ services:
- UNOMI_OPENSEARCH_SSL_ENABLE=true
- UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true
- UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence
+ - UNOMI_ROOT_PASSWORD=choose-a-strong-password
+ - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
ports:
- 8181:8181
- 9443:9443
@@ -104,7 +108,7 @@ Once Unomi is running, create a tenant, then **regenerate** API keys and save th
[source,bash]
----
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "default",
@@ -114,13 +118,13 @@ curl -X POST http://localhost:8181/cxs/tenants \
}
}'
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}"
----
Use the public API key in `X-Unomi-Api-Key` for `/cxs/context.json` requests. See <<_multitenancy,Multi-tenancy>>.
-Try accessing https://localhost:9443/cxs/cluster with username/password: karaf/karaf . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
+Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
=== Quick Start manually
@@ -159,6 +163,14 @@ discovery.type: single-node
5) Download Apache Unomi here : https://unomi.apache.org/download.html
+5b) Before starting Karaf, export required passwords (Unomi will refuse to start without them):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
6) Start it using : `./bin/karaf`
7) Start the Apache Unomi packages using:
@@ -173,14 +185,14 @@ which determines which set of features and bundles are installed and started. A
8) Wait for startup to complete
-9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf/karaf` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
+9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
10) Create a tenant that will own all your data, then regenerate keys and store `plainTextKey`:
[source,bash]
----
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "default",
@@ -190,8 +202,8 @@ curl -X POST http://localhost:8181/cxs/tenants \
}
}'
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}"
----
Save the `plainTextKey` values from the key-creation responses — you'll need them for API calls.
diff --git a/manual/src/main/asciidoc/builtin-event-types.adoc b/manual/src/main/asciidoc/builtin-event-types.adoc
index 800c1b3fe..92c5bf6a6 100644
--- a/manual/src/main/asciidoc/builtin-event-types.adoc
+++ b/manual/src/main/asciidoc/builtin-event-types.adoc
@@ -23,7 +23,9 @@ This event should be “secured”, meaning that it should not be accepted from
Usually, the login event will contain information passed by the authentication server and may include user properties and any additional information.
Rules may be set up to copy the information from the event into the profile, but this is not done in the default set of rules provided by Apache Unomi for security reasons.
-You can find an example of such a rule here: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json]
+You can find an example of such a rule here:
+https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[exampleLogin.json]
+(see <<_login_sample,Login sample>> — the event must be sent from a trusted server-side caller).
===== Structure overview
@@ -240,7 +242,9 @@ image::form-event-type.png[]
This event is usually used by user interfaces that make it possible to modify profile properties, for example a form where a user can edit his profile properties, or a management UI to modify.
-Note that this event type is a protected event type that is only accepted from configured third-party servers.
+Note that this event type is a protected event type that is only accepted from configured third-party servers
+(or equivalently from a trusted private-key / administrator caller in 3.1). Cross-profile updates and
+`systemProperties.*` writes also require a trusted caller — see <<_client_facing_hardening_3_1,client-facing hardening>>.
===== Structure definition
diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc
index b76b2abb2..2ebbbc88c 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -248,7 +248,10 @@ At the end, you should have about 4 million entries in the geonames index.
=== REST API Security
The Apache Unomi Context Server REST API is protected using JAAS authentication and using Basic or Digest HTTP auth.
-By default, the login/password for the REST API full administrative access is "karaf/karaf".
+You must set an admin password via `UNOMI_ROOT_PASSWORD` (or `org.apache.unomi.security.root.password`)
+and a health-check password via `UNOMI_HEALTHCHECK_PASSWORD` (or `org.apache.unomi.healthcheck.password`);
+Unomi does not ship known defaults.
+The default JAAS user name is `karaf`.
The generated package is also configured with a default SSL certificate. You can change it by following these steps :
@@ -267,8 +270,9 @@ org.ops4j.pax.web.ssl.keypassword=${env:UNOMI_SSL_KEYPASSWORD:-changeme}
You should now have SSL setup on Karaf with your certificate, and you can test it by trying to access it on port 9443.
-Changing the default Karaf password can be done by modifying the `org.apache.unomi.security.root.password` in the
-`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
+Changing the Karaf admin password is done by setting `UNOMI_ROOT_PASSWORD` or by modifying
+`org.apache.unomi.security.root.password` in the
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file. Unomi does not ship a known default password.
=== Tenant Management and API Access
@@ -278,14 +282,14 @@ Apache Unomi supports multi-tenancy, allowing multiple organizations to use the
IMPORTANT: All tenant management operations (create, list, update, delete, API key management) are restricted to administrators only and require JAAS authentication. These endpoints cannot be accessed using tenant API keys.
-To manage tenants, you need administrator access to Unomi (default credentials: karaf/karaf). You can manage tenants using either the REST API or the Karaf shell commands:
+To manage tenants, you need administrator access to Unomi (`karaf` / your `UNOMI_ROOT_PASSWORD`). You can manage tenants using either the REST API or the Karaf shell commands:
Using REST API (requires admin credentials):
[source,bash]
----
# Create a new tenant (JAAS auth required)
curl -X POST "http://localhost:8181/cxs/tenants" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "mytenant",
@@ -318,17 +322,17 @@ curl -X POST "http://localhost:8181/cxs/tenants" \
# List all tenants (JAAS auth required)
curl -X GET "http://localhost:8181/cxs/tenants" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# Get tenant details (JAAS auth required)
curl -X GET "http://localhost:8181/cxs/tenants/mytenant" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# Delete a tenant (JAAS auth required)
curl -X DELETE "http://localhost:8181/cxs/tenants/mytenant" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
----
Using Karaf shell (requires admin access to Karaf console). See <<_shell_commands,Shell commands>> for full syntax:
@@ -362,9 +366,9 @@ unomi:crud read tenant -i mytenant
# Obtain plaintext (store immediately; it is not persisted)
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
# Response example:
{
@@ -382,7 +386,7 @@ To generate new API keys (requires admin access):
----
# Using REST API (JAAS auth required). Replaces any existing key of the same type.
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC&validityDays=30" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
# Response (HTTP 200 OK) — store plainTextKey immediately:
{
@@ -442,7 +446,7 @@ curl -X POST "http://localhost:8181/cxs/profiles/search" \
[source,bash]
----
curl -X GET "http://localhost:8181/cxs/tenants" \
- --user "karaf:karaf" \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
----
@@ -817,10 +821,12 @@ Once the action has been created you need to submit it to Unomi (from the same f
[source,bash]
----
curl -X POST 'http://localhost:8181/cxs/groovyActions' \
---user "TENANT_ID:PRIVATE_KEY" \
+--user "karaf:${UNOMI_ROOT_PASSWORD}" \
--form 'file=@helloWorldGroovyAction.groovy'
----
+NOTE: Groovy Actions REST requires the system `ADMINISTRATOR` role (not a tenant private key). See <<_client_facing_hardening_3_1,client-facing hardening>>.
+
Important: A bug ( https://issues.apache.org/jira/browse/UNOMI-847[UNOMI-847] ) in Apache Unomi 2.5 and lower requires the filename of a Groovy file being submitted to be the same as the id of the Groovy action (as per the example above).
Finally, register a rule to trigger execution of the groovy action:
@@ -860,7 +866,7 @@ Once you're done with the Hello World! action, it can be deleted using the follo
[source,bash]
----
curl -X DELETE 'http://localhost:8181/cxs/groovyActions/helloWorldGroovyAction' \
---user "TENANT_ID:PRIVATE_KEY"
+--user "karaf:${UNOMI_ROOT_PASSWORD}"
----
And the corresponding rule can be deleted using the following command:
@@ -1078,7 +1084,7 @@ The `MergeProfilesOnPropertyAction` supports the following parameters:
==== Security considerations
-IMPORTANT: Never trigger profile merges from unauthenticated operations such as form submissions or public-facing APIs. Always verify user identity before performing a merge.
+IMPORTANT: Never trigger profile merges from unauthenticated or public-key-only operations such as form submissions or public-facing context events. Merging into another profile (or switching identity after a merge) requires a **trusted** caller — system administrator or tenant administrator (private key). Always verify user identity on your application server before emitting a merge-triggering event. See <<_client_facing_hardening_3_1,client-facing hardening>>.
The following diagram highlights key security considerations:
@@ -1178,23 +1184,29 @@ documentation for here :
* https://karaf.apache.org/manual/latest/#_security_2[https://karaf.apache.org/manual/latest/#_security_2]
-The default username/password is
+You must set an admin password and a health-check password before using the REST API / health endpoints.
+There are **no** known default passwords shipped with Unomi.
-[source]
+Set the environment variables `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD`, or set
+`org.apache.unomi.security.root.password` and `org.apache.unomi.healthcheck.password` in
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` (or `etc/custom.system.properties`).
+
+Example:
+
+[source,bash]
----
-karaf/karaf
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
----
-You should really change this default username/password as soon as possible. Changing the default Karaf password can be
-done by modifying the `org.apache.unomi.security.root.password` in the `$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
-
-Or if you want to also change the user name you could modify the following file :
+The JAAS admin user name defaults to `karaf`. Authenticate with `karaf:$UNOMI_ROOT_PASSWORD`.
+The health-check user name defaults to `health`. Authenticate with `health:$UNOMI_HEALTHCHECK_PASSWORD`.
- $MY_KARAF_HOME/etc/users.properties
+To change the admin user name, edit `$MY_KARAF_HOME/etc/users.properties` and also set:
-But you will also need to change the following property in the $MY_KARAF_HOME/etc/unomi.custom.system.properties :
+ karaf.local.user = <your-user>
- karaf.local.user = karaf
+in `unomi.custom.system.properties`.
For your context servers, and for any standalone Elasticsearch nodes you will need to open the following ports for proper
node-to-node communication : 9200 (Elasticsearch REST API), 9300 (Elasticsearch TCP transport)
diff --git a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
index 926039747..abe176384 100644
--- a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
+++ b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
@@ -77,7 +77,7 @@ If this is not the case or you prefer to deploy using a KAR bundle, see the KAR
+
[source]
----
-ssh -p 8102 karaf@localhost (default password is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD)
----
+
. Deploy into Apache Unomi using the following commands from the Apache Karaf shell:
@@ -111,8 +111,9 @@ The first URL will give you information about the version of the connectors, so
plugin is properly deployed, started and the correct version. The second URL will actually make a request to the
Salesforce REST API to retrieve the limits of the Salesforce API.
+
-Both URLs are password protected by the Apache Unomi (Karaf) password. You can find this user and password information
-in the etc/users.properties file.
+Both URLs are password protected by the Apache Unomi (Karaf) password: the `karaf` user and the
+`UNOMI_ROOT_PASSWORD` you configured at startup. No default password is shipped, and
+`etc/users.properties` only references the configured value rather than containing it.
You can now use the connectors's defined actions in rules to push or pull data to/from the Salesforce CRM. You can
find more information about rules in the <<_data_model_overview,Data Model>> and the <<_getting_started_with_unomi,Getting Started>> pages.
@@ -152,7 +153,7 @@ mvn clean install
+
[source]
----
-ssh -p 8102 karaf@localhost (password by default is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD)
----
+
. Execute the following commands in the Karaf shell
@@ -171,7 +172,7 @@ feature:install unomi-salesforce-connector-karaf-feature
https://localhost:9443/cxs/sfdc/version
----
+
-(if asked for a password it's the same karaf/karaf default)
+(if asked for credentials, use the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
==== Using the Salesforce Workbench for testing REST API
diff --git a/manual/src/main/asciidoc/getting-started.adoc b/manual/src/main/asciidoc/getting-started.adoc
index 223cb3848..1351cbc95 100644
--- a/manual/src/main/asciidoc/getting-started.adoc
+++ b/manual/src/main/asciidoc/getting-started.adoc
@@ -50,6 +50,21 @@ Note for OpenSearch users:
==== Running Unomi
+===== Set the admin and health passwords (required)
+
+Unomi does not ship known default passwords. Set both *before* starting the server
+(otherwise startup fails with a clear error):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
+Authenticate later with `karaf:$UNOMI_ROOT_PASSWORD`. See the configuration chapter if you prefer to set
+`org.apache.unomi.security.root.password` / `org.apache.unomi.healthcheck.password` in
+`etc/custom.system.properties` instead.
+
===== Start Unomi
Start Unomi according to the <<_five_minutes_quickstart,quick start with docker>> or by compiling using the
@@ -67,14 +82,14 @@ Initializing profile service endpoint...
Initializing cluster service endpoint...
----
-This indicates that all the Unomi services are started and ready to react to requests.
+This indicates that all the Unomi services are started and ready to react to requests.
-Before you can use the API, you need to create a tenant:
+Create a tenant (using the password you set before startup):
[source,bash]
----
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "default",
@@ -89,8 +104,8 @@ The tenant create response includes **masked** API key metadata only. Regenerate
[source,bash]
----
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}"
----
Store `plainTextKey` immediately — you'll need the public key for subsequent API calls. See <<_multitenancy,Multi-tenancy>> for authentication details.
diff --git a/manual/src/main/asciidoc/graphql-examples.adoc b/manual/src/main/asciidoc/graphql-examples.adoc
index 847a7ac99..57656e60a 100644
--- a/manual/src/main/asciidoc/graphql-examples.adoc
+++ b/manual/src/main/asciidoc/graphql-examples.adoc
@@ -171,13 +171,16 @@ To make this query work you need to supply authorization token in the `HTTP head
[source,json]
----
{
- "authorization": "Basic a2FyYWY6a2FyYWY="
+ "authorization": "Basic BASE64_OF_KARAF_AND_ROOT_PASSWORD"
}
----
-NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (`karaf:karaf`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication.
+Generate the Base64 value from your own credentials, for example with
+`printf 'karaf:%s' "$UNOMI_ROOT_PASSWORD" | base64`.
-When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user karaf:karaf` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads:
+NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication.
+
+When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user "karaf:$UNOMI_ROOT_PASSWORD"` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads:
[source,bash]
----
diff --git a/manual/src/main/asciidoc/how-profile-tracking-works.adoc b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
index 587ba0f9b..3b7c35f4c 100644
--- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
+++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
@@ -122,7 +122,7 @@ Starting with Apache Unomi 3.1, tenant resolution is mandatory for all requests
3. **Tenant ID Header** (When using JAAS authentication):
* Send the `X-Unomi-Tenant-Id` header with the tenant ID
- * Used when authenticating via JAAS (e.g., `karaf:karaf`)
+ * Used when authenticating via JAAS (e.g., the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
* The tenant ID must exist in the system
If no tenant can be resolved, the request will fail with an `UNAUTHORIZED` (401) error. This ensures that all data operations are scoped to the correct tenant in multi-tenant deployments.
@@ -173,7 +173,7 @@ The tenant is resolved from the Basic Auth credentials (`mytenant` is the tenant
[source,bash]
----
curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \
---user "karaf:karaf" \
+--user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "X-Unomi-Tenant-Id: mytenant" \
-H "Content-Type: application/json" \
-d '{
@@ -232,24 +232,26 @@ end note
[IMPORTANT]
====
-At least one of the following must be provided in the request: `sessionId`, `profileId` (as a parameter or in a cookie), or `personaId`. If none of these are provided, the request will fail with a `BadRequestException` (400 error).
+At least one of the following must be provided in the request: `sessionId`, a profile cookie (`context-profile-id` by default), a body/query `profileId` **from a trusted caller**, or `personaId`. If none of these are available after public-caller rules below, the request fails with a `BadRequestException` (400).
====
Apache Unomi attempts to identify the visitor's profile through the following process:
-1. **Profile ID Resolution**:
- * First checks for a `profileId` parameter in the request
- * If not found, looks for a cookie named `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`)
- * Cookie values are validated against a JSON schema - invalid values (e.g., containing script tags) will cause a `400 Bad Request` error
- * The resolved profile ID is used to attempt loading the profile from the database
+1. **Profile ID Resolution** (public vs trusted callers):
+ * **Public callers** (public API key / unauthenticated context): the profile cookie is the **only** profile bearer. A body or query `profileId` is **ignored** (even when no cookie is present).
+ * **Trusted callers** (system administrator or tenant private-key / `TENANT_ADMINISTRATOR`): an explicit body/query `profileId` is honored and may differ from the cookie.
+ * Cookie name defaults to `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`).
+ * Cookie values are validated against a JSON schema — invalid values (for example containing script tags) cause a `400 Bad Request`.
+ * The resolved profile ID is used to attempt loading the profile from the database.
2. **Session Profile Override** (if session exists):
- * If a session is found (see Step 2) and contains a profile ID that differs from the cookie/profileId parameter
- * Apache Unomi uses the session's profile ID instead (this handles cases where a user switches accounts)
- * The profile is reloaded from the database using the session's profile ID
+ * If a session is found (see Step 2) and its profile differs from the request profile, Unomi switches to the session profile **only when**:
+ ** the profile cookie already matches the session owner, **or**
+ ** the caller is trusted (and is not keeping an explicit trusted body `profileId` override).
+ * Otherwise the session is **detached** for this request (Unomi does not adopt a foreign session profile for a public caller).
3. **Profile Creation**: If no profile ID is found or the profile doesn't exist:
- * If a profile ID was provided (from parameter or cookie) but doesn't exist in the database, creates a new profile with that ID
+ * If a profile ID was provided (from cookie, or from a trusted body/query `profileId`) but doesn't exist in the database, creates a new profile with that ID
* If no profile ID was found, generates a new UUID as the profile ID and creates a new profile
* Sets the `firstVisit` property to the current timestamp
* Marks the profile for persistence
@@ -259,9 +261,20 @@ This ensures that profiles are always available for processing, even for first-t
[IMPORTANT]
====
-The profile ID is always server-generated (UUID format). Even if a client sends a custom profile ID in a cookie or parameter, Apache Unomi validates it exists in the database. If it doesn't exist, a new profile is created (potentially with that ID if provided via parameter, or with a newly generated UUID). This makes profile IDs secure and prevents profile ID manipulation.
+For **public** callers, do not rely on body/query `profileId` as identity — always send the profile cookie (browsers do this automatically when credentials are included). To act as a specific profile from a backend or admin tool, authenticate as a **trusted** caller (tenant private key or system administrator) and then send `profileId`. See <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> for client migration notes.
====
+====== Why the cookie is safer than a body `profileId` for public callers
+
+On public `/context.json` and `/eventcollector` endpoints, the profile id is a **bearer** identifier: whoever presents it can continue that visitor's tracking context. Unomi therefore prefers the **HTTP cookie** over a body or query `profileId` for public traffic:
+
+* **Browser-enforced delivery** — Once `Set-Cookie` has established the profile cookie (with `HttpOnly` and `SameSite` by default), the browser attaches it on later requests to Unomi. Application JavaScript does not need to copy the id into JSON, so routine front-end code is less likely to mishandle or over-share it.
+* **Harder for page script to exfiltrate** — With `HttpOnly` (the default), script running in the page cannot read the cookie via `document.cookie`. A body/query `profileId` is application-controlled data: anything that can influence the request payload can try to point at another profile id.
+* **Clearer trust boundary** — Public callers prove continuity with the cookie the server previously issued. Selecting an arbitrary id in the body blurred that boundary (knowing or guessing a UUID was enough). Trusted callers (private key / system admin) may still pass `profileId` explicitly when a backend intentionally acts on a chosen profile.
+* **Fewer accidental leaks in URLs and logs** — Query-string `profileId` values show up in browser history, proxies, and access logs more often than `Cookie` headers. Prefer the cookie (and read `profileId` from the JSON **response** when your app needs the value in memory).
+
+Body/query `profileId` remains appropriate for **authenticated** integrations that are supposed to bind a specific profile under operator control — not for anonymous browser trackers.
+
===== Example: Profile Identification
**Example 1: First-Time Visitor (No Cookie)**
@@ -327,13 +340,15 @@ curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \
Apache Unomi loaded the existing profile using the profile ID from the cookie.
-**Example 3: Using profileId Parameter**
+**Example 3: Trusted caller using body/query `profileId`**
+
+Public callers must send the cookie (Example 2). A trusted caller (tenant private key) may select a profile explicitly:
[source,bash]
----
-# Request with explicit profileId parameter
+# Trusted: Basic auth with tenantId:privateKey — body/query profileId is honored
curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
--H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+--user "TENANT_ID:PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": {
@@ -344,17 +359,16 @@ curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1
}'
----
-The `profileId` parameter takes precedence over the cookie value.
+For public callers, a body/query `profileId` without a matching cookie is ignored (it does **not** take precedence over the cookie).
[plantuml]
----
@startuml
title Profile and Session Identification Flow
-RestServiceUtils -> RestServiceUtils: Get profileId from parameter
-alt profileId parameter exists
- RestServiceUtils -> ProfileService: load(profileId)
-else Check cookie
+alt trusted caller with body/query profileId
+ RestServiceUtils -> ProfileService: load(bodyOrQueryProfileId)
+else public or no explicit trusted profileId
RestServiceUtils -> HttpServletRequest: getCookie("context-profile-id")
HttpServletRequest --> RestServiceUtils: profileId from cookie
RestServiceUtils -> ProfileService: load(profileId)
@@ -374,9 +388,11 @@ alt sessionId provided
RestServiceUtils -> ProfileService: loadSession(sessionId)
alt Session found
RestServiceUtils -> RestServiceUtils: Check session profile
- alt Session profile differs
+ alt Session profile differs and (cookie owns session or trusted)
RestServiceUtils -> ProfileService: load(sessionProfileId)
RestServiceUtils -> RestServiceUtils: Use session's profile
+ else public mismatch
+ RestServiceUtils -> RestServiceUtils: Detach session for this request
end
else Session not found
RestServiceUtils -> RestServiceUtils: Create new Session(sessionId, profile)
@@ -393,7 +409,7 @@ end
If a `sessionId` is provided in the request:
1. **Session Loading**: Apache Unomi attempts to load the existing session from the database
-2. **Profile Association**: If a session is found, it may contain a profile ID that takes precedence over the cookie/profileId parameter
+2. **Profile Association**: If a session is found and its profile differs from the request profile, Unomi switches only when the cookie owns that session or the caller is trusted (see Step 1). Public callers cannot adopt a foreign session profile.
3. **Session Creation**: If no session is found or the session is invalidated:
* Creates a new session with the provided `sessionId`
* Associates the session with the current profile (or anonymous profile if privacy settings require it)
@@ -804,7 +820,7 @@ Cookies are only set for regular profiles, not for Personas. If a `personaId` is
* `Max-Age=31536000` - Valid for 1 year by default (configurable via `org.apache.unomi.profile.cookie.maxAgeInSeconds`)
* `SameSite=Lax` - CSRF protection
* `Secure` - Set if the request is over HTTPS (configurable)
- * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: false)
+ * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`)
* `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain`
This cookie allows the browser to automatically send the profile ID on subsequent requests, enabling Apache Unomi to load the existing profile.
@@ -1180,7 +1196,7 @@ The following configuration properties control profile tracking behavior:
* `org.apache.unomi.profile.cookie.name` - Cookie name (default: `context-profile-id`)
* `org.apache.unomi.profile.cookie.maxAgeInSeconds` - Cookie expiration time (default: `31536000` = 1 year)
-* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `false`)
+* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response.
* `org.apache.unomi.profile.cookie.domain` - Cookie domain (optional)
-These can be configured in the `org.apache.unomi.web.cfg` configuration file.
+These can be configured in `etc/custom.system.properties` / `org.apache.unomi.web.cfg`.
diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
index c1179e909..0da22ba27 100644
--- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc
+++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
@@ -52,8 +52,9 @@ Here's a minimal tracker structure:
contextServerUrl: 'http://localhost:8181',
apiKey: 'YOUR_PUBLIC_API_KEY',
scope: 'mydigital',
- sessionCookieName: 'unomi-session-id',
- profileCookieName: 'context-profile-id'
+ sessionCookieName: 'unomi-session-id'
+ // No profile cookie name here: the profile ID cookie is HttpOnly and
+ // cannot be read from JavaScript. Use response.profileId instead.
};
// Tracker object
@@ -121,13 +122,13 @@ The tracker needs to generate and maintain a session ID. If no session ID exists
// Generate a UUID v4
// Uses crypto.randomUUID() if available (most secure, modern browsers)
// Falls back to crypto.getRandomValues() (secure, widely supported)
-// Falls back to Math.random() only for very old browsers (less secure)
+// Fails closed if neither is available: session IDs must never be guessable
generateUUID: function() {
// Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
-
+
// Use crypto.getRandomValues() if available (widely supported, secure)
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -136,14 +137,16 @@ generateUUID: function() {
return v.toString(16);
});
}
-
- // Fallback to Math.random() for very old browsers (not cryptographically secure)
- // Note: This is less secure and should only be used as a last resort
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
+
+ // No cryptographically secure source available: fail closed.
+ // Do NOT fall back to Math.random() — its output is predictable and a
+ // guessable session ID lets an attacker take over a visitor's session.
+ throw new Error(
+ 'Unomi tracker: no cryptographically secure random source available ' +
+ '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' +
+ 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' +
+ 'secure contexts) or install a Web Crypto polyfill.'
+ );
},
// Get or create session ID
@@ -843,11 +846,13 @@ getContextWithRetry: function(options, callback, maxRetries) {
===== Profile ID Synchronization
-The profile ID is set by Apache Unomi in a cookie. Make sure to read it from the response:
+The profile ID is set by Apache Unomi in a cookie. By default the cookie is `HttpOnly`, so browser JavaScript
+**cannot** read it via `document.cookie`. Always prefer the `profileId` field in the JSON response. The browser still
+**sends** the cookie on subsequent requests when credentials/cookies are included.
[source,javascript]
----
-// Enhanced context request that handles profile ID cookie
+// Enhanced context request that handles profile ID from the response
getContext: function(options, callback) {
// ... existing code ...
@@ -857,14 +862,10 @@ getContext: function(options, callback) {
try {
const response = JSON.parse(xhr.responseText);
- // Profile ID cookie is automatically set by Apache Unomi
- // but we can verify it matches the response
- const cookieProfileId = tracker.getCookie(CONFIG.profileCookieName);
- if (response.profileId && cookieProfileId !== response.profileId) {
- console.warn('Profile ID mismatch:', {
- cookie: cookieProfileId,
- response: response.profileId
- });
+ // Authoritative profile id for application logic:
+ // response.profileId (cookie may be HttpOnly and unreadable from JS)
+ if (response.profileId) {
+ // store in memory / your app state if needed — do not rely on document.cookie
}
if (callback) {
@@ -1005,7 +1006,8 @@ Here's a complete, production-ready tracker implementation combining all the con
apiKey: 'YOUR_PUBLIC_API_KEY',
scope: 'mydigital',
sessionCookieName: 'unomi-session-id',
- profileCookieName: 'context-profile-id',
+ // No profile cookie name here: the profile ID cookie is HttpOnly and
+ // cannot be read from JavaScript. Use response.profileId instead.
eventQueueSize: 10,
eventQueueInterval: 5000
};
@@ -1034,13 +1036,13 @@ Here's a complete, production-ready tracker implementation combining all the con
// UUID generation
// Uses crypto.randomUUID() if available (most secure, modern browsers)
// Falls back to crypto.getRandomValues() (secure, widely supported)
- // Falls back to Math.random() only for very old browsers (less secure)
+ // Fails closed if neither is available: session IDs must never be guessable
generateUUID: function() {
// Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
-
+
// Use crypto.getRandomValues() if available (widely supported, secure)
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -1049,14 +1051,16 @@ Here's a complete, production-ready tracker implementation combining all the con
return v.toString(16);
});
}
-
- // Fallback to Math.random() for very old browsers (not cryptographically secure)
- // Note: This is less secure and should only be used as a last resort
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
+
+ // No cryptographically secure source available: fail closed.
+ // Do NOT fall back to Math.random() — its output is predictable and a
+ // guessable session ID lets an attacker take over a visitor's session.
+ throw new Error(
+ 'Unomi tracker: no cryptographically secure random source available ' +
+ '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' +
+ 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' +
+ 'secure contexts) or install a Web Crypto polyfill.'
+ );
},
// Session management
@@ -1230,26 +1234,28 @@ Here's a complete, production-ready tracker implementation combining all the con
====
**Security Considerations for UUID Generation**
-The tracker uses UUIDs for session IDs, which should be unpredictable to prevent session hijacking. The implementation in this guide uses a secure fallback chain:
+Session IDs are security-relevant: a session ID is a bearer identifier, so anyone who can predict one can hijack the corresponding visitor session. The implementation in this guide therefore uses only cryptographically secure sources, and **fails closed** when none is available:
1. **`crypto.randomUUID()`** (Preferred): Available in modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+). This is the most secure option and generates RFC 4122-compliant UUIDs using cryptographically secure random number generation.
2. **`crypto.getRandomValues()`** (Fallback): Available in all modern browsers (IE 11+, Chrome 11+, Firefox 21+, Safari 5.1+). Uses the Web Crypto API to generate cryptographically secure random values. This is secure and widely compatible.
-3. **`Math.random()`** (Last Resort): Only used for very old browsers that don't support the Web Crypto API. This is **not cryptographically secure** and can be predictable, making it vulnerable to session hijacking attacks.
+3. **No secure source → throw**: if neither Web Crypto entry point exists, `generateUUID()` throws an `Error` instead of returning an identifier. There is deliberately **no `Math.random()` fallback**: `Math.random()` is not cryptographically secure, its output is predictable, and returning such a value would silently hand attackers a guessable session ID. Refusing to track is the safer outcome.
**Recommendations:**
* For production applications, ensure your minimum browser support includes browsers with `crypto.getRandomValues()` support (essentially all browsers from 2013+)
-* If you need to support very old browsers (IE 10 and below), consider using a polyfill or warning users about security limitations
-* Never use `Math.random()` alone for security-sensitive identifiers like session IDs or authentication tokens
+* Serve your pages over HTTPS: the Web Crypto API is only exposed in secure contexts, so a page served over plain HTTP can hit the fail-closed branch even on a current browser
+* If you must support browsers without the Web Crypto API (IE 10 and below), install a Web Crypto polyfill — do not reintroduce a `Math.random()` fallback
+* Never use `Math.random()` for security-sensitive identifiers like session IDs or authentication tokens
+* Handle the thrown error in your integration (for example, disable tracking and log a warning) rather than letting it break unrelated page scripts
* The profile ID is always generated server-side by Apache Unomi using secure UUID generation, so client-side UUID generation is only needed for session IDs
**Browser Compatibility:**
-* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+)
-* `crypto.getRandomValues()`: All modern browsers (2013+)
-* `Math.random()`: All browsers (but not secure)
+* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+), secure contexts only
+* `crypto.getRandomValues()`: All modern browsers (2013+), secure contexts only
+* Anything older, or any non-secure context: unsupported — the tracker throws rather than generating a weak session ID
-For maximum security and compatibility, the implementation automatically uses the best available method.
+The implementation automatically uses the best available secure method, and refuses to generate a session ID when there is none.
====
==== Next Steps
diff --git a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
index 2d4a01440..273005155 100644
--- a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
+++ b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
@@ -16,7 +16,7 @@
The JSON schema endpoints are private, so the user has to be authenticated to manage the JSON schema in Unomi.
-IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
==== List existing schemas
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index 18b69e288..a041bf2d2 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -34,7 +34,7 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Aspect |Unomi 3.0 |Unomi 3.1
|Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (configured admin password)
|Tenant-based API Keys + System Administrator Authentication
|Public API Endpoints
@@ -46,8 +46,8 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication
|Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication
+|System Administrator Authentication (configured `UNOMI_ROOT_PASSWORD`)
|===
==== API Key Types (3.1 Only)
@@ -68,12 +68,12 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Public API Key only
|Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
|Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
|===
==== Authentication Flow (3.1)
@@ -84,7 +84,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header
3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication:
- **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
- - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials)
+ - **System Administrator Authentication**: Basic Auth with `karaf:$UNOMI_ROOT_PASSWORD` (or configured admin credentials)
==== Code Examples
@@ -94,7 +94,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
----
// Global system administrator authentication for all endpoints
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests require no authentication
RestAssured.given()
@@ -124,14 +124,14 @@ given()
// For private endpoints using system administrator authentication
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(payload)
.post("/cxs/profiles");
// For tenant administration (system admin only)
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(tenantPayload)
.post("/cxs/tenants");
@@ -164,7 +164,7 @@ public class UnomiConfiguration {
public void init() {
RestAssured.baseURI = baseUrl;
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
}
// 3.1 Client
@@ -210,10 +210,87 @@ When migrating to 3.1, you need to understand that:
- Keep system administrator authentication as fallback for administrative operations
- Continue using system administrator authentication for tenant administration
-4. **No API Contract Changes**
- - All endpoints remain the same
- - Request/response payloads are unchanged
- - Only authentication mechanism differs
+4. **Related client contract changes (3.1 hardening)**
+ - Public `/context.json` and `/eventcollector` no longer treat body/query `profileId` as identity — see below
+ - Endpoint paths and most payload shapes remain the same; authentication and public profile-binding rules change
+
+[#_client_facing_hardening_3_1]
+==== Client-facing hardening (profile cookie, passwords, privileged APIs)
+
+In addition to tenant API keys, Unomi 3.1 hardens several contracts that can break older clients and scripts. See also <<_how_profile_tracking_works,How profile tracking works>> and https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972].
+
+**Why cookie-only for public profile identity?** On public context endpoints the profile id is a bearer token. Accepting a client-chosen body/query `profileId` let any public caller try to continue another visitor's context if they obtained that UUID. The cookie is issued by Unomi, sent automatically by the browser, and (with default `HttpOnly`) is not readable from page script — so public clients prove continuity with a server-issued bearer instead of an application-supplied id. Trusted [...]
+
+[cols="1,2,2", options="header"]
+|===
+|Area |Old common pattern |Required in 3.1
+
+|Public profile identity
+|Body/query `profileId` often selected the profile (sometimes ahead of the cookie)
+|Public callers: **cookie only**. Body/query `profileId` is ignored. Trusted callers (tenant private key / system admin) may still override.
+
+|Session resume without cookie
+|Sending only `sessionId` could switch onto that session's profile
+|Public callers switch only if the cookie already owns the session; otherwise the session is detached for the request.
+
+|Profile cookie `HttpOnly`
+|Often defaulted to `false` (JS could read `document.cookie`)
+|Default **`true`**. Prefer `profileId` from the JSON response. Opt out with `org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the tradeoff.
+
+|Admin / health passwords
+|Known defaults such as `karaf` / `karaf` were common in samples
+|Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no shipped known defaults).
+
+|Groovy Actions / Router import-export REST
+|Often callable with tenant private key
+|Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator.
+
+|Startup without a password
+|Started with the shipped default
+|`bin/karaf` and the Docker entrypoint refuse to start. On Windows startup continues — see the warning below.
+
+|`mergeProfilesOnProperty` / cross-profile `updateProperties` / `systemProperties.*`
+|Sometimes driven from public context events
+|Cross-profile merge/update and `systemProperties` writes require a trusted caller (system or tenant admin). Public callers may still update the **current** cookie-bound profile's normal properties when the event type allows it.
+|===
+
+[WARNING]
+====
+**Windows: the startup check cannot stop the launcher.**
+
+On Linux and macOS, `bin/karaf` sources `bin/setenv` directly, so a missing password aborts startup.
+The Docker entrypoint behaves the same way and the container exits.
+
+On Windows, `karaf.bat` runs `setenv.bat` with `call` and does not test the exit code, so the error
+message is printed and **startup continues anyway**. This is a limitation of the Karaf launcher that
+Unomi cannot work around without replacing `karaf.bat`.
+
+This matters because an unset password is not the same as a disabled account. `${env:UNOMI_ROOT_PASSWORD}`
+resolves to the *empty string*, so `etc/users.properties` creates the `karaf` administrator with an
+empty password that authenticates successfully.
+
+Windows operators must therefore treat the message as fatal and verify the password took effect
+before exposing the instance — for example by confirming that an empty password is rejected:
+
+[source,bash]
+----
+# Must return 401. A 200 means the account has a blank password.
+curl -i -u "karaf:" http://localhost:8181/cxs/tenants
+----
+
+Unomi additionally refuses any REST call presenting Basic authentication with an empty password, so
+the admin API is not reachable that way even if the server did start unconfigured. The Karaf SSH
+console (port 8102) is not covered by that check.
+====
+
+===== Migration checklist for client applications
+
+* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); stop treating body `profileId` as authoritative for public calls.
+* Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile.
+* Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone.
+* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`.
+* Windows deployments: confirm the passwords actually took effect after upgrading — the startup check warns but cannot halt `karaf.bat` (see the warning above).
+* Automation that uploaded Groovy actions or managed Router import/export with a tenant private key: switch to system administrator credentials.
==== Benefits of Multi-Tenancy in 3.1
@@ -232,6 +309,7 @@ Before starting the migration, please ensure that:
- You are currently running Apache Unomi 3.0 (or a later 3.0.x version)
- You understand the multi-tenancy impact on your data model
- You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 2.x)
+- You have reviewed the <<_client_facing_hardening_3_1,client-facing hardening>> notes (cookie-only public profile binding, HttpOnly default, required passwords, privileged REST roles)
- You know how to obtain plaintext API keys after upgrade (regenerate via `/cxs/tenants/{id}/apikeys`; create responses expose masked keys only)
=== Migration Process
@@ -293,7 +371,7 @@ The fundamental difference between Unomi 3.0 and 3.1 is the introduction of **co
- **3.0**: Single-tenant architecture with system administrator authentication for all operations
- **3.1**: Multi-tenant architecture with complete data isolation and tenant-specific authentication
-- **API Endpoints**: Identical between versions - no breaking changes to existing integrations
+- **API Endpoints**: Paths remain the same; authentication and public profile-binding rules change (see client-facing hardening above)
- **Data Model**: All entities (profiles, events, segments, rules, schemas) become tenant-specific in 3.1
- **Authentication**: New tenant-based authentication model with system administrator authentication as fallback
diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc b/manual/src/main/asciidoc/migrations/migrations.adoc
index 9cbe00146..6faa3da16 100644
--- a/manual/src/main/asciidoc/migrations/migrations.adoc
+++ b/manual/src/main/asciidoc/migrations/migrations.adoc
@@ -17,7 +17,7 @@ This section contains information and steps to migrate between major Unomi versi
Use this decision guide to pick the right runbook:
* **Unomi 2.x → 3.0** (platform): <<_migrate_from_2_x_to_3_0,Migrate from 2.x to 3.0>> (+ <<_migrate_from_elasticsearch_7_to_elasticsearch_9,ES7→ES9>> if needed)
-* **Unomi 3.0 → 3.1** (tenants / API keys): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients
+* **Unomi 3.0 → 3.1** (tenants / API keys / client hardening): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients; see also <<_client_facing_hardening_3_1,client-facing hardening>>
* **Elasticsearch → OpenSearch** (same Unomi version, backend swap): <<_migrate_from_elasticsearch_to_opensearch,Migrate from Elasticsearch to OpenSearch>> (not `unomi:migrate`)
[plantuml]
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index 0614d523b..623820939 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -111,7 +111,7 @@ Your V2 clients should now work without any changes:
```java
// V2-style authentication still works
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests work without API keys
RestAssured.given()
@@ -137,7 +137,7 @@ Over time, gradually update your clients to use V3 authentication:
```java
// This continues to work in V2 compatibility mode
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
RestAssured.given()
.auth().none()
@@ -260,7 +260,7 @@ The V2 third-party configuration supports dynamic updates:
- Ensure the tenant exists and is accessible
**Authentication errors**:
-- Verify system administrator credentials (karaf/karaf)
+- Verify system administrator credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
- Check that the server is running properly
- Review logs for authentication errors
diff --git a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
index 4eb64ca28..fbf69a019 100644
--- a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
@@ -40,7 +40,7 @@ This multi-tenancy support necessitates the authentication changes described bel
|Aspect |Unomi V2 |Unomi 3.1
|Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
|Tenant-based API Keys + System Administrator Authentication
|Public API Endpoints
@@ -52,8 +52,8 @@ This multi-tenancy support necessitates the authentication changes described bel
|Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication
|Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
+|System Administrator Authentication (`karaf` user)
|===
===== API Key Types (V3 Only)
@@ -74,12 +74,12 @@ V3 introduces two types of API keys per tenant:
|Public API Key only
|Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf` user)
|Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|System Admin (`karaf` user)
|===
==== Authentication Flow (V3)
@@ -90,7 +90,7 @@ The AuthenticationFilter in V3 follows this resolution order:
2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header
3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication:
- **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
- - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials)
+ - **System Administrator Authentication**: Basic Auth with the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`
==== Code Examples
@@ -100,7 +100,7 @@ The AuthenticationFilter in V3 follows this resolution order:
----
// Global system administrator authentication for all endpoints
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests require no authentication
RestAssured.given()
@@ -130,14 +130,14 @@ given()
// For private endpoints using system administrator authentication
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(payload)
.post("/cxs/profiles");
// For tenant administration (system admin only)
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(tenantPayload)
.post("/cxs/tenants");
@@ -170,7 +170,7 @@ public class UnomiConfiguration {
public void init() {
RestAssured.baseURI = baseUrl;
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
}
// V3 Client
diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc
index 6f6a914ce..904ca0399 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -59,7 +59,7 @@ To create a new tenant, use the Tenant API endpoint:
[source,bash]
----
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "my-tenant",
@@ -102,10 +102,10 @@ IMPORTANT: Store plaintext keys immediately after calling the key creation endpo
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
----
Example response:
@@ -387,7 +387,7 @@ securityService.executeAsSystemSubject(() -> {
[source,bash]
----
curl -X GET http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -396,7 +396,7 @@ curl -X GET http://localhost:8181/cxs/tenants \
[source,bash]
----
curl -X PUT http://localhost:8181/cxs/tenants/my-tenant \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Updated Organization Name",
@@ -411,10 +411,10 @@ Regenerating replaces any existing key of the same type. Store `plainTextKey` fr
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC&validityDays=30" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
----
NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expiration; omit it (or use `0`) for no expiration.
@@ -424,7 +424,7 @@ NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expirat
[source,bash]
----
curl -X DELETE http://localhost:8181/cxs/tenants/my-tenant \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -464,7 +464,7 @@ Unomi exposes read-only usage metrics per tenant. Quota enforcement belongs in y
[source,bash]
----
curl -X GET "http://localhost:8181/cxs/tenants/my-tenant/usage?period=current-month" \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -479,7 +479,7 @@ Upstream control planes can delete old tenant events through Unomi instead of ta
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/purge/events?retentionDays=90" \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -568,7 +568,7 @@ After migration, verify tenant context and data access:
----
# List tenants (JAAS admin)
curl -X GET http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# List profiles for a tenant (tenant private key auth)
@@ -652,7 +652,7 @@ curl -X GET http://localhost:8181/cxs/jsonSchema \
-H "Content-Type: application/json"
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
You can also validate events against your schema using the validation endpoint:
@@ -683,14 +683,22 @@ curl -X POST http://localhost:8181/cxs/jsonSchema/validateEvent \
Once the event type is defined, you can send events:
+[NOTE]
+====
+`/cxs/context.json` is a public endpoint, so the profile is carried by the `context-profile-id`
+cookie: since Unomi 3.1 a public caller's body `profileId` is ignored. Only a trusted caller
+(tenant private key or system administrator) may bind a profile explicitly through the body. See
+<<_client_facing_hardening_3_1,client-facing hardening>>.
+====
+
[source,bash]
----
curl -X POST http://localhost:8181/cxs/context.json \
-H "X-Unomi-Api-Key: <PUBLIC_KEY>" \
-H "Content-Type: application/json" \
+ -b "context-profile-id=profile-456" \
-d '{
"sessionId": "session-123",
- "profileId": "profile-456",
"source": {
"itemId": "checkout-page",
"itemType": "page",
@@ -768,9 +776,9 @@ To test that everything works:
curl -X POST http://localhost:8181/cxs/context.json \
-H "X-Unomi-Api-Key: <PUBLIC_KEY>" \
-H "Content-Type: application/json" \
+ -b "context-profile-id=profile-456" \
-d '{
"sessionId": "session-123",
- "profileId": "profile-456",
"source": {
"itemId": "checkout-page",
"itemType": "page",
diff --git a/manual/src/main/asciidoc/privacy.adoc b/manual/src/main/asciidoc/privacy.adoc
index dda5c3959..a32c187fe 100644
--- a/manual/src/main/asciidoc/privacy.adoc
+++ b/manual/src/main/asciidoc/privacy.adoc
@@ -78,7 +78,7 @@ curl -X DELETE http://localhost:8181/cxs/privacy/profiles/{profileID}?withData=f
--user "TENANT_ID:PRIVATE_KEY"
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
where `{profileID}` must be replaced by the actual identifier of a profile
and the `withData` specifies whether the data associated with the profile must be anonymized or not
diff --git a/manual/src/main/asciidoc/recipes.adoc b/manual/src/main/asciidoc/recipes.adoc
index 8f0f8c9ce..7236f9508 100644
--- a/manual/src/main/asciidoc/recipes.adoc
+++ b/manual/src/main/asciidoc/recipes.adoc
@@ -27,7 +27,7 @@ you might be tempted to modify them to fit your use case, which might result in
The best approach during development is to enable Apache Unomi debug mode, which will provide
you with more detailed logs about events processing.
-The debug mode can be activated via the karaf SSH console (default credentials are karaf/karaf):
+The debug mode can be activated via the karaf SSH console (authenticate with `karaf` and your configured `UNOMI_ROOT_PASSWORD`):
[source]
----
@@ -134,10 +134,11 @@ event data to the profile. This is simpler than it sounds, as usually all it req
defining the corresponding JSON schema and you're ready to update profiles using events.
- Use the protected built-in "updateProperties" event. This event is designed to be used for administrative purposes
-only. Again, prefer the custom events solution because as this is a protected event it will require sending the Unomi
-key as a request header, and as Unomi only supports a single key for the moment it could be problematic if the key is
-intercepted. But at least by using an event you will get the benefits of auditing and historical property modification
-tracing (see <<_request_tracing_explain,request tracing>>).
+only. Cross-profile updates and `systemProperties.*` writes require a **trusted** caller (tenant private key or system
+administrator). Prefer custom events for public visitors. Again, prefer the custom events solution because as this is a
+protected event it will require sending trusted credentials, and as Unomi only supports a single key for the moment it
+could be problematic if the key is intercepted. But at least by using an event you will get the benefits of auditing and
+historical property modification tracing (see <<_request_tracing_explain,request tracing>>).
Let's go into more detail about the preferred way to update a profile. Let's consider the following example of a rule:
@@ -208,7 +209,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \
}'
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
The next step consist in creating a JSON Schema to validate our event.
@@ -485,7 +486,7 @@ much preferred.
When sending a login event, you can setup a rule that can check a profile property to see if profiles can be merged on an
universal identifier such as an email address.
-In our login sample we provide an example of such a rule. You can find it here:
+In our login sample we provide an example of such a rule (fired from a **server-side** trusted call — see <<_login_sample,Login sample>>). You can find the rule here:
https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json
@@ -579,7 +580,7 @@ Upon merge:
===== API
-/context.json and /eventcollector will now look up profiles by profile ID or aliases from the same cookie (`context-profile-id`) or body parameters (`profileId`)
+/context.json and /eventcollector look up profiles by profile ID or aliases from the profile cookie (`context-profile-id` by default). Public callers must present that cookie; body/query `profileId` is ignored for public callers. Trusted callers (tenant private key / system admin) may still pass `profileId` explicitly. See <<_how_profile_tracking_works,How profile tracking works>> and <<_client_facing_hardening_3_1,client-facing hardening>>.
|===
| *Verb* | *Path* | *Description*
diff --git a/manual/src/main/asciidoc/request-examples.adoc b/manual/src/main/asciidoc/request-examples.adoc
index cb06a6f6b..26edb46e6 100644
--- a/manual/src/main/asciidoc/request-examples.adoc
+++ b/manual/src/main/asciidoc/request-examples.adoc
@@ -25,7 +25,7 @@ First, create a tenant that will own all the data:
[source]
----
curl -X POST http://localhost:8181/cxs/tenants \
---user karaf:karaf \
+--user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "mytenant",
@@ -67,8 +67,8 @@ Regenerate keys to obtain one-time plaintext values (store them immediately):
[source,bash]
----
-curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user "karaf:$UNOMI_ROOT_PASSWORD"
+curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user "karaf:$UNOMI_ROOT_PASSWORD"
----
After creating the tenant and regenerating keys, use these credentials in the examples:
@@ -549,14 +549,22 @@ The format is always `MM-DD` where:
You can also update the personalization example to use the birthday property:
+[NOTE]
+====
+The profile is selected with the `context-profile-id` cookie, not with a `profileId` in the request
+body. `/cxs/context.json` is a public endpoint, and since Unomi 3.1 a public caller's body `profileId`
+is ignored — the cookie is the only profile identity bearer. See
+<<_client_facing_hardening_3_1,Client-facing hardening>> in the 3.0 to 3.1 migration guide.
+====
+
[source]
----
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
-d '{
"sessionId": "birthday-session",
- "profileId": "profile-1",
"source": {
"itemId": "homepage",
"itemType": "page",
@@ -702,9 +710,9 @@ For the birthday profile (should show birthday message):
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
-d '{
"sessionId": "birthday-session",
- "profileId": "profile-1",
"source": {
"itemId": "homepage",
"itemType": "page",
@@ -748,9 +756,9 @@ For the non-birthday profile (should show welcome message):
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-2" \
-d '{
"sessionId": "regular-session",
- "profileId": "profile-2",
"source": {
"itemId": "homepage",
"itemType": "page",
diff --git a/manual/src/main/asciidoc/scheduler.adoc b/manual/src/main/asciidoc/scheduler.adoc
index 786fdbc87..3783a767e 100644
--- a/manual/src/main/asciidoc/scheduler.adoc
+++ b/manual/src/main/asciidoc/scheduler.adoc
@@ -645,14 +645,14 @@ NOTE: The REST API is for **monitoring and operations** (list, inspect, cancel,
.List all tasks (paginated)
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?offset=0&limit=20"
----
.Filter by status
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?status=FAILED&limit=50"
----
@@ -661,7 +661,7 @@ Valid status values: `SCHEDULED`, `WAITING`, `RUNNING`, `COMPLETED`, `FAILED`, `
.Filter by task type
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?type=cache-refresh-segment&limit=10"
----
@@ -689,7 +689,7 @@ curl -s -u karaf:karaf \
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks/TASK_ID"
----
@@ -699,7 +699,7 @@ Cancellation stops future runs and marks the task `CANCELLED`. The task record r
[source,bash]
----
-curl -s -u karaf:karaf -X DELETE \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X DELETE \
"http://localhost:8181/cxs/tasks/TASK_ID"
----
@@ -710,11 +710,11 @@ Returns HTTP `204 No Content` on success.
[source,bash]
----
# Retry keeping failure count
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/retry"
# Retry and reset failure count to zero
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/retry?resetFailureCount=true"
----
@@ -724,7 +724,7 @@ Use when status is `CRASHED` and the executor supports checkpoint resume.
[source,bash]
----
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/resume"
----
diff --git a/manual/src/main/asciidoc/security.adoc b/manual/src/main/asciidoc/security.adoc
index acfdd1b1f..fb14fbec1 100644
--- a/manual/src/main/asciidoc/security.adoc
+++ b/manual/src/main/asciidoc/security.adoc
@@ -31,12 +31,14 @@ Unomi 3.1 authenticates every request so the server can resolve a **tenant** (or
| Server-side integrations, admin UIs
| Tenant administration (`/cxs/tenants`)
-| JAAS system administrator (for example `karaf:karaf`)
+| JAAS system administrator (for example `karaf:$UNOMI_ROOT_PASSWORD`)
| Create tenants, rotate keys
|===
Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows public endpoints without API keys while migrating 2.x clients.
+Public context callers must present the profile cookie as the profile bearer (body `profileId` is ignored for public callers). See <<_client_facing_hardening_3_1,client-facing hardening>> and <<_how_profile_tracking_works,How profile tracking works>>.
+
===== Auth resolution sequence
[plantuml]
diff --git a/manual/src/main/asciidoc/shell-commands.adoc b/manual/src/main/asciidoc/shell-commands.adoc
index 19a09dca6..2a154d5d0 100644
--- a/manual/src/main/asciidoc/shell-commands.adoc
+++ b/manual/src/main/asciidoc/shell-commands.adoc
@@ -26,7 +26,7 @@ You can connect to the Apache Karaf SSH Shell using the following command:
ssh -p 8102 karaf@localhost
-The default username/password is karaf/karaf. You should change this as soon as possible by editing the `etc/users.properties` file.
+Authenticate with the `karaf` user and the password from `UNOMI_ROOT_PASSWORD` (Unomi does not ship a known default password). Set the password before start; see <<_getting_started,Getting started>> and <<_client_facing_hardening_3_1,client-facing hardening>>.
Once connected you can simply type in :
diff --git a/manual/src/main/asciidoc/tutorial.adoc b/manual/src/main/asciidoc/tutorial.adoc
index f381ce6d1..0d81767c3 100644
--- a/manual/src/main/asciidoc/tutorial.adoc
+++ b/manual/src/main/asciidoc/tutorial.adoc
@@ -115,7 +115,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \
}'
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). The default `karaf:karaf` credentials should be changed as soon as possible by modifying the `etc/users.properties` file.
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). Unomi ships no default administrator password: set `UNOMI_ROOT_PASSWORD` (and `UNOMI_HEALTHCHECK_PASSWORD`) before starting the server, or it will refuse to start.
==== Using tracker in your own JavaScript projects
@@ -210,7 +210,7 @@ Another (powerful) way to look at events is to use the SSH Console. You can conn
ssh -p 8102 karaf@localhost
----
-Using the same username password (karaf:karaf) and then you can use command such as :
+Using the same credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) and then you can use command such as :
- `event-tail` to view in realtime the events as they come in (CTRL+C to stop)
- `event-list` to view the latest events
diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc
index 4b34f3f90..d49febed1 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -21,9 +21,17 @@ Apache Unomi 3.1 builds on the 3.0 platform (Elasticsearch 9 client, Karaf 4.4,
Complete tenant isolation for profiles, events, segments, rules, and schemas. Public endpoints (for example `/cxs/context.json`) require a tenant public API key; administrative work uses tenant private keys or system administrator credentials.
* Operator guide: <<_multitenancy,Multi-tenancy>>
-* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>>
+* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes <<_client_facing_hardening_3_1,client-facing hardening>>)
* Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> (`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`)
+==== Security hardening (credentials, profile binding, privileged APIs)
+
+Unomi 3.1 requires explicit admin and health-check passwords at startup, treats the profile cookie as the only public profile bearer on `/context.json` and `/eventcollector`, defaults the profile cookie to `HttpOnly`, restricts Groovy Actions and Router import/export REST to system administrator, and gates cross-profile merge / `updateProperties` / `systemProperties` writes to trusted callers.
+
+* Details and migration table: <<_client_facing_hardening_3_1,Client-facing hardening>>
+* Profile tracking contract: <<_how_profile_tracking_works,How profile tracking works>>
+* Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]
+
==== Cluster-aware task scheduler
Built-in background job scheduler with persistence, cluster locks, recovery, REST API (`/cxs/tasks`), and Karaf shell commands.
diff --git a/samples/login-integration/src/main/webapp/WEB-INF/web.xml b/samples/login-integration/src/main/webapp/WEB-INF/web.xml
deleted file mode 100644
index dc145f299..000000000
--- a/samples/login-integration/src/main/webapp/WEB-INF/web.xml
+++ /dev/null
@@ -1,24 +0,0 @@
-<!--
- ~ Licensed to the Apache Software Foundation (ASF) under one or more
- ~ contributor license agreements. See the NOTICE file distributed with
- ~ this work for additional information regarding copyright ownership.
- ~ The ASF licenses this file to You under the Apache License, Version 2.0
- ~ (the "License"); you may not use this file except in compliance with
- ~ the License. You may obtain a copy of the License at
- ~
- ~ http://www.apache.org/licenses/LICENSE-2.0
- ~
- ~ Unless required by applicable law or agreed to in writing, software
- ~ distributed under the License is distributed on an "AS IS" BASIS,
- ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- ~ See the License for the specific language governing permissions and
- ~ limitations under the License.
- -->
-<web-app xmlns="http://java.sun.com/xml/ns/javaee"
- xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
- version="3.0">
- <welcome-file-list>
- <welcome-file>index.html</welcome-file>
- </welcome-file-list>
-</web-app>
\ No newline at end of file
diff --git a/samples/login-integration/src/main/webapp/index.html b/samples/login-integration/src/main/webapp/index.html
deleted file mode 100644
index 8b03cbded..000000000
--- a/samples/login-integration/src/main/webapp/index.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<!--
-~ Licensed to the Apache Software Foundation (ASF) under one or more
-~ contributor license agreements. See the NOTICE file distributed with
-~ this work for additional information regarding copyright ownership.
-~ The ASF licenses this file to You under the Apache License, Version 2.0
-~ (the "License"); you may not use this file except in compliance with
-~ the License. You may obtain a copy of the License at
-~
-~ http://www.apache.org/licenses/LICENSE-2.0
-~
-~ Unless required by applicable law or agreed to in writing, software
-~ distributed under the License is distributed on an "AS IS" BASIS,
-~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-~ See the License for the specific language governing permissions and
-~ limitations under the License.
--->
-<html>
-<head>
- <meta charset="utf-8">
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>Login integration example</title>
- <!-- Latest compiled and minified CSS -->
- <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
- integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous">
- <!-- Optional theme -->
- <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap-theme.min.css"
- integrity="sha384-rHyoN1iRsVXV4nD0JutlnGaslCJuC7uwjduW9SVrLvRYooPp2bWYgmgJQIXwl/Sp" crossorigin="anonymous">
- <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
- <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script>
- <!-- Latest compiled and minified JavaScript -->
- <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/js/bootstrap.min.js"
- integrity="sha384-Tc5IQib027qvyjSMfHjOMaLkfuWVxZxUPnCJA7l2mCWNIpG9mGCD8wGNIcPD7Txa"
- crossorigin="anonymous"></script>
- <script src="javascript/login-example.js"></script>
-</head>
-<body>
-<div class="container">
- <h1>Login integration example</h1>
- <p>This is a small example of integrating Apache Unomi with an event login in order to merge profile based on emails
- as merge keys (see associated rule file in src/main/resources/META-INF/cxs/rules/exampleLogin.json).</p>
- <p>Important: note that login events should normally always be sent from the server performing the login, not through
- Javascript for security reasons. Here we provide this type of example only for brievety and clarity.</p>
- <div id="alert_placeholder"></div>
- <form id="loginForm">
- <div class="form-group">
- <label for="firstname">First name</label>
- <input type="text" name="firstName" id="firstname" class="form-control"
- placeholder="Enter your first name here"/>
- </div>
- <div class="form-group">
- <label for="lastname">Last name</label>
- <input type="text" name="lastName" id="lastname" class="form-control"
- placeholder="Enter your last name here"/>
- </div>
- <div class="form-group">
- <label for="email">Email</label>
- <input type="text" name="email" id="email" class="form-control" placeholder="Enter your email here"
- >
- </div>
- <div class="form-group">
- <label for="email">Password</label>
- <input type="password" name="password" id="password" class="form-control"
- placeholder="Enter your password here">
- </div>
- <button id="loginButton" type="submit" class="btn btn-default">Login</button>
- </form>
-</div>
-</body>
-</html>
diff --git a/samples/login-integration/src/main/webapp/javascript/login-example.js b/samples/login-integration/src/main/webapp/javascript/login-example.js
deleted file mode 100644
index 2704ac853..000000000
--- a/samples/login-integration/src/main/webapp/javascript/login-example.js
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements. See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License. You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-(function () {
-
- // We use this method to generate unique sessions IDs
- function generateGuid() {
- function s4() {
- var array = new Uint16Array(1);
- window.crypto.getRandomValues(array);
- return array[0].toString(16).padStart(4, '0');
- }
-
- return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
- s4() + '-' + s4() + s4() + s4();
- }
-
- // -- COOKIE HELPER METHODS ---
-
- function createCookie(name, value, days) {
- var expires;
-
- if (days) {
- var date = new Date();
- date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
- expires = "; expires=" + date.toGMTString();
- } else {
- expires = "";
- }
- document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + expires + "; path=/";
- }
-
- function readCookie(name) {
- var nameEQ = encodeURIComponent(name) + "=";
- var ca = document.cookie.split(';');
- for (var i = 0; i < ca.length; i++) {
- var c = ca[i];
- while (c.charAt(0) === ' ') c = c.substring(1, c.length);
- if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length));
- }
- return null;
- }
-
- function eraseCookie(name) {
- createCookie(name, "", -1);
- }
-
- // -- BOOTSTRAP HELPER METHODS ---
-
- bootstrapAlert = {};
- bootstrapAlert.success = function (message) {
- $('#alert_placeholder').html('<div class="alert alert-success"><a class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>')
- };
- bootstrapAlert.danger = function (message) {
- $('#alert_placeholder').html('<div class="alert alert-danger"><a class="close" data-dismiss="alert">×</a><span>' + message + '</span></div>')
- };
-
- $(document).ready(function () {
-
- // first we check if we have an existing session ID cookie, if not we generate a new session identifier and
- // store it in the cookie.
- var unomiSessionId = readCookie('unomi-session-id');
- if (!unomiSessionId) {
- unomiSessionId = generateGuid();
- console.log("No existing session cookie found, creating a new one with value " + unomiSessionId);
- createCookie('unomi-session-id', unomiSessionId, 1);
- }
- console.log("Setting up form listener...");
- $("#loginForm").submit(function (event) {
- var email = $('#email').val();
- var firstName = $('#firstname').val();
- var lastName = $('#lastname').val();
- var password = $('#password').val();
- if (password != 'test1234') {
- bootstrapAlert.danger("Wrong password (default is : test1234)");
- event.preventDefault();
- return false;
- }
- var contextRequest = {
- source: { // the source is required for the request to be process properly
- itemId: location.pathname,
- itemType: 'webpage',
- scope: 'test' // the scope is used to regroup events and sessions into sub-groups (eg sites)
- },
- events: [{ // here we provide a simple login event, but as this is actually an array we could provide other events at the same time (page view, clicks, mouse movements, ...)
- eventType: "login",
- properties: {},
- target: {
- itemId: email,
- itemType: "exampleUser",
- properties: {
- preferredLanguage: "en",
- email: email,
- firstName: firstName,
- lastName: lastName
- }
- }
- }],
- requiredProfileProperties: ['*'], // this tells Unomi to send us back all the profile properties (by default none are returned)
- requiredSessionProperties: ['*'] // this tells Unomi to send us back all the session properties (by default none are returned)
- };
- // now let's perform the actual call to Apache Unomi, asking it to process the events and give us back the updated (or created) profile.
- // as we have a rule listening to a login event, it will be executed and its actions will be processed.
- $.ajax({
- url: "http://localhost:8181/cxs/context.json?sessionId=" + unomiSessionId,
- type: 'POST',
- data: JSON.stringify(contextRequest), // make sure you sent JSON and not form-encoded, otherwise Unomi will generate an error
- contentType: 'application/json; charset=utf-8',
- dataType: 'json',
- async: false,
- headers : {
- 'X-Unomi-Api-Key' : '670c26d1cc413346c3b2fd9ce65dab41' // this is configured in the etc/org.apache.unomi.thirdparty.cfg
- },
- success: function (data) {
- console.log("Unomi response:", data);
- bootstrapAlert.success("Successfully sent login event to Apache Unomi ! (profileId=" + data.profileId + ",properties.email=" + data.profileProperties.email + ",nbOfVisits=" + data.profileProperties.nbOfVisits + ")");
- }
- });
- event.preventDefault();
- return false;
- });
- });
-
-})();
-