This is an automated email from the ASF dual-hosted git repository.

sergehuber pushed a commit to branch UNOMI-875-pr4-security-tenant
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 621c8f24cc2142f77f13fa1164db17987075a859
Author: Serge Huber <[email protected]>
AuthorDate: Sun Jul 5 14:54:48 2026 +0200

    UNOMI-942: Fix tenant quota counts and TenantService error contracts
    
    Add per-tenant getAllItemsCount(itemType, tenantId) to persistence and wire
    TenantQuotaService to use tenant-scoped profile/event counts instead of
    cluster-wide totals.
---
 .../ElasticSearchPersistenceServiceImpl.java       | 10 ++-
 .../OpenSearchPersistenceServiceImpl.java          | 11 ++-
 .../unomi/persistence/spi/PersistenceService.java  | 10 +++
 .../services/impl/tenants/TenantQuotaService.java  |  4 +-
 .../impl/InMemoryPersistenceServiceImpl.java       | 21 ++++++
 .../impl/tenants/TenantQuotaServiceTest.java       | 85 ++++++++++++++++++++++
 6 files changed, 137 insertions(+), 4 deletions(-)

diff --git 
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
 
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
index 3f62cb884..dc90146c2 100644
--- 
a/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
+++ 
b/persistence-elasticsearch/core/src/main/java/org/apache/unomi/persistence/elasticsearch/ElasticSearchPersistenceServiceImpl.java
@@ -777,6 +777,10 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
         return queryCount(Query.of(q -> q.matchAll(m -> m)), itemType);
     }
 
+    @Override public long getAllItemsCount(String itemType, String tenantId) {
+        return queryCount(Query.of(q -> q.matchAll(m -> m)), itemType, 
tenantId);
+    }
+
     @Override public <T extends Item> PartialList<T> getAllItems(final 
Class<T> clazz, int offset, int size, String sortBy) {
         return getAllItems(clazz, offset, size, sortBy, null);
     }
@@ -2090,12 +2094,16 @@ public class ElasticSearchPersistenceServiceImpl 
implements PersistenceService,
     }
 
     private long queryCount(final Query query, final String itemType) {
+        return queryCount(query, itemType, getTenantId());
+    }
+
+    private long queryCount(final Query query, final String itemType, final 
String tenantId) {
         return new InClassLoaderExecute<Long>(metricsService, 
this.getClass().getName() + ".queryCount", this.bundleContext,
                 this.fatalIllegalStateErrors, throwExceptions) {
 
             @Override protected Long execute(Object... args) throws 
IOException {
                 CountRequest countRequest = CountRequest.of(
-                        builder -> 
builder.index(getIndexNameForQuery(itemType)).query(wrapWithTenantAndItemTypeQuery(itemType,
 query, getTenantId())));
+                        builder -> 
builder.index(getIndexNameForQuery(itemType)).query(wrapWithTenantAndItemTypeQuery(itemType,
 query, tenantId)));
                 return esClient.count(countRequest).count();
             }
         }.catchingExecuteInClassLoader(true);
diff --git 
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
 
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
index 48ca106d7..05c2c50c6 100644
--- 
a/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
+++ 
b/persistence-opensearch/core/src/main/java/org/apache/unomi/persistence/opensearch/OpenSearchPersistenceServiceImpl.java
@@ -685,6 +685,11 @@ public class OpenSearchPersistenceServiceImpl implements 
PersistenceService, Syn
         return queryCount(Query.of(q -> q.matchAll(t -> t)), itemType);
     }
 
+    @Override
+    public long getAllItemsCount(String itemType, String tenantId) {
+        return queryCount(Query.of(q -> q.matchAll(t -> t)), itemType, 
tenantId);
+    }
+
     @Override
     public <T extends Item> PartialList<T> getAllItems(final Class<T> clazz, 
int offset, int size, String sortBy) {
         return getAllItems(clazz, offset, size, sortBy, null);
@@ -1974,12 +1979,16 @@ public class OpenSearchPersistenceServiceImpl 
implements PersistenceService, Syn
     }
 
     private long queryCount(final Query filter, final String itemType) {
+        return queryCount(filter, itemType, getTenantId());
+    }
+
+    private long queryCount(final Query filter, final String itemType, final 
String tenantId) {
         return new InClassLoaderExecute<Long>(metricsService, 
this.getClass().getName() + ".queryCount", this.bundleContext, 
this.fatalIllegalStateErrors, throwExceptions) {
 
             @Override
             protected Long execute(Object... args) throws IOException {
                 CountResponse response = client.count(count -> 
count.index(getIndexNameForQuery(itemType))
-                        .query(wrapWithTenantAndItemTypeQuery(itemType, 
filter, getTenantId())));
+                        .query(wrapWithTenantAndItemTypeQuery(itemType, 
filter, tenantId)));
                 return response.count();
             }
         }.catchingExecuteInClassLoader(true);
