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;
+    }
+}

Reply via email to