This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new fa51b688a UNOMI-1008: Use a configurable tenant for JAAS admin
requests without a header (#935)
fa51b688a is described below
commit fa51b688adf6cbc13e233935e55a20079a501b90
Author: Serge Huber <[email protected]>
AuthorDate: Fri Oct 30 14:19:30 2026 +0100
UNOMI-1008: Use a configurable tenant for JAAS admin requests without a
header (#935)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../unomi/api/tenants/AdminTenantConfig.java | 35 +++
.../unomi/api/tenants/AdminTenantSelection.java | 163 ++++++++++++
.../api/tenants/AdminTenantSelectionTest.java | 121 +++++++++
docker/README.md | 3 +-
etc/org.apache.unomi.tenant.cfg | 2 +-
.../unomi/graphql/servlet/GraphQLServlet.java | 14 +-
.../auth/GraphQLServletSecurityValidator.java | 101 ++++++--
.../auth/GraphQLServletSecurityValidatorTest.java | 97 ++++++-
.../test/java/org/apache/unomi/itests/BaseIT.java | 36 ++-
.../GroovyActionsEndpointRoleSecurityIT.java | 5 +-
.../unomi/itests/RouterEndpointRoleSecurityIT.java | 12 +-
manual/src/main/asciidoc/configuration.adoc | 4 +-
manual/src/main/asciidoc/graphql.adoc | 2 +-
.../main/asciidoc/how-profile-tracking-works.adoc | 18 +-
.../asciidoc/migrations/migrate-3.0-to-4.0.adoc | 23 +-
.../asciidoc/migrations/v2-compatibility-mode.adoc | 4 +-
manual/src/main/asciidoc/multitenancy.adoc | 18 +-
manual/src/main/asciidoc/security.adoc | 20 +-
manual/src/main/asciidoc/whats-new.adoc | 1 +
.../main/resources/etc/custom.system.properties | 4 +
.../rest/authentication/AuthenticationFilter.java | 115 +++++---
.../authentication/RestAuthenticationConfig.java | 4 +-
.../impl/DefaultRestAuthenticationConfig.java | 39 ++-
.../org.apache.unomi.rest.authentication.cfg | 11 +-
.../AuthenticationFilterAdminTenantTest.java | 288 +++++++++++++++++++++
.../impl/DefaultRestAuthenticationConfigTest.java | 62 +++++
26 files changed, 1090 insertions(+), 112 deletions(-)
diff --git
a/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantConfig.java
b/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantConfig.java
new file mode 100644
index 000000000..98542b8c0
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantConfig.java
@@ -0,0 +1,35 @@
+/*
+ * 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.api.tenants;
+
+/**
+ * Tenant used for JAAS-authenticated admin requests that omit {@code
X-Unomi-Tenant-Id}
+ * (UNOMI-1008). The default is {@value #DEFAULT_ADMIN_TENANT_ID}, which is
also the default
+ * tenant id of a 3.0→4.0 migration; deployments that migrated into another
tenant configure it.
+ */
+public interface AdminTenantConfig {
+
+ /** Tenant id used when the admin default setting is missing or invalid. */
+ String DEFAULT_ADMIN_TENANT_ID = "default";
+
+ /**
+ * Tenant id applied when a JAAS admin request has no {@code
X-Unomi-Tenant-Id} header.
+ *
+ * @return a non-blank tenant id; never {@link TenantService#SYSTEM_TENANT}
+ */
+ String getAdminDefaultTenantId();
+}
diff --git
a/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantSelection.java
b/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantSelection.java
new file mode 100644
index 000000000..3b481eecf
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/tenants/AdminTenantSelection.java
@@ -0,0 +1,163 @@
+/*
+ * 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.api.tenants;
+
+/**
+ * Chooses the tenant (or system context) for a JAAS-authenticated admin
request (UNOMI-1008).
+ * Call only after authentication has succeeded, so a missing tenant never
changes the answer
+ * given for wrong credentials.
+ */
+public final class AdminTenantSelection {
+
+ /**
+ * Result of resolving the tenant header and the configured admin default.
+ */
+ public enum Kind {
+ /** Use {@code createContext(tenantId)}. */
+ TENANT,
+ /** Use the system execution context. */
+ SYSTEM,
+ /** The header names a tenant that does not exist. */
+ UNKNOWN_TENANT,
+ /** The header asks for {@code system} without a full administrator. */
+ FORBIDDEN_SYSTEM,
+ /** No header, and the configured default tenant does not exist. */
+ MISSING_DEFAULT
+ }
+
+ private final Kind kind;
+ private final String tenantId;
+
+ private AdminTenantSelection(Kind kind, String tenantId) {
+ this.kind = kind;
+ this.tenantId = tenantId;
+ }
+
+ /**
+ * @return how the caller should set the execution context
+ */
+ public Kind getKind() {
+ return kind;
+ }
+
+ /**
+ * Tenant id for {@link Kind#TENANT}, {@link Kind#UNKNOWN_TENANT} and
{@link Kind#MISSING_DEFAULT}.
+ *
+ * @return the tenant id, or {@code null} for {@link Kind#SYSTEM} / {@link
Kind#FORBIDDEN_SYSTEM}
+ */
+ public String getTenantId() {
+ return tenantId;
+ }
+
+ /**
+ * Resolves the tenant for an authenticated admin request.
+ *
+ * @param headerTenantId value of {@code X-Unomi-Tenant-Id}, or
{@code null}/blank when omitted
+ * @param adminDefaultTenantId configured default when the header is
omitted (already normalized)
+ * @param tenantService loads tenants by id
+ * @param fullAdministrator {@code true} when the subject holds {@code
ROLE_UNOMI_ADMIN}
+ * @return the selection; never {@code null}
+ */
+ public static AdminTenantSelection select(String headerTenantId, String
adminDefaultTenantId,
+ TenantService tenantService,
boolean fullAdministrator) {
+ return select(headerTenantId, adminDefaultTenantId, tenantService,
fullAdministrator, false);
+ }
+
+ /**
+ * Resolves the tenant for an authenticated admin request on a resource
that may be system-scoped.
+ * Tenant administration manages the tenant registry, which lives in the
system context and must
+ * work before any tenant exists. For such a resource a full administrator
without a header gets
+ * the system context, as before UNOMI-1008; everyone else follows the
tenant data rules.
+ *
+ * @param headerTenantId value of {@code X-Unomi-Tenant-Id}, or
{@code null}/blank when omitted
+ * @param adminDefaultTenantId configured default when the header is
omitted (already normalized)
+ * @param tenantService loads tenants by id
+ * @param fullAdministrator {@code true} when the subject holds {@code
ROLE_UNOMI_ADMIN}
+ * @param systemScopedResource {@code true} when the request targets
system-level data such as
+ * the tenant registry
+ * @return the selection; never {@code null}
+ */
+ public static AdminTenantSelection select(String headerTenantId, String
adminDefaultTenantId,
+ TenantService tenantService,
boolean fullAdministrator,
+ boolean systemScopedResource) {
+ if (headerTenantId != null && !headerTenantId.trim().isEmpty()) {
+ String id = headerTenantId.trim();
+ if (TenantService.SYSTEM_TENANT.equals(id)) {
+ if (!fullAdministrator) {
+ return new AdminTenantSelection(Kind.FORBIDDEN_SYSTEM,
null);
+ }
+ return new AdminTenantSelection(Kind.SYSTEM, null);
+ }
+ Tenant tenant = tenantService.getTenant(id);
+ if (tenant == null) {
+ return new AdminTenantSelection(Kind.UNKNOWN_TENANT, id);
+ }
+ return new AdminTenantSelection(Kind.TENANT, id);
+ }
+
+ if (systemScopedResource && fullAdministrator) {
+ return new AdminTenantSelection(Kind.SYSTEM, null);
+ }
+
+ String defaultId = normalizeDefaultTenantId(adminDefaultTenantId);
+ Tenant tenant = tenantService.getTenant(defaultId);
+ if (tenant == null) {
+ return new AdminTenantSelection(Kind.MISSING_DEFAULT, defaultId);
+ }
+ return new AdminTenantSelection(Kind.TENANT, defaultId);
+ }
+
+ /**
+ * Normalizes the configured admin default tenant id. Blank values and
{@code system} fall back
+ * to {@link AdminTenantConfig#DEFAULT_ADMIN_TENANT_ID}, because the
system context must be
+ * chosen only with an explicit {@code X-Unomi-Tenant-Id: system} header.
+ *
+ * @param configured the raw configuration value; may be {@code null}
+ * @return a usable tenant id
+ */
+ public static String normalizeDefaultTenantId(String configured) {
+ if (configured == null || configured.trim().isEmpty()) {
+ return AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
+ }
+ String trimmed = configured.trim();
+ if (TenantService.SYSTEM_TENANT.equals(trimmed)) {
+ return AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
+ }
+ return trimmed;
+ }
+
+ /**
+ * Short message suitable for a 400/403 response body.
+ *
+ * @return a human-readable explanation
+ */
+ public String clientMessage() {
+ switch (kind) {
+ case UNKNOWN_TENANT:
+ return "Unknown tenant: " + tenantId;
+ case MISSING_DEFAULT:
+ return "Admin default tenant does not exist: " + tenantId;
+ case FORBIDDEN_SYSTEM:
+ return "The system tenant requires a full administrator and
X-Unomi-Tenant-Id: system";
+ case TENANT:
+ case SYSTEM:
+ return "";
+ default:
+ throw new IllegalStateException("Unexpected kind: " + kind);
+ }
+ }
+}
diff --git
a/api/src/test/java/org/apache/unomi/api/tenants/AdminTenantSelectionTest.java
b/api/src/test/java/org/apache/unomi/api/tenants/AdminTenantSelectionTest.java
new file mode 100644
index 000000000..a776080e0
--- /dev/null
+++
b/api/src/test/java/org/apache/unomi/api/tenants/AdminTenantSelectionTest.java
@@ -0,0 +1,121 @@
+/*
+ * 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.api.tenants;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AdminTenantSelectionTest {
+
+ @Mock
+ private TenantService tenantService;
+
+ private Tenant defaultTenant;
+
+ @Before
+ public void setUp() {
+ defaultTenant = new Tenant();
+ defaultTenant.setItemId("default");
+ }
+
+ @Test
+ public void noHeaderUsesConfiguredDefaultWhenItExists() {
+ when(tenantService.getTenant("default")).thenReturn(defaultTenant);
+
+ AdminTenantSelection selection = AdminTenantSelection.select(null,
"default", tenantService, true);
+
+ assertEquals(AdminTenantSelection.Kind.TENANT, selection.getKind());
+ assertEquals("default", selection.getTenantId());
+ }
+
+ @Test
+ public void noHeaderFailsWhenConfiguredDefaultIsMissing() {
+ when(tenantService.getTenant("default")).thenReturn(null);
+
+ AdminTenantSelection selection = AdminTenantSelection.select(" ",
"default", tenantService, true);
+
+ assertEquals(AdminTenantSelection.Kind.MISSING_DEFAULT,
selection.getKind());
+ assertEquals("default", selection.getTenantId());
+ }
+
+ @Test
+ public void headerSelectsExistingTenant() {
+ Tenant tenant = new Tenant();
+ tenant.setItemId("acme");
+ when(tenantService.getTenant("acme")).thenReturn(tenant);
+
+ AdminTenantSelection selection = AdminTenantSelection.select("acme",
"default", tenantService, false);
+
+ assertEquals(AdminTenantSelection.Kind.TENANT, selection.getKind());
+ assertEquals("acme", selection.getTenantId());
+ }
+
+ @Test
+ public void unknownHeaderTenantIsReported() {
+ when(tenantService.getTenant("missing")).thenReturn(null);
+
+ AdminTenantSelection selection =
AdminTenantSelection.select("missing", "default", tenantService, true);
+
+ assertEquals(AdminTenantSelection.Kind.UNKNOWN_TENANT,
selection.getKind());
+ assertEquals("missing", selection.getTenantId());
+ }
+
+ @Test
+ public void systemHeaderRequiresFullAdministrator() {
+ AdminTenantSelection forbidden = AdminTenantSelection.select("system",
"default", tenantService, false);
+ assertEquals(AdminTenantSelection.Kind.FORBIDDEN_SYSTEM,
forbidden.getKind());
+ assertNull(forbidden.getTenantId());
+
+ AdminTenantSelection allowed = AdminTenantSelection.select("system",
"default", tenantService, true);
+ assertEquals(AdminTenantSelection.Kind.SYSTEM, allowed.getKind());
+ }
+
+ @Test
+ public void
systemScopedResourceGivesFullAdministratorSystemWithoutHeader() {
+ AdminTenantSelection selection = AdminTenantSelection.select(null,
"default", tenantService, true, true);
+
+ assertEquals(AdminTenantSelection.Kind.SYSTEM, selection.getKind());
+ verifyNoInteractions(tenantService);
+ }
+
+ @Test
+ public void systemScopedResourceStillUsesDefaultForOtherSubjects() {
+ when(tenantService.getTenant("default")).thenReturn(defaultTenant);
+
+ AdminTenantSelection selection = AdminTenantSelection.select(null,
"default", tenantService, false, true);
+
+ assertEquals(AdminTenantSelection.Kind.TENANT, selection.getKind());
+ assertEquals("default", selection.getTenantId());
+ }
+
+ @Test
+ public void normalizeRejectsBlankAndSystem() {
+ assertEquals("default",
AdminTenantSelection.normalizeDefaultTenantId(null));
+ assertEquals("default",
AdminTenantSelection.normalizeDefaultTenantId(" "));
+ assertEquals("default",
AdminTenantSelection.normalizeDefaultTenantId("system"));
+ assertEquals("acme", AdminTenantSelection.normalizeDefaultTenantId("
acme "));
+ }
+}
diff --git a/docker/README.md b/docker/README.md
index 0728743d3..2334f3242 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -139,7 +139,8 @@ Note: Linux doesn't support the host.docker.internal DNS
lookup method yet, it s
See "Websites on other origins" in the manual's configuration chapter.
-### API keys
+### Authentication and API keys
+- `UNOMI_REST_AUTHENTICATION_DEFAULTTENANTID`: Tenant used when a JAAS admin
request has no `X-Unomi-Tenant-Id` header (default: `default`). Not used by
single-tenant compatibility mode yet, which always runs REST requests on
`default`
- `UNOMI_TENANT_APIKEY_REFRESH_INTERVAL`: How often, in milliseconds, each
node reloads its list of API keys to pick up keys created on other nodes
(default: `30000`)
- `UNOMI_TENANT_APIKEY_MISS_REFRESH_INTERVAL`: Minimum time, in milliseconds,
between reloads triggered by unknown keys (default: `5000`)
diff --git a/etc/org.apache.unomi.tenant.cfg b/etc/org.apache.unomi.tenant.cfg
index ee8e7dcf3..8eb1d06da 100644
--- a/etc/org.apache.unomi.tenant.cfg
+++ b/etc/org.apache.unomi.tenant.cfg
@@ -15,7 +15,7 @@
# limitations under the License.
#
# Tenant Configuration
-tenant.default.id=default
+# The default tenant is not set here: see default.tenant.id in
org.apache.unomi.rest.authentication.cfg.
tenant.apikey.validity.period=30
tenant.apikey.validity.unit=DAYS
tenant.apikey.rotation.warning.days=7
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
index e4b3f88a8..cf4e8e26d 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/GraphQLServlet.java
@@ -24,6 +24,7 @@ import graphql.introspection.IntrospectionQuery;
import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
import org.apache.unomi.api.tenants.TenantService;
import org.apache.unomi.graphql.schema.GraphQLSchemaUpdater;
import org.apache.unomi.graphql.services.ServiceManager;
@@ -65,6 +66,8 @@ public class GraphQLServlet extends WebSocketServlet {
private SecurityService securityService;
+ private AdminTenantConfig adminTenantConfig;
+
private GraphQLServletSecurityValidator validator;
@Reference
@@ -72,6 +75,11 @@ public class GraphQLServlet extends WebSocketServlet {
this.serviceManager = serviceManager;
}
+ @Reference
+ public void setAdminTenantConfig(AdminTenantConfig adminTenantConfig) {
+ this.adminTenantConfig = adminTenantConfig;
+ }
+
@Reference
public void setGraphQLSchemaUpdater(GraphQLSchemaUpdater
graphQLSchemaUpdater) {
this.graphQLSchemaUpdater = graphQLSchemaUpdater;
@@ -102,7 +110,11 @@ public class GraphQLServlet extends WebSocketServlet {
// Must precede super.init(): WebSocketServlet.init() calls
configure(), which captures this
// validator into the SubscriptionWebSocketFactory. Constructing it
afterwards left the factory -
// and therefore every socket - with a null validator, so
connection_init could never authenticate.
- this.validator = new GraphQLServletSecurityValidator(tenantService,
securityService, executionContextManager);
+ AdminTenantConfig tenantConfig = adminTenantConfig != null
+ ? adminTenantConfig
+ : () -> AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
+ this.validator = new GraphQLServletSecurityValidator(
+ tenantService, securityService, executionContextManager,
tenantConfig);
super.init(config);
}
diff --git
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index c21a0c47c..67464bccd 100644
---
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -23,6 +23,8 @@ import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
+import org.apache.unomi.api.tenants.AdminTenantSelection;
import org.apache.unomi.api.tenants.ApiKey;
import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.api.tenants.TenantService;
@@ -57,19 +59,34 @@ public class GraphQLServletSecurityValidator {
private static final Logger LOG =
LoggerFactory.getLogger(GraphQLServletSecurityValidator.class);
private static final String UNOMI_TENANT_ID_HEADER = "X-Unomi-Tenant-Id";
+ private static final String TENANT_REJECTION_STATUS_ATTRIBUTE =
+ GraphQLServletSecurityValidator.class.getName() +
".tenantRejectionStatus";
+ private static final String TENANT_REJECTION_MESSAGE_ATTRIBUTE =
+ GraphQLServletSecurityValidator.class.getName() +
".tenantRejectionMessage";
private final Parser parser;
private final TenantService tenantService;
private final SecurityService securityService;
private final ExecutionContextManager executionContextManager;
+ private final AdminTenantConfig adminTenantConfig;
public GraphQLServletSecurityValidator(TenantService tenantService,
SecurityService securityService,
ExecutionContextManager
executionContextManager) {
+ this(tenantService, securityService, executionContextManager, () ->
AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+ }
+
+ public GraphQLServletSecurityValidator(TenantService tenantService,
+ SecurityService securityService,
+ ExecutionContextManager
executionContextManager,
+ AdminTenantConfig adminTenantConfig) {
this.parser = new Parser();
this.tenantService = tenantService;
this.securityService = securityService;
this.executionContextManager = executionContextManager;
+ this.adminTenantConfig = adminTenantConfig != null
+ ? adminTenantConfig
+ : () -> AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
}
/**
@@ -87,6 +104,9 @@ public class GraphQLServletSecurityValidator {
if (isAuthenticatedUser(req)) {
return true;
}
+ if (sendTenantRejection(req, res)) {
+ return false;
+ }
// A 401 carries a challenge whether the header was missing or its
credential was refused.
res.addHeader("WWW-Authenticate", "Basic realm=\"karaf\"");
res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
@@ -133,11 +153,28 @@ public class GraphQLServletSecurityValidator {
if (isAuthenticatedUser(req)) {
return true;
} else {
- res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ if (!sendTenantRejection(req, res)) {
+ res.sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ }
return false;
}
}
+ /**
+ * Sends the 400/403 recorded when valid JAAS credentials named an
unusable tenant (UNOMI-1008),
+ * the same statuses REST returns, so callers can tell a tenant problem
from wrong credentials.
+ *
+ * @return {@code true} when a response was sent
+ */
+ private boolean sendTenantRejection(HttpServletRequest req,
HttpServletResponse res) throws IOException {
+ Object status = req.getAttribute(TENANT_REJECTION_STATUS_ATTRIBUTE);
+ if (!(status instanceof Integer)) {
+ return false;
+ }
+ res.sendError((Integer) status, (String)
req.getAttribute(TENANT_REJECTION_MESSAGE_ATTRIBUTE));
+ return true;
+ }
+
/**
* Decides whether a GraphQL document may run against a public API key.
The check authorizes the
* exact operation graphql-java will execute for the request (selected by
{@code operationName}),
@@ -389,35 +426,45 @@ public class GraphQLServletSecurityValidator {
return false;
}
- // Check for tenant ID header (only present when the credential
arrived on a request;
- // the connection_init route carries none, so it can never select
a tenant this way)
+ // Tenant header or the configured admin default (UNOMI-1008).
connection_init has no
+ // request headers for the tenant, so it uses the admin default
like a REST call without
+ // X-Unomi-Tenant-Id. The system context needs an explicit header
and a full administrator.
String tenantId = req != null ?
req.getHeader(UNOMI_TENANT_ID_HEADER) : null;
- if (tenantId != null && !tenantId.trim().isEmpty()) {
- // Validate tenant exists
- Tenant tenant = tenantService.getTenant(tenantId);
- if (tenant == null) {
- LOG.warn("Invalid tenant ID provided in header: {}",
tenantId);
- securityService.clearCurrentSubject();
- return false;
- }
- // Naming a tenant is not the same as having authority over it.
- if (!securityService.hasSystemAccess() &&
!securityService.hasTenantAccess(tenantId)) {
- LOG.warn("Refusing GraphQL access to '{}': no authority
over tenant {}", username, tenantId);
- securityService.clearCurrentSubject();
- return false;
- }
-
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
- } else {
- // No tenant header. The system context is inherited by every
tenant, so it is reserved
- // for subjects that actually hold system access rather than
being the default.
- if (!securityService.hasSystemAccess()) {
- LOG.warn("Refusing GraphQL access to '{}': no tenant
specified and no system access", username);
+ AdminTenantSelection selection = AdminTenantSelection.select(
+ tenantId,
+ adminTenantConfig.getAdminDefaultTenantId(),
+ tenantService,
+ securityService.hasRole(UnomiRoles.ADMINISTRATOR));
+ switch (selection.getKind()) {
+ case TENANT:
+ if (!securityService.hasSystemAccess()
+ &&
!securityService.hasTenantAccess(selection.getTenantId())) {
+ LOG.warn("Refusing GraphQL access to '{}': no
authority over tenant {}",
+ username, selection.getTenantId());
+ securityService.clearCurrentSubject();
+ return false;
+ }
+ executionContextManager.setCurrentContext(
+
executionContextManager.createContext(selection.getTenantId()));
+ break;
+ case SYSTEM:
+
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ break;
+ case UNKNOWN_TENANT:
+ case MISSING_DEFAULT:
+ case FORBIDDEN_SYSTEM:
+ LOG.warn("Refusing GraphQL access to '{}': {}", username,
selection.clientMessage());
securityService.clearCurrentSubject();
+ if (req != null) {
+ req.setAttribute(TENANT_REJECTION_STATUS_ATTRIBUTE,
+ selection.getKind() ==
AdminTenantSelection.Kind.FORBIDDEN_SYSTEM
+ ? HttpServletResponse.SC_FORBIDDEN
+ : HttpServletResponse.SC_BAD_REQUEST);
+ req.setAttribute(TENANT_REJECTION_MESSAGE_ATTRIBUTE,
selection.clientMessage());
+ }
return false;
- }
- // The thread-local execution context must always be set
explicitly here, otherwise a
- // stale context from a previous request on this pooled thread
could leak in.
-
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ default:
+ throw new IllegalStateException("Unexpected admin tenant
selection: " + selection.getKind());
}
if (req != null) {
diff --git
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index 2a8a9e694..2def7c0fa 100644
---
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -21,6 +21,7 @@ import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
import org.apache.unomi.api.tenants.ApiKey;
import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.api.tenants.TenantService;
@@ -51,6 +52,8 @@ import java.util.Map;
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.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.refEq;
import static org.mockito.Mockito.lenient;
@@ -88,7 +91,25 @@ class GraphQLServletSecurityValidatorTest {
void setUp() {
previousConfiguration = Configuration.getConfiguration();
Configuration.setConfiguration(new
AlwaysSucceedingKarafConfiguration());
- validator = new GraphQLServletSecurityValidator(tenantService,
securityService, executionContextManager);
+ validator = new GraphQLServletSecurityValidator(
+ tenantService, securityService, executionContextManager,
+ () -> AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+ // Back request attributes with a map, as a servlet container does.
+ Map<String, Object> attributes = new HashMap<>();
+ lenient().doAnswer(invocation ->
attributes.put(invocation.getArgument(0), invocation.getArgument(1)))
+ .when(request).setAttribute(anyString(), any());
+ lenient().when(request.getAttribute(anyString()))
+ .thenAnswer(invocation ->
attributes.get(invocation.<String>getArgument(0)));
+ }
+
+ private Tenant givenDefaultTenant() {
+ Tenant tenant = new Tenant();
+ tenant.setItemId(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+
when(tenantService.getTenant(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID)).thenReturn(tenant);
+ ExecutionContext context = new
ExecutionContext(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID, null, null);
+
lenient().when(executionContextManager.createContext(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID))
+ .thenReturn(context);
+ return tenant;
}
@AfterEach
@@ -115,7 +136,24 @@ class GraphQLServletSecurityValidatorTest {
assertFalse(authenticated);
verify(executionContextManager,
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
- verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ // Valid credentials, unusable tenant: 400 as on REST, not the 401 of
wrong credentials.
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST,
"Unknown tenant: not-a-real-tenant");
+ verify(response,
never()).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ }
+
+ @Test
+ void validate_withSystemTenantHeaderWithoutFullAdministrator_isForbidden()
throws IOException {
+
lenient().when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(false);
+
lenient().when(securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(true);
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(request.getHeader(TENANT_HEADER)).thenReturn("system");
+ when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+ boolean authenticated = validator.validate(null, null, request,
response);
+
+ assertFalse(authenticated);
+ verify(executionContextManager, never()).setCurrentContext(any());
+ verify(response).sendError(eq(HttpServletResponse.SC_FORBIDDEN),
anyString());
}
@Test
@@ -155,19 +193,20 @@ class GraphQLServletSecurityValidatorTest {
}
@Test
- void validate_withoutTenantHeaderAndNoSystemAccess_isRejected() throws
IOException {
- // The system context is not the default for an authenticated caller.
+ void validate_withoutTenantHeaderAndMissingDefaultTenant_isRejected()
throws IOException {
+ // No header uses the admin default tenant; if that tenant is missing,
refuse.
givenAdministratorRole();
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
when(request.getHeader(TENANT_HEADER)).thenReturn(null);
when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
- when(securityService.hasSystemAccess()).thenReturn(false);
+
when(tenantService.getTenant(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID)).thenReturn(null);
boolean authenticated = validator.validate(null, null, request,
response);
assertFalse(authenticated);
- verify(executionContextManager,
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
- verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+ verify(executionContextManager, never()).setCurrentContext(any());
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST,
+ "Admin default tenant does not exist: " +
AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
}
@Test
@@ -193,8 +232,9 @@ class GraphQLServletSecurityValidatorTest {
}
@Test
- void validate_withSystemAccessAndNoTenantHeader_usesSystemContext() throws
IOException {
+ void validate_withNoTenantHeader_usesAdminDefaultTenant() throws
IOException {
givenAdministratorRole();
+ givenDefaultTenant();
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
when(request.getHeader(TENANT_HEADER)).thenReturn(null);
when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
@@ -202,9 +242,26 @@ class GraphQLServletSecurityValidatorTest {
boolean authenticated = validator.validate(null, null, request,
response);
+ assertTrue(authenticated);
+
verify(executionContextManager).createContext(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+ verify(executionContextManager,
never()).setCurrentContext(refEq(ExecutionContext.systemContext()));
+ verify(response, never()).sendError(any(Integer.class));
+ verify(response, never()).sendError(anyInt(), any());
+ }
+
+ @Test
+ void validate_withSystemTenantHeader_usesSystemContext() throws
IOException {
+ givenAdministratorRole();
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(request.getHeader(TENANT_HEADER)).thenReturn("system");
+ when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+ boolean authenticated = validator.validate(null, null, request,
response);
+
assertTrue(authenticated);
verify(executionContextManager).setCurrentContext(refEq(ExecutionContext.systemContext()));
verify(response, never()).sendError(any(Integer.class));
+ verify(response, never()).sendError(anyInt(), any());
}
@Test
@@ -231,6 +288,7 @@ class GraphQLServletSecurityValidatorTest {
@Test
void validateWebSocketUpgrade_withBasicAuth_isAccepted() throws
IOException {
givenAdministratorRole();
+ givenDefaultTenant();
when(securityService.hasSystemAccess()).thenReturn(true);
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
@@ -240,12 +298,30 @@ class GraphQLServletSecurityValidatorTest {
assertTrue(authenticated);
verify(securityService).setCurrentSubject(any(Subject.class));
verify(response, never()).sendError(any(Integer.class));
+ verify(response, never()).sendError(anyInt(), any());
+ }
+
+ /** Valid credentials but a missing admin default tenant: 400 on the
upgrade too, not 401. */
+ @Test
+ void validateWebSocketUpgrade_withMissingDefaultTenant_isBadRequest()
throws IOException {
+ givenAdministratorRole();
+ when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+ when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
when(tenantService.getTenant(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID)).thenReturn(null);
+
+ boolean authenticated = validator.validateWebSocketUpgrade(request,
response);
+
+ assertFalse(authenticated);
+ verify(response).sendError(HttpServletResponse.SC_BAD_REQUEST,
+ "Admin default tenant does not exist: " +
AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+ verify(response,
never()).sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
/** HTTP authentication scheme names are case-insensitive. */
@Test
void validateWebSocketUpgrade_withLowercaseScheme_isAccepted() throws
IOException {
givenAdministratorRole();
+ givenDefaultTenant();
when(securityService.hasSystemAccess()).thenReturn(true);
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH.replaceFirst("Basic",
"basic"));
when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
@@ -255,6 +331,7 @@ class GraphQLServletSecurityValidatorTest {
assertTrue(authenticated);
verify(securityService).setCurrentSubject(any(Subject.class));
verify(response, never()).sendError(any(Integer.class));
+ verify(response, never()).sendError(anyInt(), any());
}
@Test
@@ -396,8 +473,9 @@ class GraphQLServletSecurityValidatorTest {
/** Control: a non-blank credential still reaches the realm and is
accepted by it. */
@Test
void validate_withNonBlankPassword_reachesJaas() throws IOException {
- // Role + system access are required after JAAS succeeds when no
tenant header is present.
+ // Role + authority over the admin default tenant are required after
JAAS when no header is set.
givenAdministratorRole();
+ givenDefaultTenant();
when(securityService.hasSystemAccess()).thenReturn(true);
when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
when(tenantService.getTenantByApiKey(any(),
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
@@ -406,6 +484,7 @@ class GraphQLServletSecurityValidatorTest {
assertTrue(authenticated);
verify(response, never()).sendError(any(Integer.class));
+ verify(response, never()).sendError(anyInt(), any());
}
/**
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 16e1c5806..7e297ba9a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -46,6 +46,7 @@ import org.apache.unomi.api.query.Query;
import org.apache.unomi.api.rules.Rule;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.services.*;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
import org.apache.unomi.api.tenants.ApiKey;
import org.apache.unomi.api.tenants.ApiKeyCreationResult;
import org.apache.unomi.api.tenants.Tenant;
@@ -352,6 +353,11 @@ public abstract class BaseIT extends KarafTestSupport {
executionContextManager =
getOsgiService(ExecutionContextManager.class, 600000);
restAuthenticationConfig =
getOsgiService(RestAuthenticationConfig.class, 600000);
+ // UNOMI-1008: JAAS requests without X-Unomi-Tenant-Id use the
configured admin default
+ // tenant (default "default"). Create it here so HTTP admin calls in
the suite and GraphQL
+ // checks that omit the header do not fail with "Admin default tenant
does not exist".
+ ensureAdminDefaultTenantExists();
+
// Create test tenant if not exists
if (testTenant == null) {
testTenant = tenantService.getTenant(TEST_TENANT_ID);
@@ -896,6 +902,25 @@ public abstract class BaseIT extends KarafTestSupport {
return value;
}
+ /**
+ * Ensures the configured admin default tenant exists (UNOMI-1008). Fresh
IT containers have no
+ * tenants until this suite creates them; without this, JAAS calls that
omit {@code X-Unomi-Tenant-Id}
+ * (GraphQL waits, WebSocket upgrades, some RestAssured scripts) fail with
HTTP 400.
+ */
+ private void ensureAdminDefaultTenantExists() {
+ String adminDefaultId = restAuthenticationConfig != null
+ ? restAuthenticationConfig.getAdminDefaultTenantId()
+ : AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
+ if (adminDefaultId == null || adminDefaultId.trim().isEmpty()) {
+ adminDefaultId = AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
+ }
+ if (tenantService.getTenant(adminDefaultId) == null) {
+ LOGGER.info("Creating admin default tenant '{}' for UNOMI-1008 IT
coverage", adminDefaultId);
+ tenantService.createTenant(adminDefaultId, Collections.emptyMap());
+ persistenceService.refresh();
+ }
+ }
+
/**
* Runs {@code action} with the request subject temporarily replaced by a
tenant subject for
* {@link #TEST_TENANT_ID}: a tenant-admin (private key) subject when
{@code systemAccess} is
@@ -1679,6 +1704,11 @@ public abstract class BaseIT extends KarafTestSupport {
String credentials = BASIC_AUTH_USER_NAME + ":" +
BASIC_AUTH_PASSWORD;
request.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(credentials.getBytes()));
}
+ // Scope the admin call to the suite tenant unless the test
already chose one
+ // (including "system"). Without this, UNOMI-1008 would put
the call on "default".
+ if (request.getFirstHeader("X-Unomi-Tenant-Id") == null) {
+ request.setHeader("X-Unomi-Tenant-Id", TEST_TENANT_ID);
+ }
break;
case CUSTOM_BASIC:
// Remove any existing auth headers first
@@ -1714,10 +1744,10 @@ public abstract class BaseIT extends KarafTestSupport {
if (request.getFirstHeader("X-Unomi-Api-Key") == null &&
testPublicKeyValue != null) {
request.setHeader("X-Unomi-Api-Key",
testPublicKeyValue);
}
- } else if (normalizedPath.startsWith("/tenants")) {
- // Admin endpoint - use JAAS admin
+ } else if (normalizedPath.startsWith("tenants")) {
+ // Admin endpoint - use JAAS admin. No tenant header, as
in the documented
+ // examples: tenant administration then runs in the system
context.
request.removeHeaders("X-Unomi-Api-Key");
- // Only set Authorization header if it's not already set
if (request.getFirstHeader("Authorization") == null) {
String adminCredentials = BASIC_AUTH_USER_NAME + ":" +
BASIC_AUTH_PASSWORD;
request.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(adminCredentials.getBytes()));
diff --git
a/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
index 5802faf26..e080e2f2d 100644
---
a/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/GroovyActionsEndpointRoleSecurityIT.java
@@ -53,7 +53,9 @@ public class GroovyActionsEndpointRoleSecurityIT extends
BaseIT {
403, tenantAdmin.getStatusLine().getStatusCode());
}
- try (CloseableHttpResponse jaasAdmin = executeHttpRequest(new
HttpDelete(path), AuthType.JAAS_ADMIN)) {
+ HttpDelete jaasDelete = new HttpDelete(path);
+ jaasDelete.setHeader("X-Unomi-Tenant-Id", "system");
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(jaasDelete,
AuthType.JAAS_ADMIN)) {
int status = jaasAdmin.getStatusLine().getStatusCode();
Assert.assertTrue("JAAS admin delete should be allowed (got " +
status + ")",
status == 200 || status == 204 || status == 404);
@@ -75,6 +77,7 @@ public class GroovyActionsEndpointRoleSecurityIT extends
BaseIT {
HttpPost uploadJaas = multipartPost(getFullUrl("/cxs/groovyActions/"),
"----UnomiGroovyBoundaryJaas",
filePart("file", "RestRoleSecurityITProbe.groovy",
"text/plain", script));
+ uploadJaas.setHeader("X-Unomi-Tenant-Id", "system");
try (CloseableHttpResponse jaasAdmin = executeHttpRequest(uploadJaas,
AuthType.JAAS_ADMIN)) {
Assert.assertEquals("JAAS admin should be allowed to upload groovy
actions",
200, jaasAdmin.getStatusLine().getStatusCode());
diff --git
a/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
b/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
index a076bcdf9..1774acea7 100644
---
a/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
+++
b/itests/src/test/java/org/apache/unomi/itests/RouterEndpointRoleSecurityIT.java
@@ -48,8 +48,9 @@ public class RouterEndpointRoleSecurityIT extends BaseIT {
403, tenantAdmin.getStatusLine().getStatusCode());
}
- try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
- new HttpGet(getFullUrl("/cxs/importConfiguration")),
AuthType.JAAS_ADMIN)) {
+ HttpGet jaasImport = new
HttpGet(getFullUrl("/cxs/importConfiguration"));
+ jaasImport.setHeader("X-Unomi-Tenant-Id", "system");
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(jaasImport,
AuthType.JAAS_ADMIN)) {
Assert.assertEquals("JAAS admin should list import configurations",
200, jaasAdmin.getStatusLine().getStatusCode());
}
@@ -62,8 +63,9 @@ public class RouterEndpointRoleSecurityIT extends BaseIT {
Assert.assertEquals(403,
tenantAdmin.getStatusLine().getStatusCode());
}
- try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
- new HttpGet(getFullUrl("/cxs/exportConfiguration")),
AuthType.JAAS_ADMIN)) {
+ HttpGet jaasExport = new
HttpGet(getFullUrl("/cxs/exportConfiguration"));
+ jaasExport.setHeader("X-Unomi-Tenant-Id", "system");
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(jaasExport,
AuthType.JAAS_ADMIN)) {
Assert.assertEquals(200,
jaasAdmin.getStatusLine().getStatusCode());
}
}
@@ -83,6 +85,7 @@ public class RouterEndpointRoleSecurityIT extends BaseIT {
"----UnomiImportBoundaryJaas",
part("importConfigId", "text/plain",
"rest-role-security-oneshot"),
filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+ oneshotJaas.setHeader("X-Unomi-Tenant-Id", "system");
try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas,
AuthType.JAAS_ADMIN)) {
// Role gate is what we care about; missing config may yield 500
after auth succeeds.
Assert.assertNotEquals(403,
jaasAdmin.getStatusLine().getStatusCode());
@@ -102,6 +105,7 @@ public class RouterEndpointRoleSecurityIT extends BaseIT {
HttpPost oneshotJaas = new
HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
oneshotJaas.setEntity(new StringEntity(body,
ContentType.APPLICATION_JSON));
+ oneshotJaas.setHeader("X-Unomi-Tenant-Id", "system");
try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas,
AuthType.JAAS_ADMIN)) {
Assert.assertNotEquals(403,
jaasAdmin.getStatusLine().getStatusCode());
Assert.assertNotEquals(401,
jaasAdmin.getStatusLine().getStatusCode());
diff --git a/manual/src/main/asciidoc/configuration.adoc
b/manual/src/main/asciidoc/configuration.adoc
index 80d7c39dc..c96978bdd 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -471,8 +471,8 @@ curl -X GET "http://localhost:8181/cxs/tenants" \
Authentication rules (Unomi 4.0):
1. Public paths (for example `/cxs/context.json`, `/cxs/eventcollector`,
`/cxs/client/*`) require a valid **public** API key via `X-Unomi-Api-Key`
(unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled). Browser
clients may call them from any origin, but only listed origins and Unomi's own
origin may use the visitor cookie; see <<_public_tracker_origins,Websites on
other origins>>.
-2. Private paths accept `tenantId:privateApiKey` Basic auth, or JAAS admin
credentials (often with `X-Unomi-Tenant-Id` when a tenant context is required).
-3. Tenant administration (`/cxs/tenants`) requires system administrator JAAS
credentials.
+2. Private paths accept `tenantId:privateApiKey` Basic auth, or JAAS admin
credentials. With JAAS, `X-Unomi-Tenant-Id` selects the tenant; when it is
omitted, Unomi uses `org.apache.unomi.rest.authentication.defaultTenantId` (env
`UNOMI_REST_AUTHENTICATION_DEFAULTTENANTID`, default `default`; not used yet by
<<_v2_compatibility_mode,single-tenant compatibility mode>>, whose REST
requests always run on `default`). Send `X-Unomi-Tenant-Id: system` only when a
full administrator must work i [...]
+3. Tenant administration (`/cxs/tenants`) requires system administrator JAAS
credentials. Without `X-Unomi-Tenant-Id` it runs in the system context, so it
works on a fresh install before any tenant exists.
4. JAAS admin does **not** replace the public API key on public endpoints.
==== Public vs Private Endpoints
diff --git a/manual/src/main/asciidoc/graphql.adoc
b/manual/src/main/asciidoc/graphql.adoc
index 3824517e2..21f022013 100644
--- a/manual/src/main/asciidoc/graphql.adoc
+++ b/manual/src/main/asciidoc/graphql.adoc
@@ -42,7 +42,7 @@ you might need to adjust the URL to point GraphQL UI to the
`/graphql` endpoint.
GraphQL requests follow the same tenant security model as REST:
* *Public read operations* on the `cdp` root field: send `X-Unomi-Api-Key`
with a tenant **public** API key.
-* *Mutations and administrative queries*: HTTP Basic authentication with
`tenantId:privateApiKey`, or JAAS credentials plus optional `X-Unomi-Tenant-Id`.
+* *Mutations and administrative queries*: HTTP Basic authentication with
`tenantId:privateApiKey`, or JAAS credentials. With JAAS, `X-Unomi-Tenant-Id`
selects the tenant; when it is omitted, Unomi uses the same admin default
tenant as REST (default `default`). The system context needs an explicit
`X-Unomi-Tenant-Id: system` and a full administrator.
* *Introspection* (`IntrospectionQuery`): allowed without credentials for
schema discovery.
See <<_multitenancy,Multi-tenancy>> for key management.
diff --git a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
index e177f4743..a600a02e6 100644
--- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
+++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
@@ -123,9 +123,11 @@ Starting with Apache Unomi 4.0, tenant resolution is
mandatory for all requests
3. **Tenant ID Header** (When using JAAS authentication):
* Send the `X-Unomi-Tenant-Id` header with the tenant ID
* Used when authenticating via JAAS (e.g., the `karaf` user and your
configured `UNOMI_ROOT_PASSWORD`)
- * The tenant ID must exist in the system
+ * The tenant ID must exist; an unknown id returns HTTP 400 after
authentication succeeds
+ * If the header is omitted, Unomi uses the configured admin default tenant
(default `default`)
+ * The system context needs an explicit `X-Unomi-Tenant-Id: system` and a
full administrator
-If no tenant can be resolved, the request will fail with an `UNAUTHORIZED`
(401) error. This ensures that all data operations are scoped to the correct
tenant in multi-tenant deployments.
+Wrong credentials return HTTP 401 whether or not the tenant exists. A missing
admin default tenant also fails with HTTP 400, on REST and GraphQL. Tenant
administration (`/cxs/tenants`) does not need the header: a full administrator
then works in the system context, even before any tenant exists.
====
===== Example: Tenant Resolution
@@ -185,7 +187,7 @@ curl -X POST
http://localhost:8181/cxs/context.json?sessionId=1234 \
}'
----
-The tenant is resolved from the `X-Unomi-Tenant-Id` header when using JAAS
authentication.
+With JAAS authentication, the tenant comes from the `X-Unomi-Tenant-Id`
header, or from the configured default tenant when the header is omitted.
[plantuml]
----
@@ -213,7 +215,15 @@ else JAAS Auth with Tenant Header
TenantService --> AuthenticationFilter: Tenant
AuthenticationFilter -> ExecutionContextManager: Set tenant context
AuthenticationFilter --> Client: Request authorized
-else No Tenant Resolved
+else JAAS Auth without Tenant Header
+ Client -> AuthenticationFilter: JAAS Auth
+ AuthenticationFilter -> TenantService: getTenant(defaultTenantId)
+ TenantService --> AuthenticationFilter: Tenant
+ AuthenticationFilter -> ExecutionContextManager: Set tenant context
+ AuthenticationFilter --> Client: Request authorized
+else JAAS Auth, tenant unknown or default tenant missing
+ AuthenticationFilter --> Client: 400 BAD REQUEST (403 without an
administrator role)
+else Invalid or Missing Credentials
AuthenticationFilter --> Client: 401 UNAUTHORIZED
end
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
index de73b7384..3c2a6a090 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-4.0.adoc
@@ -122,14 +122,26 @@ given()
.body(payload)
.post("/cxs/profiles");
-// For private endpoints using system administrator authentication
+// For private endpoints using system administrator authentication.
+// Without X-Unomi-Tenant-Id, Unomi uses the admin default tenant (default
"default", which is
+// also the migration's default tenant id; see the note below if you chose
another one).
+// To work in the system context, send .header("X-Unomi-Tenant-Id", "system")
explicitly.
given()
.auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(payload)
.post("/cxs/profiles");
-// For tenant administration (system admin only)
+// Same call scoped to a named tenant
+given()
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
+ .header("X-Unomi-Tenant-Id", "default")
+ .contentType(ContentType.JSON)
+ .body(payload)
+ .post("/cxs/profiles");
+
+// For tenant administration (system admin only). No tenant header is needed:
+// these calls run in the system context, even before any tenant exists.
given()
.auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
@@ -137,6 +149,10 @@ given()
.post("/cxs/tenants");
----
+NOTE: JAAS admin calls without `X-Unomi-Tenant-Id` now act on the admin
default tenant. If your migration used another
+tenant id than `default`, set
`org.apache.unomi.rest.authentication.defaultTenantId` to the same value.
Definitions
+that every tenant must inherit (for example shared rules or segments) are
written with `X-Unomi-Tenant-Id: system`.
+
==== Implementation Strategy
===== Client Factory Pattern
@@ -571,7 +587,8 @@ If you are migrating from **Unomi 2.x** (not 3.0), you can
enable <<_v2_compatib
singletenantcompatibility.enabled = true
----
-This mode always uses tenant `default`. There is no property to point it at
another tenant. If tenant `default` does not exist yet, the first public
request creates it. Migrated 2.x data has to live in that tenant to be visible
while the mode is on.
+This mode always uses tenant `default`. There is no property to point it at
another tenant: `defaultTenantId` does not
+apply to it yet, so leave that setting at `default` while the mode is on
(GraphQL still follows it). If tenant `default` does not exist yet, the first
public request creates it. Migrated 2.x data has to live in that tenant to be
visible while the mode is on.
Environment variable equivalent:
`UNOMI_REST_AUTHENTICATION_SINGLETENANTCOMPATIBILITYMODEENABLED=true`
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index c1d48143b..b69621c79 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -28,7 +28,9 @@ When single-tenant compatibility mode is enabled:
- **Public endpoints** (like `/context.json`) require no authentication (like
V2)
- **Protected events** (like `login`, `updateProperties`) require IP +
X-Unomi-Peer (like V2)
- **Private endpoints** require system administrator authentication (like V2)
-- **Tenant `default`** is used for every operation. The mode cannot be pointed
at another tenant.
+- **Tenant `default`** is used for every operation. The mode cannot be pointed
at another tenant: it ignores
+ `X-Unomi-Tenant-Id` and the `defaultTenantId` setting on REST. GraphQL is
not affected by the mode and still uses
+ `defaultTenantId` for JAAS admin requests without a header, so keep that
setting at `default` while the mode is on.
The tenant is created on a public request if it is missing. Each node then
keeps it in memory for
about 30 seconds, so public requests do not wait on each other or re-read it
from the database
every time. A change made on another node is picked up after about that time.
diff --git a/manual/src/main/asciidoc/multitenancy.adoc
b/manual/src/main/asciidoc/multitenancy.adoc
index 3474e8910..9acd5c9aa 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -157,13 +157,25 @@ curl -X GET http://localhost:8181/cxs/profiles \
===== Basic Setup
-Configure default tenant settings in `etc/org.apache.unomi.tenant.cfg`:
+Configure the default tenant in `etc/org.apache.unomi.rest.authentication.cfg`
+(or `org.apache.unomi.rest.authentication.defaultTenantId` /
+`UNOMI_REST_AUTHENTICATION_DEFAULTTENANTID`):
[source,properties]
----
-# Default tenant ID for backward compatibility
-tenant.default.id=default
+# Tenant for JAAS admin requests that omit X-Unomi-Tenant-Id (UNOMI-1008).
+# Defaults to "default"; set it to the tenant id chosen during the 3.0→4.0
migration.
+default.tenant.id=default
+----
+
+In 4.0 this setting only applies to JAAS admin requests.
<<_v2_compatibility_mode,Single-tenant compatibility mode>>
+does not use it yet: its REST requests always run on tenant `default`, so keep
the setting at `default` while that mode
+is on, or GraphQL admin queries will work on a different tenant than REST.
+
+Other tenant-related settings live in `etc/org.apache.unomi.tenant.cfg`:
+[source,properties]
+----
# API key validity period
tenant.apikey.validity.period=30
tenant.apikey.validity.unit=DAYS
diff --git a/manual/src/main/asciidoc/security.adoc
b/manual/src/main/asciidoc/security.adoc
index 1772869af..a459d94e0 100644
--- a/manual/src/main/asciidoc/security.adoc
+++ b/manual/src/main/asciidoc/security.adoc
@@ -27,7 +27,7 @@ Unomi 4.0 authenticates every request so the server can
resolve a **tenant** (or
| Browser / mobile SDKs, trackers
| Private REST
-| Basic `tenantId:privateApiKey`, or JAAS + optional `X-Unomi-Tenant-Id`
+| Basic `tenantId:privateApiKey`, or JAAS + `X-Unomi-Tenant-Id` (optional; see
below)
| Server-side integrations, admin UIs
| Tenant administration (`/cxs/tenants`)
@@ -35,7 +35,18 @@ Unomi 4.0 authenticates every request so the server can
resolve a **tenant** (or
| Create tenants, rotate keys
|===
+When a JAAS administrator authenticates without `X-Unomi-Tenant-Id`, Unomi
uses the configured admin default tenant
+(`org.apache.unomi.rest.authentication.defaultTenantId` /
`UNOMI_REST_AUTHENTICATION_DEFAULTTENANTID`,
+default `default`). Set it to the tenant id chosen during the 3.0→4.0
migration (also `default` unless changed). An unknown tenant id, or a missing
default tenant,
+returns HTTP 400 after authentication succeeds, on REST and GraphQL. Wrong
credentials always return HTTP 401, whether
+or not the tenant exists. For tenant data, the system context is never the
silent default: send
+`X-Unomi-Tenant-Id: system`, and only a full administrator
(`ROLE_UNOMI_ADMIN`) may do so. Tenant administration
+(`/cxs/tenants`) is the exception: it manages the tenant registry, so a full
administrator without the header works in
+the system context, even before any tenant exists.
+
Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows
public endpoints without API keys while migrating 2.x clients.
+In that mode every REST request, JAAS admin requests included, runs on tenant
`default` and the default tenant setting
+is not used yet; GraphQL still follows the setting, so keep it at `default`
while the mode is on.
Public context callers must present the profile cookie as the profile bearer
(body `profileId` is ignored for public callers). See
<<_client_facing_hardening_4_0,client-facing hardening>> and
<<_how_profile_tracking_works,How profile tracking works>>.
@@ -62,10 +73,11 @@ else Public path + X-Unomi-Api-Key
else Private path + Basic tenantId:privateKey
Auth -> Tenant : Validate private key
Auth -> API : Tenant context
-else Private path + JAAS (+ optional X-Unomi-Tenant-Id)
- Auth -> API : Admin / tenant context
+else Private path + JAAS
+ Auth -> Tenant : X-Unomi-Tenant-Id or admin default tenant
+ Auth -> API : Tenant context (system only with explicit header)
else V2 compatibility mode
- Auth -> Tenant : Default tenant
+ Auth -> Tenant : Compatibility tenant
Auth -> API : Legacy 2.x-style access
else
Auth --> Client : 401 Unauthorized
diff --git a/manual/src/main/asciidoc/whats-new.adoc
b/manual/src/main/asciidoc/whats-new.adoc
index 0c1a80f63..a511248d2 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -24,6 +24,7 @@ Complete tenant isolation for profiles, events, segments,
rules, and schemas. Pu
* Migration: <<_migrate_from_3_0_to_4_0,Migrate from 3.0 to 4.0>> (includes
<<_client_facing_hardening_4_0,client-facing hardening>>)
* Migrating from Unomi 2.x: <<_v2_compatibility_mode,single-tenant
compatibility mode>> (`singletenantcompatibility.enabled` in
`org.apache.unomi.rest.authentication.cfg`)
* Public and GraphQL API key checks no longer read every tenant on each
request, and refuse revoked and expired keys
(https://issues.apache.org/jira/browse/UNOMI-1016[UNOMI-1016])
+* JAAS admin requests without `X-Unomi-Tenant-Id` use a configurable default
tenant (default `default`), so 3.0 admin scripts keep seeing migrated data
(https://issues.apache.org/jira/browse/UNOMI-1008[UNOMI-1008])
==== Security hardening (credentials, profile binding, privileged APIs)
diff --git a/package/src/main/resources/etc/custom.system.properties
b/package/src/main/resources/etc/custom.system.properties
index fe48818e1..524aaaf8c 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -570,3 +570,7 @@
org.apache.unomi.campaigns.refresh.interval=${env:UNOMI_CAMPAIGNS_REFRESH_INTERV
## REST API Authorization Settings
##
#######################################################################################################################
org.apache.unomi.rest.authentication.singleTenantCompatibilityModeEnabled=${env:UNOMI_REST_AUTHENTICATION_SINGLETENANTCOMPATIBILITYMODEENABLED:-false}
+# Default tenant (UNOMI-1008). In 4.0 it is used by JAAS admin requests (REST
and GraphQL) that omit
+# X-Unomi-Tenant-Id. Single-tenant compatibility mode does not use it yet: its
REST requests always run
+# on tenant "default" (UNOMI-1029). Cannot be "system". Default "default".
+org.apache.unomi.rest.authentication.defaultTenantId=${env:UNOMI_REST_AUTHENTICATION_DEFAULTTENANTID:-default}
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 14816719e..126102488 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
@@ -27,6 +27,7 @@ import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.AdminTenantSelection;
import org.apache.unomi.api.tenants.ApiKey;
import org.apache.unomi.api.tenants.Tenant;
import org.apache.unomi.api.tenants.TenantService;
@@ -42,6 +43,7 @@ import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.PreMatching;
import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
@@ -189,26 +191,15 @@ public class AuthenticationFilter implements
ContainerRequestFilter {
try {
jaasAuthenticationFilter.filter(requestContext);
- // Get the subject from the security context after
successful JAAS auth
- SecurityContext securityContext =
JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
- if (securityContext != null) {
- Subject subject = ((RolePrefixSecurityContextImpl)
securityContext).getSubject();
+ Subject subject = authenticatedJaasSubject();
+ if (subject != null) {
// Set the authenticated subject in Unomi's security
service
securityService.setCurrentSubject(subject);
- // Check for tenant ID header
- String tenantId =
requestContext.getHeaderString(UNOMI_TENANT_ID_HEADER);
- if (tenantId != null && !tenantId.trim().isEmpty()) {
- // Validate tenant exists
- Tenant tenant = tenantService.getTenant(tenantId);
- if (tenant != null) {
-
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
- } else {
- logger.warn("Invalid tenant ID provided in
header: {}", tenantId);
-
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
- }
- } else {
-
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ // Tenant administration works on the tenant registry,
so it runs in the
+ // system context when no header is sent, even before
any tenant exists.
+ if (!applyAdminTenantContext(requestContext, true)) {
+ return;
}
}
return;
@@ -275,26 +266,13 @@ public class AuthenticationFilter implements
ContainerRequestFilter {
// If tenant auth fails, try JAAS auth
try {
jaasAuthenticationFilter.filter(requestContext);
- // Get the subject from the security context after
successful JAAS auth
- SecurityContext securityContext =
JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
- if (securityContext != null) {
- Subject subject = ((RolePrefixSecurityContextImpl)
securityContext).getSubject();
+ Subject subject = authenticatedJaasSubject();
+ if (subject != null) {
// Set the authenticated subject in Unomi's security
service
securityService.setCurrentSubject(subject);
- // Check for tenant ID header
- String tenantId =
requestContext.getHeaderString(UNOMI_TENANT_ID_HEADER);
- if (tenantId != null && !tenantId.trim().isEmpty()) {
- // Validate tenant exists
- Tenant tenant = tenantService.getTenant(tenantId);
- if (tenant != null) {
-
executionContextManager.setCurrentContext(executionContextManager.createContext(tenantId));
- } else {
- logger.warn("Invalid tenant ID provided in
header: {}", tenantId);
-
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
- }
- } else {
-
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ if (!applyAdminTenantContext(requestContext, false)) {
+ return;
}
}
return;
@@ -453,13 +431,72 @@ public class AuthenticationFilter implements
ContainerRequestFilter {
}
}
+ /**
+ * Subject left in the CXF security context by a successful JAAS login.
Package-visible so unit
+ * tests can drive {@link #filter} without a CXF message.
+ *
+ * @return the subject, or {@code null} when JAAS did not authenticate the
request
+ */
+ Subject authenticatedJaasSubject() {
+ SecurityContext securityContext =
JAXRSUtils.getCurrentMessage().get(SecurityContext.class);
+ return securityContext != null ? ((RolePrefixSecurityContextImpl)
securityContext).getSubject() : null;
+ }
+
+ /**
+ * After JAAS succeeds, sets the execution context from {@code
X-Unomi-Tenant-Id} or the
+ * configured admin default tenant (UNOMI-1008). Aborts the request when
the selection is invalid.
+ * Package-visible for unit tests.
+ *
+ * @param systemScopedResource {@code true} for tenant administration: a
full administrator without
+ * a header then gets the system context
instead of the admin default
+ * @return {@code true} when a context was set; {@code false} when the
request was aborted
+ */
+ boolean applyAdminTenantContext(ContainerRequestContext requestContext,
boolean systemScopedResource) {
+ AdminTenantSelection selection = AdminTenantSelection.select(
+ requestContext.getHeaderString(UNOMI_TENANT_ID_HEADER),
+ restAuthenticationConfig.getAdminDefaultTenantId(),
+ tenantService,
+ securityService.hasRole(UnomiRoles.ADMINISTRATOR),
+ systemScopedResource);
+ switch (selection.getKind()) {
+ case TENANT:
+ executionContextManager.setCurrentContext(
+
executionContextManager.createContext(selection.getTenantId()));
+ return true;
+ case SYSTEM:
+
executionContextManager.setCurrentContext(ExecutionContext.systemContext());
+ return true;
+ case UNKNOWN_TENANT:
+ case MISSING_DEFAULT:
+ if (!securityService.hasSystemAccess()) {
+ // The authorization filter refuses such an account with
403 whatever the tenant.
+ // Answer the same here, so that it cannot learn which
tenants exist.
+ abort(requestContext, Response.Status.FORBIDDEN, "User
does not have required role");
+ return false;
+ }
+ logger.warn("{}", selection.clientMessage());
+ abort(requestContext, Response.Status.BAD_REQUEST,
selection.clientMessage());
+ return false;
+ case FORBIDDEN_SYSTEM:
+ logger.warn("{}", selection.clientMessage());
+ abort(requestContext, Response.Status.FORBIDDEN,
selection.clientMessage());
+ return false;
+ default:
+ throw new IllegalStateException("Unexpected admin tenant
selection: " + selection.getKind());
+ }
+ }
+
private void unauthorized(ContainerRequestContext requestContext) {
- Response response = Response.status(Response.Status.UNAUTHORIZED)
- .header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"" +
REALM_NAME + "\"")
- .entity("Unauthorized Access") // Ensures response is not empty
- .build();
+ abort(requestContext, Response.Status.UNAUTHORIZED, "Unauthorized
Access");
+ }
- requestContext.abortWith(response);
+ private void abort(ContainerRequestContext requestContext, Response.Status
status, String entity) {
+ // Plain text: the entity may echo the tenant header, and must never
be rendered as HTML.
+ Response.ResponseBuilder builder =
Response.status(status).entity(entity).type(MediaType.TEXT_PLAIN_TYPE);
+ if (status == Response.Status.UNAUTHORIZED) {
+ builder.header(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"" +
REALM_NAME + "\"");
+ }
+ requestContext.abortWith(builder.build());
}
private boolean isPublicPath(ContainerRequestContext requestContext) {
diff --git
a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
index fe3eed098..0d030f161 100644
---
a/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
+++
b/rest/src/main/java/org/apache/unomi/rest/authentication/RestAuthenticationConfig.java
@@ -16,6 +16,8 @@
*/
package org.apache.unomi.rest.authentication;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
+
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@@ -23,7 +25,7 @@ import java.util.regex.Pattern;
/**
* This interface provide rest authentication configuration for the rest
server.
*/
-public interface RestAuthenticationConfig {
+public interface RestAuthenticationConfig extends AdminTenantConfig {
/**
* This provide the patterns to identify public endpoints
diff --git
a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
index 789e02ac2..2a4d4c9de 100644
---
a/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
+++
b/rest/src/main/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfig.java
@@ -16,8 +16,9 @@
*/
package org.apache.unomi.rest.authentication.impl;
-import org.apache.commons.lang3.StringUtils;
import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
+import org.apache.unomi.api.tenants.AdminTenantSelection;
import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
@@ -35,7 +36,8 @@ import java.util.regex.Pattern;
* Default OSGi-backed implementation of {@link RestAuthenticationConfig} for
REST endpoint
* authentication, role mappings, and single-tenant compatibility mode
settings.
*/
-@Component(service = { RestAuthenticationConfig.class}, configurationPid =
"org.apache.unomi.rest.authentication", immediate = true)
+@Component(service = { RestAuthenticationConfig.class, AdminTenantConfig.class
},
+ configurationPid = "org.apache.unomi.rest.authentication", immediate =
true)
@Designate(ocd = DefaultRestAuthenticationConfig.Config.class)
public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig {
@@ -77,6 +79,7 @@ public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig
}
private volatile boolean singleTenantCompatibilityModeEnabled = false;
+ private volatile String adminDefaultTenantId =
AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
/**
* Updates authentication settings from OSGi configuration.
@@ -91,8 +94,16 @@ public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig
return;
}
boolean singleTenantMode = config.singletenantcompatibility_enabled();
- LOGGER.info("Configuration updated -
singleTenantCompatibilityModeEnabled: {}", singleTenantMode);
+ String rawDefault = config.default_tenant_id();
+ String normalizedDefault =
AdminTenantSelection.normalizeDefaultTenantId(rawDefault);
+ if (rawDefault != null && !rawDefault.trim().isEmpty()
+ && !normalizedDefault.equals(rawDefault.trim())) {
+ LOGGER.warn("Ignoring default.tenant.id value '{}'; using '{}'",
rawDefault, normalizedDefault);
+ }
+ LOGGER.info("Configuration updated -
singleTenantCompatibilityModeEnabled: {}, defaultTenantId: {}",
+ singleTenantMode, normalizedDefault);
this.singleTenantCompatibilityModeEnabled = singleTenantMode;
+ this.adminDefaultTenantId = normalizedDefault;
}
@@ -116,6 +127,11 @@ public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig
return singleTenantCompatibilityModeEnabled;
}
+ @Override
+ public String getAdminDefaultTenantId() {
+ return adminDefaultTenantId;
+ }
+
/**
* OSGi configuration for REST authentication.
@@ -136,5 +152,22 @@ public class DefaultRestAuthenticationConfig implements
RestAuthenticationConfig
description = "Serve clients from before Unomi 4.0, which send no
tenant API key, and run them all on one tenant"
)
boolean singletenantcompatibility_enabled() default false;
+
+ /**
+ * Default tenant ({@code default.tenant.id}). In 4.0 it is only used
for JAAS admin requests that
+ * omit {@code X-Unomi-Tenant-Id}; single-tenant compatibility mode
still uses tenant
+ * {@code "default"} on REST (UNOMI-1029).
+ *
+ * @return the default tenant id
+ */
+ @AttributeDefinition(
+ name = "Default tenant id",
+ description = "Tenant used when a JAAS-authenticated admin request
(REST or GraphQL) has no "
+ + "X-Unomi-Tenant-Id header (UNOMI-1008). Defaults to
\"default\"; set it to the tenant id "
+ + "chosen during a 3.0 migration. Not used yet by
single-tenant compatibility mode, whose "
+ + "REST requests always run on tenant \"default\"
(UNOMI-1029). "
+ + "Cannot be \"system\": that context needs an explicit
header."
+ )
+ String default_tenant_id() default
AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID;
}
}
diff --git a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
index 58a9475f4..a18dae6ac 100644
--- a/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
+++ b/rest/src/main/resources/org.apache.unomi.rest.authentication.cfg
@@ -24,7 +24,10 @@
# - A default tenant is automatically used for all operations
singletenantcompatibility.enabled =
${org.apache.unomi.rest.authentication.singleTenantCompatibilityModeEnabled:-false}
-# V2 Compatibility Default Tenant ID
-# Default tenant ID to use in single-tenant compatibility mode
-# This tenant will be used for all operations when single-tenant compatibility
mode is enabled
-# Should match the tenant ID used during migration (e.g., "default" or
"system")
+# Default tenant (UNOMI-1008)
+# In 4.0 this is the tenant used when a JAAS-authenticated admin request (REST
or GraphQL) has no
+# X-Unomi-Tenant-Id header. Single-tenant compatibility mode does not use it
yet: its REST requests
+# always run on tenant "default" (UNOMI-1029), so keep this at "default" while
that mode is on.
+# Defaults to "default"; set it to the tenant id chosen during the 3.0→4.0
migration. Cannot be "system":
+# the system context needs an explicit X-Unomi-Tenant-Id: system header.
+default.tenant.id =
${org.apache.unomi.rest.authentication.defaultTenantId:-default}
diff --git
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterAdminTenantTest.java
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterAdminTenantTest.java
new file mode 100644
index 000000000..d99b9f882
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterAdminTenantTest.java
@@ -0,0 +1,288 @@
+/*
+ * 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.ExecutionContext;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.AdminTenantConfig;
+import org.apache.unomi.api.tenants.Tenant;
+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.security.auth.Subject;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.MediaType;
+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 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.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * UNOMI-1008: after JAAS succeeds, the filter picks the tenant from {@code
X-Unomi-Tenant-Id}
+ * or from the configured admin default, and refuses unknown or forbidden
selections.
+ */
+class AuthenticationFilterAdminTenantTest {
+
+ private static final String TENANT_HEADER = "X-Unomi-Tenant-Id";
+
+ private RestAuthenticationConfig restAuthenticationConfig;
+ private TenantService tenantService;
+ private SecurityService securityService;
+ private ExecutionContextManager executionContextManager;
+ private JAASAuthenticationFilter jaasAuthenticationFilter;
+ /** Subject the stubbed JAAS login yields; {@code null} means JAAS refused
the credential. */
+ private Subject jaasSubject;
+ private AuthenticationFilter filter;
+ private ContainerRequestContext requestContext;
+
+ @BeforeEach
+ void setUp() {
+ restAuthenticationConfig = mock(RestAuthenticationConfig.class);
+ tenantService = mock(TenantService.class);
+ securityService = mock(SecurityService.class);
+ executionContextManager = mock(ExecutionContextManager.class);
+ when(restAuthenticationConfig.getAdminDefaultTenantId())
+ .thenReturn(AdminTenantConfig.DEFAULT_ADMIN_TENANT_ID);
+ // JAAS administrators hold ROLE_UNOMI_ADMIN or
ROLE_UNOMI_TENANT_ADMIN.
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class);
+ jaasSubject = new Subject();
+ filter = new AuthenticationFilter(
+ restAuthenticationConfig,
+ tenantService,
+ securityService,
+ executionContextManager,
+ jaasAuthenticationFilter) {
+ @Override
+ Subject authenticatedJaasSubject() {
+ return jaasSubject;
+ }
+ };
+ requestContext = mock(ContainerRequestContext.class);
+ }
+
+ @Test
+ void noHeaderUsesAdminDefaultTenant() {
+ Tenant tenant = tenant("default");
+ when(tenantService.getTenant("default")).thenReturn(tenant);
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn(null);
+ ExecutionContext context = new ExecutionContext("default", null, null);
+
when(executionContextManager.createContext("default")).thenReturn(context);
+
+ assertTrue(filter.applyAdminTenantContext(requestContext, false));
+ verify(executionContextManager).setCurrentContext(context);
+ verify(requestContext, never()).abortWith(any());
+ }
+
+ @Test
+ void missingDefaultTenantIsBadRequest() {
+ when(tenantService.getTenant("default")).thenReturn(null);
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn(null);
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, false));
+ assertAborted(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ void namedTenantIsUsed() {
+ Tenant tenant = tenant("acme");
+ when(tenantService.getTenant("acme")).thenReturn(tenant);
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("acme");
+ ExecutionContext context = new ExecutionContext("acme", null, null);
+
when(executionContextManager.createContext("acme")).thenReturn(context);
+
+ assertTrue(filter.applyAdminTenantContext(requestContext, false));
+ verify(executionContextManager).setCurrentContext(context);
+ }
+
+ @Test
+ void unknownTenantIsBadRequest() {
+ when(tenantService.getTenant("missing")).thenReturn(null);
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("missing");
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, false));
+ assertAborted(Response.Status.BAD_REQUEST.getStatusCode());
+ verify(executionContextManager, never()).setCurrentContext(any());
+ }
+
+ @Test
+ void unknownTenantWithoutAdministratorRoleIsForbiddenLikeAnExistingOne() {
+ // A realm account without a Unomi administrator role must not learn
which tenants exist.
+ when(securityService.hasSystemAccess()).thenReturn(false);
+ when(tenantService.getTenant("missing")).thenReturn(null);
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("missing");
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, false));
+ assertAborted(Response.Status.FORBIDDEN.getStatusCode());
+ }
+
+ @Test
+ void errorBodyIsPlainText() {
+ when(tenantService.getTenant("<b>x</b>")).thenReturn(null);
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("<b>x</b>");
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, false));
+ ArgumentCaptor<Response> captor =
ArgumentCaptor.forClass(Response.class);
+ verify(requestContext).abortWith(captor.capture());
+ assertEquals(MediaType.TEXT_PLAIN_TYPE,
captor.getValue().getMediaType());
+ }
+
+ @Test
+ void systemHeaderWithoutFullAdminIsForbidden() {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(false);
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("system");
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, false));
+ assertAborted(Response.Status.FORBIDDEN.getStatusCode());
+ }
+
+ @Test
+ void systemHeaderWithFullAdminUsesSystemContext() {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("system");
+
+ assertTrue(filter.applyAdminTenantContext(requestContext, false));
+ ArgumentCaptor<ExecutionContext> context =
ArgumentCaptor.forClass(ExecutionContext.class);
+ verify(executionContextManager).setCurrentContext(context.capture());
+ assertTrue(context.getValue().isSystem());
+ verify(tenantService, never()).getTenant("system");
+ }
+
+ @Test
+ void
tenantAdministrationWithoutHeaderUsesSystemContextBeforeAnyTenantExists() {
+ // Fresh install: /cxs/tenants must work so the first tenant can be
created.
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn(null);
+
+ assertTrue(filter.applyAdminTenantContext(requestContext, true));
+ ArgumentCaptor<ExecutionContext> context =
ArgumentCaptor.forClass(ExecutionContext.class);
+ verify(executionContextManager).setCurrentContext(context.capture());
+ assertTrue(context.getValue().isSystem());
+ verify(tenantService, never()).getTenant(any());
+ verify(requestContext, never()).abortWith(any());
+ }
+
+ @Test
+ void tenantAdministrationWithoutFullAdminFollowsTenantRules() {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(false);
+ when(tenantService.getTenant("default")).thenReturn(null);
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn(null);
+
+ assertFalse(filter.applyAdminTenantContext(requestContext, true));
+ assertAborted(Response.Status.BAD_REQUEST.getStatusCode());
+ }
+
+ @Test
+ void tenantAdministrationHonoursExplicitHeader() {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+ when(tenantService.getTenant("acme")).thenReturn(tenant("acme"));
+ when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("acme");
+ ExecutionContext context = new ExecutionContext("acme", null, null);
+
when(executionContextManager.createContext("acme")).thenReturn(context);
+
+ assertTrue(filter.applyAdminTenantContext(requestContext, true));
+ verify(executionContextManager).setCurrentContext(context);
+ }
+
+ // ------------------------------------------------------------------
filter() wiring
+
+ /** Fresh install: tenant administration must reach the system context
before any tenant exists. */
+ @Test
+ void filterGivesTenantAdministrationTheSystemContextWithoutAnyTenant()
throws IOException {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+ givenJaasRequest("tenants");
+
+ filter.filter(requestContext);
+
+ ArgumentCaptor<ExecutionContext> context =
ArgumentCaptor.forClass(ExecutionContext.class);
+ verify(executionContextManager).setCurrentContext(context.capture());
+ assertTrue(context.getValue().isSystem());
+ verify(requestContext, never()).abortWith(any());
+ }
+
+ /** Other private paths use the admin default tenant, never the system
context, without a header. */
+ @Test
+ void filterRefusesPrivatePathWhenAdminDefaultTenantIsMissing() throws
IOException {
+
when(securityService.hasRole(UnomiRoles.ADMINISTRATOR)).thenReturn(true);
+
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+ when(tenantService.getTenant("default")).thenReturn(null);
+ givenJaasRequest("profiles");
+
+ filter.filter(requestContext);
+
+ assertAborted(Response.Status.BAD_REQUEST.getStatusCode());
+ verify(executionContextManager, never()).setCurrentContext(any());
+ }
+
+ /** Wrong credentials answer 401 before any tenant is looked up, whatever
the tenant header says. */
+ @Test
+ void filterAnswersWrongCredentialsWith401WithoutLookingUpTheTenant()
throws IOException {
+ // CXF's JAAS filter refuses a credential by aborting with 401 and
returning normally.
+ jaasSubject = null;
+ doAnswer(invocation -> {
+
requestContext.abortWith(Response.status(Response.Status.UNAUTHORIZED).build());
+ return null;
+ }).when(jaasAuthenticationFilter).filter(requestContext);
+
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+ givenJaasRequest("profiles");
+
when(requestContext.getHeaderString(TENANT_HEADER)).thenReturn("missing");
+
+ filter.filter(requestContext);
+
+ assertAborted(Response.Status.UNAUTHORIZED.getStatusCode());
+ verify(tenantService, never()).getTenant(any());
+ }
+
+ private void givenJaasRequest(String path) {
+ UriInfo uriInfo = mock(UriInfo.class);
+ when(uriInfo.getPath()).thenReturn(path);
+ when(requestContext.getUriInfo()).thenReturn(uriInfo);
+ when(requestContext.getMethod()).thenReturn("GET");
+
when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn("Basic
"
+ +
Base64.getEncoder().encodeToString("karaf:a-strong-password".getBytes(StandardCharsets.UTF_8)));
+ }
+
+ private Tenant tenant(String id) {
+ Tenant tenant = new Tenant();
+ tenant.setItemId(id);
+ return tenant;
+ }
+
+ private void assertAborted(int status) {
+ ArgumentCaptor<Response> captor =
ArgumentCaptor.forClass(Response.class);
+ verify(requestContext).abortWith(captor.capture());
+ assertEquals(status, captor.getValue().getStatus());
+ }
+}
diff --git
a/rest/src/test/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfigTest.java
b/rest/src/test/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfigTest.java
new file mode 100644
index 000000000..ec9c5ce1d
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/authentication/impl/DefaultRestAuthenticationConfigTest.java
@@ -0,0 +1,62 @@
+/*
+ * 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.impl;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * UNOMI-1008: the admin default tenant is configurable, but can never be
blank or {@code system}.
+ */
+class DefaultRestAuthenticationConfigTest {
+
+ @Test
+ void usesConfiguredAdminDefaultTenant() {
+ assertEquals("acme", adminDefaultTenantFor(" acme "));
+ }
+
+ @Test
+ void blankOrSystemFallsBackToDefault() {
+ assertEquals("default", adminDefaultTenantFor(""));
+ assertEquals("default", adminDefaultTenantFor(null));
+ assertEquals("default", adminDefaultTenantFor(" system "));
+ }
+
+ @Test
+ void reconfigurationReplacesThePreviousValue() {
+ DefaultRestAuthenticationConfig restConfig = new
DefaultRestAuthenticationConfig();
+ restConfig.modified(config("acme"));
+ restConfig.modified(config("system"));
+
+ assertEquals("default", restConfig.getAdminDefaultTenantId());
+ }
+
+ private static String adminDefaultTenantFor(String configured) {
+ DefaultRestAuthenticationConfig restConfig = new
DefaultRestAuthenticationConfig();
+ restConfig.modified(config(configured));
+ return restConfig.getAdminDefaultTenantId();
+ }
+
+ private static DefaultRestAuthenticationConfig.Config config(String
adminDefaultTenantId) {
+ DefaultRestAuthenticationConfig.Config config =
mock(DefaultRestAuthenticationConfig.Config.class);
+ when(config.default_tenant_id()).thenReturn(adminDefaultTenantId);
+ return config;
+ }
+}