This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch UNOMI-974-explicit-admin-password in repository https://gitbox.apache.org/repos/asf/unomi.git
commit a7b2488ed39842d7511442f4309ca8709dc57893 Author: Serge Huber <[email protected]> AuthorDate: Thu Aug 13 14:43:46 2026 +0200 UNOMI-974: require an explicit admin and health-check password at startup The shipped karaf and health-check accounts fell back to a value carried in the distribution when the operator set nothing. Both now resolve only from UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD, with no fallback. Removing the fallback is necessary but not sufficient, because of how Karaf resolves properties: PropertiesLoader and PropertiesLoginModule both substitute with defaultsToEmptyString=true, so an unset property yields an empty value rather than an unusable account. The account must therefore be made unusable deliberately. bin/setenv and the Docker entrypoint refuse to start when either variable is unset, and AuthenticationFilter requires a non-blank password on any Basic credential it accepts. That last check runs at each point a Basic credential is consumed rather than once at the top of filter(). The public paths and every V2 path ignore Authorization entirely, so a single up-front check would turn a stray or stale header into a 401 on requests that must succeed anonymously. The shell guards cannot cover every way the JVM is started - karaf.bat calls setenv.bat without testing errorlevel, as setenv itself documents, and a systemd unit or container command override skips them too - so the REST-layer requirement is what actually holds. The guards are executed by the tests rather than grepped, because a check whose text is present but whose condition never matches would otherwise pass silently. Documentation, examples, compose files and the setup scripts no longer carry a sample password. Operators upgrading must set both variables before starting; the 3.0-to-3.1 migration guide covers it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> --- clear-elasticsearch.sh | 2 + clear-opensearch.sh | 2 + docker/README.md | 18 +- docker/src/main/docker/docker-compose-build-es.yml | 2 + docker/src/main/docker/docker-compose-build-os.yml | 2 + docker/src/main/docker/docker-compose-cluster.yml | 4 + docker/src/main/docker/docker-compose-es.yml | 3 + docker/src/main/docker/docker-compose-os.yml | 3 + docker/src/main/docker/entrypoint.sh | 41 ++ .../test/java/org/apache/unomi/itests/BaseIT.java | 6 + .../test/java/org/apache/unomi/itests/BasicIT.java | 29 +- .../org/apache/unomi/itests/HealthCheckIT.java | 11 +- .../java/org/apache/unomi/itests/TenantIT.java | 8 +- .../apache/unomi/itests/V2CompatibilityModeIT.java | 4 +- .../apache/unomi/itests/graphql/BaseGraphQLIT.java | 2 +- itests/src/test/resources/etc/users.properties | 4 +- manual/src/main/asciidoc/5-min-quickstart.adoc | 38 +- 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/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/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 +- package/src/main/resources/bin/setenv | 88 +++++ package/src/main/resources/bin/setenv.bat | 61 +++ .../main/resources/etc/custom.system.properties | 8 +- package/src/main/resources/etc/users.properties | 6 +- .../rest/authentication/AuthenticationFilter.java | 76 +++- .../AuthenticationFilterBlankPasswordTest.java | 243 ++++++++++++ .../config/ShippedAdminPasswordConfigTest.java | 418 +++++++++++++++++++++ setup-elasticsearch.sh | 22 +- setup-opensearch.sh | 26 +- setup-utils.sh | 27 ++ 41 files changed, 1299 insertions(+), 144 deletions(-) diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh index 0d92e9da2..baa760647 100755 --- a/clear-elasticsearch.sh +++ b/clear-elasticsearch.sh @@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-elasticsearch.sh / setup-opensearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/clear-opensearch.sh b/clear-opensearch.sh index 61b6c5a12..df4c9425b 100755 --- a/clear-opensearch.sh +++ b/clear-opensearch.sh @@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-opensearch.sh / setup-elasticsearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/docker/README.md b/docker/README.md index c5ab6553d..a5dc49bde 100644 --- a/docker/README.md +++ b/docker/README.md @@ -30,9 +30,13 @@ required Unomi tarball. ## Launching docker-compose using Maven project -Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose: +Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose. + +Set admin and health passwords first (required; no known defaults are shipped): ``` +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' mvn docker:start ``` @@ -72,6 +76,8 @@ For Unomi (with ElasticSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -81,6 +87,8 @@ For Unomi (with OpenSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -93,6 +101,8 @@ For ElasticSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -101,6 +111,8 @@ For OpenSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s ## Environment Variables ### Common Variables +- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default +- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no known default - `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`) - `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`) @@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints such as `/cxs/context.js ```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","properties":{"name":"Default Tenant"}}' ``` diff --git a/docker/src/main/docker/docker-compose-build-es.yml b/docker/src/main/docker/docker-compose-build-es.yml index 03f265a7b..34193ad30 100644 --- a/docker/src/main/docker/docker-compose-build-es.yml +++ b/docker/src/main/docker/docker-compose-build-es.yml @@ -39,6 +39,8 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-build-os.yml b/docker/src/main/docker/docker-compose-build-os.yml index 97d38cfe4..f5c8592d5 100644 --- a/docker/src/main/docker/docker-compose-build-os.yml +++ b/docker/src/main/docker/docker-compose-build-os.yml @@ -100,6 +100,8 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-cluster.yml b/docker/src/main/docker/docker-compose-cluster.yml index 75aed4628..fa1a3069e 100644 --- a/docker/src/main/docker/docker-compose-cluster.yml +++ b/docker/src/main/docker/docker-compose-cluster.yml @@ -36,6 +36,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-1 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -58,6 +60,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-2 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8182:8181 - 9444:9443 diff --git a/docker/src/main/docker/docker-compose-es.yml b/docker/src/main/docker/docker-compose-es.yml index edfada591..a3d294672 100644 --- a/docker/src/main/docker/docker-compose-es.yml +++ b/docker/src/main/docker/docker-compose-es.yml @@ -45,6 +45,9 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-os.yml b/docker/src/main/docker/docker-compose-os.yml index 579d3e2bc..a37babf5b 100644 --- a/docker/src/main/docker/docker-compose-os.yml +++ b/docker/src/main/docker/docker-compose-os.yml @@ -84,6 +84,9 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/entrypoint.sh b/docker/src/main/docker/entrypoint.sh index 1cfc68456..e805537b4 100755 --- a/docker/src/main/docker/entrypoint.sh +++ b/docker/src/main/docker/entrypoint.sh @@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${ echo "KARAF_OPTS: $KARAF_OPTS" +# Refuse to start without admin/health passwords. An unset password is not "no account": it +# expands to the empty string, which Karaf's PropertiesLoginModule accepts as a valid password. +# This is the gate for container launches: exiting here means the container fails to start rather +# than booting with an administrator account that accepts an empty password. +check_required_password() { + # $1 env var name, $2 property name, $3 skip flag name + eval _value=\"\${$1}\" + eval _skip=\"\${$3}\" + + [ -n "${_value}" ] && return 0 + + if [ "${_skip}" = "true" ]; then + cat >&2 <<EOF + +WARNING: $3=true but $1 is empty. + Unless $2 is supplied another way, the + account will have an EMPTY password that grants full administrator access. + +EOF + return 0 + fi + + cat >&2 <<EOF +ERROR: $1 is not set. + +Apache Unomi does not ship a known default password, and an unset value becomes an EMPTY +password that still authenticates. Pass it when starting the container, for example: + + docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \\ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ... + +Or with docker compose, export both UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD first. +EOF + return 1 +} + +check_required_password UNOMI_ROOT_PASSWORD \ + org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1 +check_required_password UNOMI_HEALTHCHECK_PASSWORD \ + org.apache.unomi.healthcheck.password UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1 + # Function to check cluster health for a specific node check_node_health() { local node_url="$1" diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index 5ec6385ba..1940d3c79 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -132,7 +132,10 @@ public abstract class BaseIT extends KarafTestSupport { protected static final ContentType JSON_CONTENT_TYPE = ContentType.create("application/json"); protected static final String BASE_URL = "http://localhost"; protected static final String BASIC_AUTH_USER_NAME = "karaf"; + /** Explicit IT password — package no longer ships a known default ({@code UNOMI_ROOT_PASSWORD}). */ protected static final String BASIC_AUTH_PASSWORD = "karaf"; + protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; + protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final int REQUEST_TIMEOUT = 60000; protected static final int DEFAULT_TRYING_TIMEOUT = 1000; protected static final int DEFAULT_TRYING_TRIES = 10; @@ -671,6 +674,9 @@ public abstract class BaseIT extends KarafTestSupport { editConfigurationFilePut("etc/system.properties", SEARCH_ENGINE_PROPERTY, searchEngine), editConfigurationFilePut("etc/system.properties", PERSISTENCE_PROVIDER_PROPERTY, searchEngine), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.migration.tenant.id", TEST_TENANT_ID), + // Explicit test credentials (package no longer ships a known default password). + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD), + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD), // Default scheduler.thread.poolSize (5) is sized for the near-instant in-memory unit-test // double, not a real ES/OS backend. Under real refresh/write latency, the checker, task // executions, and lease-renewal heartbeats (see scheduler.adoc) compete for the same small diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java index bd650359b..ff4e12f06 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java @@ -38,6 +38,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.ops4j.pax.exam.junit.PaxExam; + +import java.util.Base64; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; import org.slf4j.Logger; @@ -248,15 +250,36 @@ public class BasicIT extends BaseIT { loginEventPropertiesVisitor2.put(LAST_NAME, LAST_NAME_VISITOR_2); loginEventPropertiesVisitor2.put(EMAIL, EMAIL_VISITOR_2); - // Create login event with VISITOR_2 ContextRequest contextRequestLoginVisitor2 = getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor2, EMAIL_VISITOR_2, SESSION_ID_4); + + // Public API key must not switch identity / merge into another profile on a shared cookie. + HttpPost publicSwitchAttempt = new HttpPost(getFullUrl("/cxs/context.json")); + publicSwitchAttempt.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); + publicSwitchAttempt.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + publicSwitchAttempt.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), + ContentType.create("application/json"))); + TestUtils.RequestResponse publicSwitchResponse = executeContextJSONRequest(publicSwitchAttempt, SESSION_ID_4); + Assert.assertEquals("Public login must not switch away from the cookie profile", + profileIdVisitor1, publicSwitchResponse.getContextResponse().getProfileId()); + + // Public login still runs copyProperties on the cookie profile; restore visitor1 before the trusted switch. + Profile restoredVisitor1 = profileService.load(profileIdVisitor1); + restoredVisitor1.setProperty(FIRST_NAME, FIRST_NAME_VISITOR_1); + restoredVisitor1.setProperty(LAST_NAME, LAST_NAME_VISITOR_1); + restoredVisitor1.setProperty(EMAIL, EMAIL_VISITOR_1); + profileService.save(restoredVisitor1); + keepTrying("Visitor1 properties not restored", () -> profileService.load(profileIdVisitor1), + p -> FIRST_NAME_VISITOR_1.equals(p.getProperty(FIRST_NAME)), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Trusted private key may switch the browsing profile to VISITOR_2. HttpPost requestLoginVisitor2 = new HttpPost(getFullUrl("/cxs/context.json")); requestLoginVisitor2.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); - requestLoginVisitor2.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + requestLoginVisitor2.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString( + (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes())); requestLoginVisitor2.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), ContentType.create("application/json"))); - TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4); + TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4, -1, false); // We should have a new profile id so the session should have been moved from VISITOR_1 to VISITOR_2 String profileIdVisitor2 = requestResponseLoginVisitor2.getContextResponse().getProfileId(); Assert.assertNotEquals("Context profile id should not be the same", profileIdVisitor1, diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java index 305c2f38e..f7179a7f4 100644 --- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java @@ -54,8 +54,6 @@ public class HealthCheckIT extends BaseIT { private final static Logger LOGGER = LoggerFactory.getLogger(HealthCheckIT.class); - protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; - protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final String HEALTHCHECK_ENDPOINT = "/health/check"; @Test @@ -132,6 +130,15 @@ public class HealthCheckIT extends BaseIT { } } + @Test + public void testHealthCheck_wrongPasswordRejected() throws Exception { + final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT)); + try (CloseableHttpResponse response = executeHttpRequest( + httpGet, AuthType.CUSTOM_BASIC, HEALTHCHECK_AUTH_USER_NAME, "wrong-password")) { + Assert.assertEquals(401, response.getStatusLine().getStatusCode()); + } + } + protected <T> T get(final String url, TypeReference<T> typeReference) { CloseableHttpResponse response = null; try { diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java index a9ef9a595..9cce84707 100644 --- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java @@ -190,7 +190,7 @@ public class TenantIT extends BaseIT { // Create test tenant for API key tests BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build()) { Map<String, Object> requestBody = new HashMap<>(); @@ -273,7 +273,7 @@ public class TenantIT extends BaseIT { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying X-Unomi-Api-Key from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) { Assert.assertEquals("JAAS auth should grant access to public endpoints", 200, response.getStatusLine().getStatusCode()); @@ -325,7 +325,7 @@ public class TenantIT extends BaseIT { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying Authorization from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/cxs/profiles/count")))) { Assert.assertEquals("JAAS auth should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); @@ -378,7 +378,7 @@ public class TenantIT extends BaseIT { // Test with JAAS authentication (should succeed) getRequest = new HttpGet(getFullUrl("/cxs/profiles/count")); - getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(("karaf:karaf").getBytes())); + getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((BASIC_AUTH_USER_NAME + ":" + BASIC_AUTH_PASSWORD).getBytes())); try (CloseableHttpResponse response = executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) { Assert.assertEquals("JAAS authentication should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); } diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java index cce6c5484..f27089368 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java @@ -225,7 +225,7 @@ public class V2CompatibilityModeIT extends BaseIT { request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) @@ -283,7 +283,7 @@ public class V2CompatibilityModeIT extends BaseIT { HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + TEST_PROFILE_ID)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java index 1ff220109..34db0381e 100644 --- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java @@ -72,7 +72,7 @@ public abstract class BaseGraphQLIT extends BaseIT { } /** - * Performs a GraphQL POST request with JAAS admin authentication (karaf:karaf). + * Performs a GraphQL POST request with JAAS admin authentication ({@link #BASIC_AUTH_USER_NAME}/{@link #BASIC_AUTH_PASSWORD}). * This is equivalent to AuthType.JAAS_ADMIN. * * @param resource The resource path to the GraphQL query/mutation diff --git a/itests/src/test/resources/etc/users.properties b/itests/src/test/resources/etc/users.properties index 377fe6a16..538906c38 100644 --- a/itests/src/test/resources/etc/users.properties +++ b/itests/src/test/resources/etc/users.properties @@ -29,6 +29,6 @@ # and modifiable via the JAAS command group. These users reside in a JAAS domain # with the name "karaf". # -karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup -health = ${org.apache.unomi.healthcheck.password:-health},health +karaf = ${org.apache.unomi.security.root.password},_g_:admingroup +health = ${org.apache.unomi.healthcheck.password},health _g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_USER,ROLE_UNOMI_TENANT_ADMIN diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc index c42a9404a..9966b275a 100644 --- a/manual/src/main/asciidoc/5-min-quickstart.adoc +++ b/manual/src/main/asciidoc/5-min-quickstart.adoc @@ -19,6 +19,16 @@ Begin by creating a `docker-compose.yml` file. You can choose between Elasticsea ==== Option 1: Using Elasticsearch +Export the passwords first. The compose files read them from your shell, and the `curl` commands +further down use the same variables, so setting them once here keeps both consistent. Unomi refuses +to start if they are unset. + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + [source,yaml] ---- version: '3.8' @@ -35,6 +45,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=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -84,6 +96,8 @@ services: - UNOMI_OPENSEARCH_SSL_ENABLE=true - UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -104,7 +118,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 +128,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 +173,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 +195,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 +212,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/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 '[email protected]' ---- +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/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/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/package/src/main/resources/bin/setenv b/package/src/main/resources/bin/setenv index e48561de4..66fa7157d 100755 --- a/package/src/main/resources/bin/setenv +++ b/package/src/main/resources/bin/setenv @@ -50,6 +50,94 @@ MY_DIRNAME=`dirname $0` MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd` +# Fail fast when starting without admin/health passwords (no known defaults are shipped). +# +# An unset password does NOT resolve to "no account": ${env:...} with no value expands to the +# empty string, which Karaf's PropertiesLoginModule accepts as a valid (empty) password for the +# admin account. So "unset" and "blank" are the same failure, and both must be refused. +# +# This script is the gate for shell-launched Karaf; the Docker entrypoint covers containers. Any +# launcher that starts the JVM directly without sourcing this file (systemd units, Kubernetes +# command overrides, PaxExam) bypasses the check, so those deployments must set the passwords +# themselves. +_unomi_etc="${KARAF_ETC:-${MY_KARAF_HOME}/etc}" + +# Print the configured value of a property, searching custom.system.properties and the optional +# overlay it includes. Last assignment wins, comments ignored. Empty output means "not configured". +_unomi_configured_property() { + for _unomi_file in "${_unomi_etc}/custom.system.properties" "${_unomi_etc}/unomi.custom.system.properties"; do + [ -f "${_unomi_file}" ] || continue + sed -n "s|^[[:space:]]*$1[[:space:]]*=[[:space:]]*\(.*\)$|\1|p" "${_unomi_file}" + done | sed -e 's/[[:space:]]*$//' | grep -v '^$' | tail -n 1 +} + +# _unomi_require_password ENV_VAR_NAME PROPERTY_NAME SKIP_FLAG_NAME +# Returns 0 when a non-blank password is available, 1 when startup must be refused. +_unomi_require_password() { + eval _unomi_value=\"\${$1}\" + eval _unomi_skip=\"\${$3}\" + + [ -n "${_unomi_value}" ] && return 0 + + _unomi_configured=`_unomi_configured_property "$2"` + case "${_unomi_configured}" in + # An unresolved ${env:...} placeholder is not a configured password — it expands to empty. + *'${'*) _unomi_configured='' ;; + esac + [ -n "${_unomi_configured}" ] && return 0 + + if [ "${_unomi_skip}" = "true" ]; then + cat >&2 <<EOF + +WARNING: $3=true, but neither $1 nor + $2 is set to a non-empty value. + If nothing else supplies this password, the account will be created with an EMPTY + password that authenticates successfully, granting full administrator access. + +EOF + return 0 + fi + + cat >&2 <<EOF +ERROR: $1 is not set. + +Apache Unomi does not ship a known default password, and an unset value becomes an EMPTY +password that still authenticates. Set one before starting, for example: + + export UNOMI_ROOT_PASSWORD='choose-a-strong-password' + export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' + ./bin/karaf + +Or set $2 in etc/custom.system.properties +(then $3=true only suppresses this early check). + +See getting started / configuration documentation for details. +EOF + return 1 +} + +# Only guard the scripts that actually start the server. bin/stop, bin/status, bin/client and +# bin/shell set KARAF_SCRIPT to their own name, so the outer case already excludes them. +# +# "bin/karaf stop" and "bin/karaf status" reuse KARAF_SCRIPT=karaf, so skip on the subcommand too: +# those are the only two that replace the main class (with Main.Stop / Main.Status) instead of +# booting the server. Everything else - including "karaf client", "karaf shell" and any unknown +# argument - falls through to org.apache.karaf.main.Main and starts a full server, so it must be +# guarded. +case "${KARAF_SCRIPT}" in + karaf|start|server) + case "$1" in + stop|status) ;; + *) + _unomi_require_password UNOMI_ROOT_PASSWORD \ + org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1 + _unomi_require_password UNOMI_HEALTHCHECK_PASSWORD \ + org.apache.unomi.healthcheck.password UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1 + ;; + esac + ;; +esac + # In order to activate the Yourkit Profiler agent, uncomment one of the following lines depending on the operating # system and adjust the path to the location of the installation directory. # On MacOS: diff --git a/package/src/main/resources/bin/setenv.bat b/package/src/main/resources/bin/setenv.bat index f5db6f80b..5333a1090 100644 --- a/package/src/main/resources/bin/setenv.bat +++ b/package/src/main/resources/bin/setenv.bat @@ -63,3 +63,64 @@ rem SET KARAF_DEBUG set MY_DIRNAME=%~dp0% set MY_KARAF_HOME=%DIRNAME%.. + +rem Warn early when starting without admin/health passwords (no known defaults are shipped). +rem +rem An unset password does NOT mean "no account": it expands to the empty string, which Karaf's +rem PropertiesLoginModule accepts as a valid password for the admin account. +rem +rem KNOWN LIMITATION: karaf.bat invokes this file with "call" and does not test errorlevel +rem afterwards, so the "exit /b 1" below returns from this script but does NOT stop the launcher. +rem The error message is printed and startup continues. Windows operators must therefore act on the +rem message; there is no way to fail closed from here without also killing the operator's console +rem (plain "exit 1" would terminate the whole cmd.exe session, including an interactive prompt). +rem +rem karaf.bat sets KARAF_SCRIPT with the quotes included in the value (SET KARAF_SCRIPT="karaf.bat"), +rem so strip them before comparing - otherwise every comparison below silently fails to match. +set _UNOMI_KARAF_SCRIPT=%KARAF_SCRIPT:"=% +if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf.bat" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="start.bat" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="start" goto checkPasswords +goto afterPasswordChecks + +:checkPasswords +if not "%UNOMI_ROOT_PASSWORD%"=="" goto afterRootPasswordCheck +if /I "%UNOMI_SKIP_ROOT_PASSWORD_CHECK%"=="true" goto afterRootPasswordCheck +echo ERROR: UNOMI_ROOT_PASSWORD is not set. +echo. +echo Apache Unomi does not ship a known default admin password, and an unset value becomes +echo an EMPTY password that still authenticates. Set one before starting, for example: +echo. +echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password +echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password +echo bin\karaf.bat +echo. +echo Or set org.apache.unomi.security.root.password in etc\custom.system.properties +echo and set UNOMI_SKIP_ROOT_PASSWORD_CHECK=true. +echo. +echo WARNING: startup continues anyway on Windows - see the note at the top of this file. +set _UNOMI_KARAF_SCRIPT= +exit /b 1 + +:afterRootPasswordCheck +if not "%UNOMI_HEALTHCHECK_PASSWORD%"=="" goto afterPasswordChecks +if /I "%UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK%"=="true" goto afterPasswordChecks +echo ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set. +echo. +echo Apache Unomi does not ship a known default health-check password, and an unset value +echo becomes an EMPTY password that still authenticates. Set one before starting, for example: +echo. +echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password +echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password +echo bin\karaf.bat +echo. +echo Or set org.apache.unomi.healthcheck.password in etc\custom.system.properties +echo and set UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true. +echo. +echo WARNING: startup continues anyway on Windows - see the note at the top of this file. +set _UNOMI_KARAF_SCRIPT= +exit /b 1 + +:afterPasswordChecks +set _UNOMI_KARAF_SCRIPT= diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index fa98f7979..658d9caba 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -22,7 +22,13 @@ ${optionals}=unomi.custom.system.properties ####################################################################################################################### ## Security settings ## ####################################################################################################################### -org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD:-karaf} +# Required: set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start +# (bin/setenv and the Docker entrypoint refuse to start if either is unset). +# There are no shipped known defaults. +# Escape hatches: UNOMI_SKIP_ROOT_PASSWORD_CHECK=true / UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true +# if you set the matching property another way. +org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD} +org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD} # These parameters control the list of classes that are allowed or forbidden when executing expressions. org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String} diff --git a/package/src/main/resources/etc/users.properties b/package/src/main/resources/etc/users.properties index bffdc5bf3..c7064f536 100644 --- a/package/src/main/resources/etc/users.properties +++ b/package/src/main/resources/etc/users.properties @@ -29,6 +29,8 @@ # and modifiable via the JAAS command group. These users reside in a JAAS domain # with the name "karaf". # -karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup -health = ${org.apache.unomi.healthcheck.password:-health},health +# Passwords come from org.apache.unomi.security.root.password / healthcheck.password (see custom.system.properties). +# No known defaults are shipped; set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start. +karaf = ${org.apache.unomi.security.root.password},_g_:admingroup +health = ${org.apache.unomi.healthcheck.password},health _g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_ADMIN,ROLE_UNOMI_TENANT_USER diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java index 3c59c1cf6..0e990ffaa 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java @@ -101,17 +101,33 @@ public class AuthenticationFilter implements ContainerRequestFilter { TenantService tenantService, SecurityService securityService, ExecutionContextManager executionContextManager) { + this(restAuthenticationConfig, tenantService, securityService, executionContextManager, buildJaasFilter()); + } + + /** + * Test seam: lets a test supply a stub JAAS filter so it can assert that a credential was + * refused <em>before</em> it reached JAAS. Asserting on the response status alone proves + * nothing here — every refusal path in this class ends in the same 401. + */ + AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig, + TenantService tenantService, + SecurityService securityService, + ExecutionContextManager executionContextManager, + JAASAuthenticationFilter jaasAuthenticationFilter) { this.restAuthenticationConfig = restAuthenticationConfig; this.tenantService = tenantService; this.securityService = securityService; this.executionContextManager = executionContextManager; + this.jaasAuthenticationFilter = jaasAuthenticationFilter; + } - // Build wrapped jaas filter - jaasAuthenticationFilter = new JAASAuthenticationFilter(); - jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER); - jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); - jaasAuthenticationFilter.setContextName(CONTEXT_NAME); - jaasAuthenticationFilter.setRealmName(REALM_NAME); + private static JAASAuthenticationFilter buildJaasFilter() { + JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter(); + jaasFilter.setRoleClassifier(ROLE_CLASSIFIER); + jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); + jaasFilter.setContextName(CONTEXT_NAME); + jaasFilter.setRealmName(REALM_NAME); + return jaasFilter; } @Override @@ -133,6 +149,9 @@ public class AuthenticationFilter implements ContainerRequestFilter { unauthorized(requestContext); return; } + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); @@ -192,6 +211,9 @@ public class AuthenticationFilter implements ContainerRequestFilter { // For all other cases, try tenant private key first, then fall back to JAAS String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } // Try tenant private key authentication first String[] credentials = extractBasicAuthCredentials(authHeader); if (credentials != null && credentials.length == 2) { @@ -299,6 +321,9 @@ public class AuthenticationFilter implements ContainerRequestFilter { // For private endpoints, require system administrator authentication (like V2) String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); // JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally). @@ -352,6 +377,45 @@ public class AuthenticationFilter implements ContainerRequestFilter { unauthorized(requestContext); } + /** + * Rejects the request when the Basic credential about to be used carries an empty password. + * <p> + * Defence in depth against a blank administrator password (UNOMI-972). If + * {@code org.apache.unomi.security.root.password} is unset, Karaf resolves it to the empty + * string and the shipped JAAS account authenticates with an empty password. {@code bin/karaf} + * and the Docker entrypoint refuse to start in that state, but they cannot cover every launcher + * (notably {@code karaf.bat}), so an empty credential is never accepted over REST either. + * <p> + * Deliberately called at each point where a Basic credential is actually consumed rather than + * once at the top of {@link #filter}: anonymous traffic on public paths — and every path in V2 + * compatibility mode — ignores {@code Authorization} entirely, so rejecting up front would turn + * a stray or stale Basic header (a cached browser credential, an injecting proxy) into a 401 on + * a request that is supposed to succeed without authentication. + * + * @return {@code true} when the request has been aborted and the caller must return + */ + private boolean rejectBlankBasicAuthPassword(ContainerRequestContext requestContext, String authHeader) { + if (!hasBlankBasicAuthPassword(authHeader)) { + return false; + } + logger.warn("Rejecting Basic authentication with an empty password"); + unauthorized(requestContext); + return true; + } + + /** + * Whether a Basic {@code Authorization} header carries an empty password. A missing, malformed + * or non-Basic header is not treated as blank here — those are rejected by the normal + * authentication paths instead. + */ + boolean hasBlankBasicAuthPassword(String authHeader) { + if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) { + return false; + } + String[] credentials = extractBasicAuthCredentials(authHeader); + return credentials != null && credentials.length == 2 && credentials[1].isEmpty(); + } + private String[] extractBasicAuthCredentials(String authHeader) { try { String base64Credentials = authHeader.substring(BASIC_AUTH_PREFIX.length()).trim(); diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java new file mode 100644 index 000000000..19d1203fa --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java @@ -0,0 +1,243 @@ +/* + * 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. + */ +package org.apache.unomi.rest.authentication; + +import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.ApiKey; +import org.apache.unomi.api.tenants.TenantService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A blank {@code UNOMI_ROOT_PASSWORD} leaves the shipped JAAS administrator with an empty password + * that {@code PropertiesLoginModule} accepts. {@code bin/karaf} and the Docker entrypoint refuse to + * start in that state, but they cannot cover every launcher, so the REST layer must never accept an + * empty credential either. + * <p> + * Covers both the predicate and its wiring into {@link AuthenticationFilter#filter}: the check is + * applied where a Basic credential is consumed, and must NOT reject anonymous traffic on public + * paths that ignores {@code Authorization} altogether. + */ +class AuthenticationFilterBlankPasswordTest { + + private RestAuthenticationConfig restAuthenticationConfig; + private TenantService tenantService; + private JAASAuthenticationFilter jaasAuthenticationFilter; + private AuthenticationFilter filter; + + @BeforeEach + void setUp() { + restAuthenticationConfig = mock(RestAuthenticationConfig.class); + tenantService = mock(TenantService.class); + // Stubbed so the tests below can assert the credential never reached JAAS. Every refusal + // path in the filter answers 401, so the status alone cannot tell "refused for a blank + // password" apart from "JAAS rejected it" — only this can. + jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class); + filter = new AuthenticationFilter( + restAuthenticationConfig, + tenantService, + mock(SecurityService.class), + mock(ExecutionContextManager.class), + jaasAuthenticationFilter); + } + + @Test + void emptyPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic("karaf:"))); + } + + @Test + void emptyUserAndPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic(":"))); + } + + @Test + void realPasswordIsAccepted() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf:a-strong-password"))); + } + + /** + * A password consisting of spaces is a real (if terrible) password, not the blank-resolution + * failure this guard exists for — leave it to the realm. + */ + @Test + void whitespacePasswordIsNotTreatedAsBlank() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf: "))); + } + + @Test + void missingOrNonBasicHeadersAreLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword(null)); + assertFalse(filter.hasBlankBasicAuthPassword("Bearer some-token")); + } + + @Test + void malformedHeaderIsLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword("Basic not-base64!!")); + // No colon at all: cannot be split into user and password. + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf"))); + } + + // ------------------------------------------------------------------ filter() wiring + + /** + * The predicate being correct is not enough: if the call site were dropped or moved after an + * earlier {@code return}, only this test would notice. + */ + @Test + void filterRejectsBlankPasswordOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control: a non-blank credential on the same path must still be handed to JAAS to judge. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * V2 compatibility mode routes every request through {@link AuthenticationFilter}'s own + * private-endpoint branch, which consumes the Basic credential at a third, separate call site. + * Without this test that call site is unreachable from the suite: the other tests leave + * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting + * the guard there would leave every test green. + */ + @Test + void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control for the V2 branch: a non-blank credential must still reach JAAS there too. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * A public path in V2 compatibility mode authenticates by default tenant, ignoring + * {@code Authorization} entirely — so a stray blank Basic header must not turn it into a 401. + */ + @Test + void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default"); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenant("default"); + } + + private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext requestContext) throws IOException { + ArgumentCaptor<Response> aborted = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(aborted.capture()); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), aborted.getValue().getStatus()); + verify(jaasAuthenticationFilter, never()).filter(any()); + } + + /** + * The check has to run where a Basic credential is consumed, not once at the top of + * {@code filter()}. Anonymous traffic carrying a stray Basic header — a stale cached browser + * credential, an injecting proxy — must still authenticate by API key on the public path. + * <p> + * Asserted by observing that the public-path branch is still reached (the API key is looked up) + * rather than by asserting no abort: a public path whose API key does not resolve legitimately + * falls through and is refused for that unrelated reason, and authenticating one successfully + * needs a live CXF exchange, which is out of scope for a unit test. + */ + @Test + void filterConsultsThePublicPathBranchDespiteAStrayBlankBasicHeader() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + /** A public path with no Authorization header at all must equally reach the API-key lookup. */ + @Test + void filterConsultsThePublicPathBranchForAnonymousRequests() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", null); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + private ContainerRequestContext request(String path, String authHeader) { + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getPath()).thenReturn(path); + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(requestContext.getMethod()).thenReturn("POST"); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authHeader); + return requestContext; + } + + private static String basic(String credentials) { + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java new file mode 100644 index 000000000..639c0096b --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java @@ -0,0 +1,418 @@ +/* + * 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. + */ +package org.apache.unomi.rest.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Ensures the distribution cannot start with a known or blank admin/health password. + * <p> + * The launcher guards are <em>executed</em> here rather than grepped: a check whose text is present + * but whose condition never matches would otherwise pass silently, which is exactly how the Windows + * {@code KARAF_SCRIPT} quoting bug survived review. + */ +class ShippedAdminPasswordConfigTest { + + private static final String ROOT_PASSWORD_PROPERTY = "org.apache.unomi.security.root.password"; + private static final String HEALTHCHECK_PASSWORD_PROPERTY = "org.apache.unomi.healthcheck.password"; + + /** + * The two files this test executes. They double as the fingerprint of the repository root (see + * {@link #locateRepoRoot()}): a directory containing both is the Unomi checkout, not some nested + * copy or a same-named directory further up the filesystem. + */ + private static final String SETENV_PATH = "package/src/main/resources/bin/setenv"; + private static final String ENTRYPOINT_PATH = "docker/src/main/docker/entrypoint.sh"; + + /** First line of the guard to copy out of entrypoint.sh. */ + private static final String ENTRYPOINT_GUARD_START = "check_required_password()"; + /** Last line of the guard; the slice is meaningless unless this is actually reached. */ + private static final String ENTRYPOINT_GUARD_END = "UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1"; + + /** Default stub launcher name, mirroring {@code bin/karaf}. */ + private static final String DEFAULT_KARAF_SCRIPT = "karaf"; + + private static final Path REPO_ROOT = locateRepoRoot(); + + // ---------------------------------------------------------------- shipped configuration + + @Test + void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/etc/users.properties")); + + assertFalse(content.contains(":-karaf"), "users.properties must not default the karaf password to 'karaf'"); + assertFalse(content.contains(":-health"), "users.properties must not default the health password to 'health'"); + assertTrue(content.contains("${" + ROOT_PASSWORD_PROPERTY + "}")); + assertTrue(content.contains("${" + HEALTHCHECK_PASSWORD_PROPERTY + "}")); + } + + @Test + void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() throws Exception { + List<String> securityLines = Files.readAllLines(repoFile("package/src/main/resources/etc/custom.system.properties")) + .stream() + .filter(line -> line.contains(ROOT_PASSWORD_PROPERTY) || line.contains(HEALTHCHECK_PASSWORD_PROPERTY)) + .collect(Collectors.toList()); + + assertFalse(securityLines.isEmpty()); + for (String line : securityLines) { + assertFalse(line.contains(":-karaf"), "root password must not fall back to karaf: " + line); + assertFalse(line.contains(":-health"), "health password must not fall back to health: " + line); + } + } + + + // ---------------------------------------------------------------- bin/setenv, executed + + @Test + void setenv_refusesToStartWhenPasswordsMissing(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(1, missing.exitCode, "setenv must refuse to start without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached"); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The outer {@code case "${KARAF_SCRIPT}"} in bin/setenv lists every shipped script that boots a + * server: {@code bin/karaf}, {@code bin/start} and {@code bin/karaf server} all reach + * {@code org.apache.karaf.main.Main}. Only {@code karaf} used to be exercised here, so narrowing + * that list to a single entry would have gone unnoticed - now each entry has its own test case. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"karaf", "start", "server"}) + void setenv_refusesToStartForEveryServerStartingScript(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(1, missing.exitCode, + "bin/" + karafScript + " starts a server, so it must refuse to run without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached for KARAF_SCRIPT=" + karafScript); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The dedicated {@code bin/stop}, {@code bin/status}, {@code bin/client} and {@code bin/shell} + * scripts set {@code KARAF_SCRIPT} to their own name. None of them starts a server - they talk to + * an already running one - so the outer case must keep excluding them, otherwise stopping an + * instance would require the passwords used to start it. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"stop", "status", "client", "shell"}) + void setenv_doesNotGuardScriptsThatOnlyTalkToARunningServer(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(0, result.exitCode, "bin/" + karafScript + " must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + @Test + void setenv_startsWhenPasswordsProvided(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map<String, String> env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + + Result provided = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("LAUNCHED"), provided.output); + } + + /** + * The escape hatch only suppresses the environment-variable check. Configuring the property + * directly is a supported way to start, and must not be reported as an error. + */ + @Test + void setenv_acceptsPasswordsConfiguredInPropertiesFile(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "configured-root", "configured-health"); + + Result configured = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(0, configured.exitCode, configured.output); + assertTrue(configured.output.contains("LAUNCHED"), configured.output); + } + + /** + * Claiming the password is set elsewhere, while leaving it blank everywhere, is the dangerous + * case: it must warn rather than pass silently. + */ + @Test + void setenv_warnsWhenSkipFlagHidesABlankPassword(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map<String, String> env = new HashMap<>(); + env.put("UNOMI_SKIP_ROOT_PASSWORD_CHECK", "true"); + env.put("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK", "true"); + + Result skipped = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, skipped.exitCode, skipped.output); + assertTrue(skipped.output.contains("WARNING"), "a bypassed check must still warn:\n" + skipped.output); + } + + /** + * {@code bin/karaf stop} and {@code bin/karaf status} keep {@code KARAF_SCRIPT=karaf} but replace + * the main class with {@code Main.Stop} / {@code Main.Status}: no server is started, so no + * password is needed. Requiring one would make an instance impossible to shut down cleanly from a + * shell that no longer has the startup environment. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"stop", "status"}) + void setenv_doesNotBlockSubcommandsThatDoNotStartAServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand)); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(0, result.exitCode, "'karaf " + subcommand + "' must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + /** + * Regression guard for a real bug: the inner skip list once read {@code stop|status|client|shell}, + * which silently disabled the password check for {@code bin/karaf client} and + * {@code bin/karaf shell}. Those subcommands are <em>not</em> the {@code bin/client} / + * {@code bin/shell} remote consoles - the karaf script does not special-case them, so they fall + * through to {@code org.apache.karaf.main.Main} and boot a complete server, admin account + * included. The same is true of any argument the script does not recognise. Only {@code stop} and + * {@code status} may skip the check; everything else here must still be refused. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"client", "shell", "console", "--an-argument-karaf-does-not-know"}) + void setenv_stillGuardsSubcommandsThatBootAFullServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand.replace("-", "_"))); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(1, result.exitCode, + "'karaf " + subcommand + "' starts a full server, so it must refuse to run without passwords:\n" + result.output); + assertFalse(result.output.contains("LAUNCHED"), "the launcher must not be reached for 'karaf " + subcommand + "'"); + assertTrue(result.output.contains("UNOMI_ROOT_PASSWORD is not set"), result.output); + } + + // ---------------------------------------------------------------- docker entrypoint, executed + + @Test + void entrypoint_refusesToStartWhenPasswordsMissing(@TempDir Path workDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path guard = extractEntrypointPasswordGuard(workDir); + + assertEquals(1, runShell(guard, workDir, new HashMap<>()).exitCode, + "the Docker entrypoint must exit non-zero without passwords"); + + Map<String, String> env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + Result provided = runShell(guard, workDir, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("GUARD-PASSED"), provided.output); + } + + // ---------------------------------------------------------------- setenv.bat regression guard + + /** + * {@code karaf.bat} sets {@code KARAF_SCRIPT} with the quotes included in the value + * ({@code SET KARAF_SCRIPT="karaf.bat"}), so comparing {@code "%KARAF_SCRIPT%"} against + * {@code "karaf.bat"} never matches and the whole check is skipped. The quotes must be stripped + * before comparing. This cannot be executed on a POSIX CI machine, so assert the shape instead. + */ + @Test + void setenvBat_stripsQuotesBeforeComparingKarafScript() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/bin/setenv.bat")); + + assertTrue(content.contains("%KARAF_SCRIPT:\"=%"), + "setenv.bat must strip the quotes karaf.bat embeds in KARAF_SCRIPT"); + assertFalse(content.contains("\"%KARAF_SCRIPT%\"==\"karaf.bat\""), + "comparing the raw KARAF_SCRIPT against karaf.bat never matches"); + assertTrue(content.contains("UNOMI_ROOT_PASSWORD") && content.contains("UNOMI_HEALTHCHECK_PASSWORD"), + "setenv.bat must check both passwords"); + } + + // ---------------------------------------------------------------- helpers + + /** + * Finds the repository root by walking up from the working directory until an ancestor holds + * <em>both</em> shipped launcher scripts this test executes. Matching on a single relative path + * would let a nested checkout (or any unrelated directory that happens to contain a + * {@code package/} tree) win, and the test would then silently assert against the wrong sources. + */ + private static Path locateRepoRoot() { + Path start = Paths.get("").toAbsolutePath().normalize(); + for (Path candidate = start; candidate != null; candidate = candidate.getParent()) { + if (Files.isRegularFile(candidate.resolve(SETENV_PATH)) + && Files.isRegularFile(candidate.resolve(ENTRYPOINT_PATH))) { + return candidate; + } + } + throw new IllegalStateException("Could not locate the Unomi repository root from " + start + + " (looked for an ancestor containing both " + SETENV_PATH + " and " + ENTRYPOINT_PATH + ")"); + } + + /** + * Resolves a repository-relative path against the detected repository root, so the test works + * regardless of which module directory the build runs it from. + */ + private static Path repoFile(String relativePath) { + Path resolved = REPO_ROOT.resolve(relativePath); + if (!Files.exists(resolved)) { + throw new IllegalStateException("Could not locate " + relativePath + " under repository root " + REPO_ROOT); + } + return resolved; + } + + private static boolean hasPosixShell() { + return !System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + /** + * Builds a throwaway Karaf layout containing the real {@code bin/setenv} plus a stub launcher + * that mimics how the shipped scripts source it. {@code karafScript} names the stub and the value + * it exports as {@code KARAF_SCRIPT}, so tests can reproduce {@code bin/karaf}, {@code bin/start}, + * {@code bin/stop}, ... rather than only ever exercising {@code karaf}. + */ + private static void installFakeKarafHome(Path karafHome, String karafScript, String rootPassword, String healthPassword) + throws IOException { + Path bin = Files.createDirectories(karafHome.resolve("bin")); + Path etc = Files.createDirectories(karafHome.resolve("etc")); + + Files.copy(repoFile(SETENV_PATH), bin.resolve("setenv")); + Files.write(etc.resolve("custom.system.properties"), + (ROOT_PASSWORD_PROPERTY + "=" + rootPassword + "\n" + + HEALTHCHECK_PASSWORD_PROPERTY + "=" + healthPassword + "\n").getBytes(StandardCharsets.UTF_8)); + + // Mirrors apache-karaf/bin/<script>: export KARAF_SCRIPT, then source setenv at top level. + Path launcher = bin.resolve(karafScript); + Files.write(launcher, ("#!/bin/sh\n" + + "KARAF_SCRIPT=\"" + karafScript + "\"\n" + + "export KARAF_SCRIPT\n" + + ". \"$(dirname \"$0\")/setenv\"\n" + + "echo LAUNCHED\n").getBytes(StandardCharsets.UTF_8)); + launcher.toFile().setExecutable(true); + } + + /** + * Copies the entrypoint's password guard into a standalone script, so the test does not have to + * run the rest of the container bootstrap (which needs a search engine). + */ + private static Path extractEntrypointPasswordGuard(Path workDir) throws IOException { + List<String> lines = Files.readAllLines(repoFile(ENTRYPOINT_PATH)); + StringBuilder guard = new StringBuilder("#!/bin/sh\n"); + boolean capturing = false; + boolean terminated = false; + for (String line : lines) { + if (line.startsWith(ENTRYPOINT_GUARD_START)) { + capturing = true; + } + if (capturing) { + guard.append(line).append('\n'); + } + if (capturing && line.contains(ENTRYPOINT_GUARD_END)) { + terminated = true; + break; + } + } + assertTrue(capturing, "entrypoint.sh must define the guard; start marker '" + ENTRYPOINT_GUARD_START + + "' was not found in " + ENTRYPOINT_PATH); + // Without this, a reformatted entrypoint.sh would make the slice run to end-of-file: the test + // would still "pass" while executing something entirely different from the guard. + assertTrue(terminated, "the guard slice never reached its terminating line '" + ENTRYPOINT_GUARD_END + + "' in " + ENTRYPOINT_PATH + " - update the marker, this test is no longer testing the guard"); + guard.append("echo GUARD-PASSED\n"); + + Path script = workDir.resolve("entrypoint-guard.sh"); + Files.write(script, guard.toString().getBytes(StandardCharsets.UTF_8)); + return script; + } + + private static Result runLauncher(Path karafHome, String karafScript, Map<String, String> env, String... args) throws Exception { + String[] command = new String[args.length + 2]; + command[0] = "/bin/sh"; + command[1] = karafHome.resolve("bin").resolve(karafScript).toString(); + System.arraycopy(args, 0, command, 2, args.length); + return run(new ProcessBuilder(command).directory(karafHome.toFile()), env); + } + + private static Result runShell(Path script, Path workDir, Map<String, String> env) throws Exception { + return run(new ProcessBuilder("/bin/sh", script.toString()).directory(workDir.toFile()), env); + } + + private static Result run(ProcessBuilder builder, Map<String, String> env) throws Exception { + Map<String, String> environment = builder.environment(); + + // Inherit the JVM's environment - bin/setenv shells out to dirname, sed, grep and tail, which + // do not live under /usr/bin:/bin on every platform (NixOS, minimal images), and a hardcoded + // PATH turns that into a confusing failure instead of a working test. + // + // But strip every UNOMI_* variable first: an ambient UNOMI_ROOT_PASSWORD on a developer's + // machine would otherwise satisfy the "missing password" cases and make them pass by accident, + // which is the one property these tests cannot afford to lose. KARAF_* goes too, because + // KARAF_ETC would redirect the script at the developer's own etc/ directory. + List<String> inherited = new ArrayList<>(environment.keySet()); + for (String name : inherited) { + if (name.startsWith("UNOMI_") || name.startsWith("KARAF_")) { + environment.remove(name); + } + } + environment.putIfAbsent("PATH", "/usr/bin:/bin"); + environment.putAll(env); + builder.redirectErrorStream(true); + + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "script did not terminate"); + return new Result(process.exitValue(), output); + } + + private static final class Result { + private final int exitCode; + private final String output; + + private Result(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + } +} diff --git a/setup-elasticsearch.sh b/setup-elasticsearch.sh index 8c103668f..f794801ce 100755 --- a/setup-elasticsearch.sh +++ b/setup-elasticsearch.sh @@ -56,17 +56,15 @@ _setup_elasticsearch() { return 1 fi - # Load only the Elasticsearch password from .env.local if it exists - # This ensures we don't load the OpenSearch password - if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then - # If not found in .env.local, check if it's already set in environment - if [ -z "${UNOMI_ELASTICSEARCH_PASSWORD}" ]; then - echo "Note: UNOMI_ELASTICSEARCH_PASSWORD not found in .env.local or environment" - fi + # Load required passwords from .env.local (or existing environment). + # Engine password is Elasticsearch-only so we do not pull OpenSearch's password. + if ! require_password "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then + return 1 fi - - # Check if password is set - if ! check_password "UNOMI_ELASTICSEARCH_PASSWORD"; then + if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then + return 1 + fi + if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then return 1 fi @@ -76,7 +74,6 @@ _setup_elasticsearch() { export UNOMI_ELASTICSEARCH_USERNAME=elastic export UNOMI_ELASTICSEARCH_SSL_ENABLE=true export UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - # Password is already set from .env.local or environment # Set the distribution to use Elasticsearch (default, but explicit for clarity) export UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch @@ -87,6 +84,9 @@ _setup_elasticsearch() { echo " Username: ${UNOMI_ELASTICSEARCH_USERNAME} (overridden from default: empty)" echo " SSL Enabled: ${UNOMI_ELASTICSEARCH_SSL_ENABLE} (overridden from default: false)" echo " Trust All Certificates: ${UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES} (overridden from default: false)" + echo " Elasticsearch password: (set from .env.local or environment)" + echo " Root password: (set from .env.local or environment)" + echo " Healthcheck password: (set from .env.local or environment)" return 0 } diff --git a/setup-opensearch.sh b/setup-opensearch.sh index 565dcc4e9..995ad1b2a 100755 --- a/setup-opensearch.sh +++ b/setup-opensearch.sh @@ -56,30 +56,26 @@ _setup_opensearch() { return 1 fi - # Load only the OpenSearch password from .env.local if it exists - # This ensures we don't load the Elasticsearch password - if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then - # If not found in .env.local, check if it's already set in environment - if [ -z "${UNOMI_OPENSEARCH_PASSWORD}" ]; then - echo "Note: UNOMI_OPENSEARCH_PASSWORD not found in .env.local or environment" - fi + # Load required passwords from .env.local (or existing environment). + # Engine password is OpenSearch-only so we do not pull Elasticsearch's password. + if ! require_password "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then + return 1 fi - - # Check if password is set - if ! check_password "UNOMI_OPENSEARCH_PASSWORD"; then + if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then + return 1 + fi + if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then return 1 fi - - # Set OpenSearch 3 password (only override needed - defaults are appropriate for OpenSearch 3) - # Password is already set from .env.local or environment, just ensure it's exported - export UNOMI_OPENSEARCH_PASSWORD # Set the distribution to use OpenSearch export UNOMI_DISTRIBUTION=unomi-distribution-opensearch echo "OpenSearch 3 environment variables configured." echo " Distribution: ${UNOMI_DISTRIBUTION}" - echo " Password: (set from .env.local or environment)" + echo " OpenSearch password: (set from .env.local or environment)" + echo " Root password: (set from .env.local or environment)" + echo " Healthcheck password: (set from .env.local or environment)" echo " Note: Using Unomi defaults for other OpenSearch settings (cluster, addresses, username, SSL)" return 0 diff --git a/setup-utils.sh b/setup-utils.sh index 6d8f04338..fd82fb0c2 100755 --- a/setup-utils.sh +++ b/setup-utils.sh @@ -139,6 +139,33 @@ check_password() { return 0 } +# Load a password from .env.local (if present) and require it to be set. +# Usage: require_password SCRIPT_DIR PASSWORD_VAR_NAME +# Returns: 0 if set, 1 if missing +require_password() { + local script_dir="$1" + local password_var="$2" + + if ! load_password_from_env_local "${script_dir}" "${password_var}"; then + if [ -n "${ZSH_VERSION}" ]; then + local password_value="${(P)password_var}" + else + local password_value="${!password_var}" + fi + if [ -z "${password_value}" ]; then + echo "Note: ${password_var} not found in .env.local or environment" + fi + fi + + if ! check_password "${password_var}"; then + return 1 + fi + + # Ensure the variable is exported into the current shell + eval "export ${password_var}" + return 0 +} + # Clear the opposite search engine's environment variables # Usage: clear_opposite SCRIPT_DIR OPPOSITE_TYPE # OPPOSITE_TYPE should be "opensearch" or "elasticsearch"