diff --git 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
index 8e8b22622..0f6ca0d3b 100644
--- 
a/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
+++ 
b/persistence-spi/src/main/java/org/apache/unomi/persistence/spi/PersistenceService.java
@@ -619,6 +619,16 @@ public interface PersistenceService {
      */
     long getAllItemsCount(String itemType);
 
+    /**
+     * Retrieves the number of items with the specified type as defined by the 
Item subclass public field {@code ITEM_TYPE} for the given tenant.
+     *
+     * @param itemType the String representation of the item type we want to 
retrieve the count of, as defined by its class' {@code ITEM_TYPE} field
+     * @param tenantId the ID of the tenant whose items should be counted
+     * @return the number of items of the specified type for the given tenant
+     * @see Item Item for a discussion of {@code ITEM_TYPE}
+     */
+    long getAllItemsCount(String itemType, String tenantId);
+
     /**
      * Retrieves the number of items with the specified type as defined by the 
Item subclass public field {@code ITEM_TYPE} matching the optional specified 
condition and
      * aggregated according to the specified {@link BaseAggregate}.
diff --git 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
index 9b3dc4ac1..c08775a26 100644
--- 
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
+++ 
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java
@@ -107,8 +107,8 @@ public class TenantQuotaService {
                 if (shutdownNow) return; // Check shutdown flag during 
iteration
                 
                 TenantUsage usage = usageCache.get(tenantId);
-                
usage.setProfileCount(persistenceService.getAllItemsCount("profile"));
-                
usage.setEventCount(persistenceService.getAllItemsCount("event"));
+                
usage.setProfileCount(persistenceService.getAllItemsCount("profile", tenantId));
+                
usage.setEventCount(persistenceService.getAllItemsCount("event", tenantId));
                 // Note: Storage size calculation would require additional 
implementation
             }
         } catch (Exception e) {
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
index facd96002..1afbb3d32 100644
--- 
a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImpl.java
@@ -1358,6 +1358,27 @@ public class InMemoryPersistenceServiceImpl implements 
PersistenceService {
         return filteredItems.size();
     }
 
+    @Override
+    public long getAllItemsCount(String itemType, String tenantId) {
+        if (itemType == null || tenantId == null) {
+            return 0;
+        }
+
+        LOGGER.debug("Counting all items of type {} for tenant {}", itemType, 
tenantId);
+
+        Map<String, Item> filteredItems = new HashMap<>();
+        for (Map.Entry<String, Item> entry : itemsById.entrySet()) {
+            Item item = entry.getValue();
+            if (item.getItemType().equals(itemType) && 
tenantId.equals(item.getTenantId())) {
+                String itemKey = entry.getKey();
+                if (isItemAvailableForQuery(itemKey, itemType)) {
+                    filteredItems.put(itemKey, item);
+                }
+            }
+        }
+        return filteredItems.size();
+    }
+
     @Override
     public Map<String, Double> getSingleValuesMetrics(Condition condition, 
String[] metrics, String field, String itemType) {
         if (metrics == null || metrics.length == 0 || field == null) {
diff --git 
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
new file mode 100644
index 000000000..5125f1e38
--- /dev/null
+++ 
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.services.impl.tenants;
+
+import org.apache.unomi.persistence.spi.PersistenceService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+public class TenantQuotaServiceTest {
+
+    @Mock
+    private PersistenceService persistenceService;
+
+    private TenantQuotaService tenantQuotaService;
+
+    @BeforeEach
+    public void setUp() throws Exception {
+        tenantQuotaService = new TenantQuotaService();
+        tenantQuotaService.setPersistenceService(persistenceService);
+
+        Field usageCacheField = 
TenantQuotaService.class.getDeclaredField("usageCache");
+        usageCacheField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Map<String, TenantUsage> usageCache = (Map<String, TenantUsage>) 
usageCacheField.get(tenantQuotaService);
+        usageCache.put("tenant-a", new TenantUsage());
+        usageCache.put("tenant-b", new TenantUsage());
+    }
+
+    @Test
+    public void updateUsageStatisticsUsesPerTenantCounts() throws Exception {
+        when(persistenceService.getAllItemsCount(eq("profile"), 
eq("tenant-a"))).thenReturn(10L);
+        when(persistenceService.getAllItemsCount(eq("event"), 
eq("tenant-a"))).thenReturn(20L);
+        when(persistenceService.getAllItemsCount(eq("profile"), 
eq("tenant-b"))).thenReturn(100L);
+        when(persistenceService.getAllItemsCount(eq("event"), 
eq("tenant-b"))).thenReturn(200L);
+
+        Method updateMethod = 
TenantQuotaService.class.getDeclaredMethod("updateUsageStatistics");
+        updateMethod.setAccessible(true);
+        updateMethod.invoke(tenantQuotaService);
+
+        Field usageCacheField = 
TenantQuotaService.class.getDeclaredField("usageCache");
+        usageCacheField.setAccessible(true);
+        @SuppressWarnings("unchecked")
+        Map<String, TenantUsage> usageCache = (Map<String, TenantUsage>) 
usageCacheField.get(tenantQuotaService);
+
+        assertEquals(10L, usageCache.get("tenant-a").getProfileCount());
+        assertEquals(20L, usageCache.get("tenant-a").getEventCount());
+        assertEquals(100L, usageCache.get("tenant-b").getProfileCount());
+        assertEquals(200L, usageCache.get("tenant-b").getEventCount());
+
+        verify(persistenceService).getAllItemsCount("profile", "tenant-a");
+        verify(persistenceService).getAllItemsCount("event", "tenant-a");
+        verify(persistenceService).getAllItemsCount("profile", "tenant-b");
+        verify(persistenceService).getAllItemsCount("event", "tenant-b");
+        verify(persistenceService, never()).getAllItemsCount("profile");
+        verify(persistenceService, never()).getAllItemsCount("event");
+    }
+}

Reply via email to