diff --git a/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java b/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java deleted file mode 100644 index 6d9e3359c..000000000 --- a/api/src/main/java/org/apache/unomi/api/tenants/ResourceQuota.java +++ /dev/null @@ -1,258 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.api.tenants; - -import java.util.HashMap; -import java.util.Map; - -/** - * Defines resource quotas and limits for a tenant. - * This class manages various resource constraints to ensure fair usage and prevent abuse. - * Each quota represents a maximum limit that the tenant cannot exceed. - * When a quota is reached, the system will prevent further resource allocation until - * resources are freed or the quota is increased. - */ -public class ResourceQuota { - /** - * The maximum number of profiles that can be stored for this tenant. - * When this limit is reached, attempts to create new profiles will be rejected. - */ - private long maxProfiles; - - /** - * The maximum number of events that can be processed per time period for this tenant. - * Events beyond this limit will be rejected until the next period begins. - */ - private long maxEvents; - - /** - * The maximum number of rules that can be defined for this tenant. - * Attempts to create rules beyond this limit will be rejected. - */ - private long maxRules; - - /** - * The maximum number of segments that can be defined for this tenant. - * Attempts to create segments beyond this limit will be rejected. - */ - private long maxSegments; - - /** - * The maximum storage size in bytes that this tenant can use. - * This includes all data associated with the tenant including profiles, - * events, rules, and other stored data. - */ - private long maxStorageSize; - - /** - * The maximum number of concurrent API requests that can be processed - * for this tenant. Additional requests will be rejected with a 429 status - * until ongoing requests complete. - */ - private int maxConcurrentRequests; - - /** - * The maximum number of API keys (both public and private) that can be - * generated for this tenant. This includes both active and historical keys - * stored for auditing purposes. - */ - private int maxApiKeys; - - /** - * The maximum number of days that data will be retained for this tenant. - * Data older than this period will be automatically purged from the system. - * A value of 0 indicates no automatic purging. - */ - private long maxDataRetentionDays; - - /** - * The maximum number of API requests that can be made per time period - * for this tenant. Requests beyond this limit will be rejected with - * a 429 status until the next period begins. - */ - private long maxRequests; - - /** - * Custom quota limits that can be defined for tenant-specific needs. - * The map keys represent the quota type and the values represent the limits. - * These quotas can be used to limit custom resources or actions specific - * to certain tenant use cases. - */ - private Map customQuotas = new HashMap<>(); - - /** - * Gets the maximum number of profiles allowed for the tenant. - * @return the maximum number of profiles - */ - public long getMaxProfiles() { - return maxProfiles; - } - - /** - * Sets the maximum number of profiles allowed for the tenant. - * @param maxProfiles the maximum number of profiles to set (must be >= 0) - */ - public void setMaxProfiles(long maxProfiles) { - this.maxProfiles = maxProfiles; - } - - /** - * Gets the maximum number of events allowed for the tenant per time period. - * @return the maximum number of events - */ - public long getMaxEvents() { - return maxEvents; - } - - /** - * Sets the maximum number of events allowed for the tenant per time period. - * @param maxEvents the maximum number of events to set (must be >= 0) - */ - public void setMaxEvents(long maxEvents) { - this.maxEvents = maxEvents; - } - - /** - * Gets the maximum number of rules allowed for the tenant. - * @return the maximum number of rules - */ - public long getMaxRules() { - return maxRules; - } - - /** - * Sets the maximum number of rules allowed for the tenant. - * @param maxRules the maximum number of rules to set (must be >= 0) - */ - public void setMaxRules(long maxRules) { - this.maxRules = maxRules; - } - - /** - * Gets the maximum number of segments allowed for the tenant. - * @return the maximum number of segments - */ - public long getMaxSegments() { - return maxSegments; - } - - /** - * Sets the maximum number of segments allowed for the tenant. - * @param maxSegments the maximum number of segments to set (must be >= 0) - */ - public void setMaxSegments(long maxSegments) { - this.maxSegments = maxSegments; - } - - /** - * Gets the maximum storage size in bytes allowed for the tenant. - * @return the maximum storage size in bytes - */ - public long getMaxStorageSize() { - return maxStorageSize; - } - - /** - * Sets the maximum storage size in bytes allowed for the tenant. - * @param maxStorageSize the maximum storage size in bytes to set (must be >= 0) - */ - public void setMaxStorageSize(long maxStorageSize) { - this.maxStorageSize = maxStorageSize; - } - - /** - * Gets the maximum number of concurrent requests allowed for the tenant. - * @return the maximum number of concurrent requests - */ - public int getMaxConcurrentRequests() { - return maxConcurrentRequests; - } - - /** - * Sets the maximum number of concurrent requests allowed for the tenant. - * @param maxConcurrentRequests the maximum number of concurrent requests to set (must be >= 0) - */ - public void setMaxConcurrentRequests(int maxConcurrentRequests) { - this.maxConcurrentRequests = maxConcurrentRequests; - } - - /** - * Gets the maximum number of API keys allowed for the tenant. - * @return the maximum number of API keys - */ - public int getMaxApiKeys() { - return maxApiKeys; - } - - /** - * Sets the maximum number of API keys allowed for the tenant. - * @param maxApiKeys the maximum number of API keys to set (must be >= 0) - */ - public void setMaxApiKeys(int maxApiKeys) { - this.maxApiKeys = maxApiKeys; - } - - /** - * Gets the maximum number of days to retain data for the tenant. - * @return the maximum data retention period in days (0 for no limit) - */ - public long getMaxDataRetentionDays() { - return maxDataRetentionDays; - } - - /** - * Sets the maximum number of days to retain data for the tenant. - * @param maxDataRetentionDays the maximum data retention period in days to set (0 for no limit, must be >= 0) - */ - public void setMaxDataRetentionDays(long maxDataRetentionDays) { - this.maxDataRetentionDays = maxDataRetentionDays; - } - - /** - * Gets the maximum number of API requests allowed per time period. - * @return the maximum number of requests per time period - */ - public long getMaxRequests() { - return maxRequests; - } - - /** - * Sets the maximum number of API requests allowed per time period. - * @param maxRequests the maximum number of requests to set (must be >= 0) - */ - public void setMaxRequests(long maxRequests) { - this.maxRequests = maxRequests; - } - - /** - * Gets the custom quotas map. Custom quotas can be used to define - * tenant-specific resource limits beyond the standard quotas. - * @return map of custom quota types to their limits - */ - public Map getCustomQuotas() { - return customQuotas; - } - - /** - * Sets the custom quotas map. Custom quotas can be used to define - * tenant-specific resource limits beyond the standard quotas. - * @param customQuotas map of custom quota types to their limits (values must be >= 0) - */ - public void setCustomQuotas(Map customQuotas) { - this.customQuotas = customQuotas; - } -} diff --git a/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java index a4d1dfc5c..6de2f11fe 100644 --- a/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java +++ b/api/src/main/java/org/apache/unomi/api/tenants/Tenant.java @@ -20,6 +20,7 @@ import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.xml.bind.annotation.XmlTransient; @@ -27,9 +28,9 @@ * Represents a tenant in the system. * A tenant is an isolated entity within the system with its own users, data, and configuration. * Each tenant has its own set of API keys (public and private) for authentication and authorization, - * resource quotas to limit usage, and event permissions to control access to specific event types. + * and event permissions to control access to specific event types. * This class extends the base Item class and provides functionality for managing tenant - * settings, resource quotas, and lifecycle. + * settings and lifecycle. */ public class Tenant extends Item { /** @@ -62,11 +63,6 @@ public class Tenant extends Item { */ private Date lastModificationDate; - /** - * The resource quota limits for the tenant. - * This includes limits on profiles, events, and requests. - */ - private ResourceQuota resourceQuota; /** * The list of all API keys (both active and historical) associated with the tenant. @@ -185,22 +181,6 @@ public void setLastModificationDate(Date lastModificationDate) { this.lastModificationDate = lastModificationDate; } - /** - * Gets the tenant's resource quota settings. - * @return the resource quota settings - */ - public ResourceQuota getResourceQuota() { - return resourceQuota; - } - - /** - * Sets the tenant's resource quota settings. - * @param resourceQuota the resource quota settings to set - */ - public void setResourceQuota(ResourceQuota resourceQuota) { - this.resourceQuota = resourceQuota; - } - /** * Gets the list of all API keys associated with the tenant. * This includes both active and historical keys for auditing purposes. @@ -278,17 +258,7 @@ public void setAuthorizedIPs(Set authorizedIPs) { */ @XmlTransient public String getPrivateApiKey() { - if (apiKeys == null) { - return null; - } - - return apiKeys.stream() - .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE) - .filter(key -> !key.isRevoked()) - .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date())) - .max(Comparator.comparing(ApiKey::getCreationDate)) - .map(ApiKey::getMaskedKey) - .orElse(null); + return getLatestMaskedActiveKey(ApiKey.ApiKeyType.PRIVATE); } /** @@ -301,17 +271,7 @@ public String getPrivateApiKey() { */ @XmlTransient public String getPublicApiKey() { - if (apiKeys == null) { - return null; - } - - return apiKeys.stream() - .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC) - .filter(key -> !key.isRevoked()) - .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date())) - .max(Comparator.comparing(ApiKey::getCreationDate)) - .map(ApiKey::getMaskedKey) - .orElse(null); + return getLatestMaskedActiveKey(ApiKey.ApiKeyType.PUBLIC); } /** @@ -321,15 +281,7 @@ public String getPublicApiKey() { */ @XmlTransient public List getActivePrivateApiKeys() { - if (apiKeys == null) { - return new ArrayList<>(); - } - - return apiKeys.stream() - .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PRIVATE) - .filter(key -> !key.isRevoked()) - .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date())) - .collect(Collectors.toList()); + return streamActiveApiKeysOfType(ApiKey.ApiKeyType.PRIVATE).collect(Collectors.toList()); } /** @@ -339,15 +291,7 @@ public List getActivePrivateApiKeys() { */ @XmlTransient public List getActivePublicApiKeys() { - if (apiKeys == null) { - return new ArrayList<>(); - } - - return apiKeys.stream() - .filter(key -> key.getKeyType() == ApiKey.ApiKeyType.PUBLIC) - .filter(key -> !key.isRevoked()) - .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date())) - .collect(Collectors.toList()); + return streamActiveApiKeysOfType(ApiKey.ApiKeyType.PUBLIC).collect(Collectors.toList()); } /** @@ -360,10 +304,30 @@ public List getActiveApiKeys() { if (apiKeys == null) { return new ArrayList<>(); } - + return apiKeys.stream() - .filter(key -> !key.isRevoked()) - .filter(key -> key.getExpirationDate() == null || key.getExpirationDate().after(new Date())) + .filter(this::isActiveApiKey) .collect(Collectors.toList()); } + + private String getLatestMaskedActiveKey(ApiKey.ApiKeyType keyType) { + return streamActiveApiKeysOfType(keyType) + .max(Comparator.comparing(ApiKey::getCreationDate)) + .map(ApiKey::getMaskedKey) + .orElse(null); + } + + private Stream streamActiveApiKeysOfType(ApiKey.ApiKeyType keyType) { + if (apiKeys == null) { + return Stream.empty(); + } + return apiKeys.stream() + .filter(key -> key.getKeyType() == keyType) + .filter(this::isActiveApiKey); + } + + private boolean isActiveApiKey(ApiKey key) { + return !key.isRevoked() + && (key.getExpirationDate() == null || key.getExpirationDate().after(new Date())); + } } diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantEventPurgeResult.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantEventPurgeResult.java new file mode 100644 index 000000000..7199a64b1 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantEventPurgeResult.java @@ -0,0 +1,76 @@ +/* + * 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; + +/** + * Result of a tenant-scoped event retention purge request. + */ +public class TenantEventPurgeResult { + + private String tenantId; + private int retentionDays; + /** Events matching the retention cutoff before delete-by-query was submitted; not a post-delete count. */ + private long eventsMatched; + /** + * {@code true} if the delete-by-query completed successfully; {@code false} if the + * persistence layer reported a failure (see server logs for the cause). This is not a + * partial-success indicator: {@link #eventsMatched} is a pre-delete estimate only, so a + * {@code true} result does not by itself confirm how many events were actually removed. + */ + private boolean purgeRequested; + private long requestedAt; + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public int getRetentionDays() { + return retentionDays; + } + + public void setRetentionDays(int retentionDays) { + this.retentionDays = retentionDays; + } + + public long getEventsMatched() { + return eventsMatched; + } + + public void setEventsMatched(long eventsMatched) { + this.eventsMatched = eventsMatched; + } + + public boolean isPurgeRequested() { + return purgeRequested; + } + + public void setPurgeRequested(boolean purgeRequested) { + this.purgeRequested = purgeRequested; + } + + public long getRequestedAt() { + return requestedAt; + } + + public void setRequestedAt(long requestedAt) { + this.requestedAt = requestedAt; + } +} diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantScopeUsage.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantScopeUsage.java new file mode 100644 index 000000000..3029b1fb4 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantScopeUsage.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * Segment and rule counts for one scope within a tenant. + */ +public class TenantScopeUsage { + + private String scopeId; + private long segmentCount; + private long ruleCount; + + public String getScopeId() { + return scopeId; + } + + public void setScopeId(String scopeId) { + this.scopeId = scopeId; + } + + public long getSegmentCount() { + return segmentCount; + } + + public void setSegmentCount(long segmentCount) { + this.segmentCount = segmentCount; + } + + public long getRuleCount() { + return ruleCount; + } + + public void setRuleCount(long ruleCount) { + this.ruleCount = ruleCount; + } +} diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java new file mode 100644 index 000000000..5b78ac10d --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java @@ -0,0 +1,169 @@ +/* + * 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 java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Read-only usage snapshot for a tenant. Values are refreshed on a background schedule + * (see {@link TenantUsageService}) and may be stale until the next collection cycle. + * + *

Profile, scope, segment, and rule counts are point-in-time totals. {@link #eventCount} + * counts events whose {@code timeStamp} falls within {@link #periodStart} (inclusive) and + * {@link #periodEnd} (exclusive).

+ */ +public class TenantUsage { + + private String tenantId; + /** Normalized period label, e.g. {@code 2026-07} for a calendar month. */ + private String period; + /** Inclusive start of the reporting period (epoch millis, UTC). */ + private long periodStart; + /** Exclusive end of the reporting period (epoch millis, UTC). */ + private long periodEnd; + private long profileCount; + /** Events in the calendar month identified by {@link #period}. */ + private long eventCount; + /** Tenant scopes excluding {@code systemscope}. */ + private long scopeCount; + private long segmentCount; + private long ruleCount; + /** Document count across tenant indices (not byte size). */ + private long storageDocumentCount; + /** Active API keys on the tenant record. */ + private long activeApiKeyCount; + /** In-memory REST request counter for this tenant since the Unomi process started. */ + private long restRequestCount; + private List scopeUsages = new ArrayList<>(); + private long collectedAt; + + public String getTenantId() { + return tenantId; + } + + public void setTenantId(String tenantId) { + this.tenantId = tenantId; + } + + public String getPeriod() { + return period; + } + + public void setPeriod(String period) { + this.period = period; + } + + public long getPeriodStart() { + return periodStart; + } + + public void setPeriodStart(long periodStart) { + this.periodStart = periodStart; + } + + public long getPeriodEnd() { + return periodEnd; + } + + public void setPeriodEnd(long periodEnd) { + this.periodEnd = periodEnd; + } + + public long getProfileCount() { + return profileCount; + } + + public void setProfileCount(long profileCount) { + this.profileCount = profileCount; + } + + public long getEventCount() { + return eventCount; + } + + public void setEventCount(long eventCount) { + this.eventCount = eventCount; + } + + public long getScopeCount() { + return scopeCount; + } + + public void setScopeCount(long scopeCount) { + this.scopeCount = scopeCount; + } + + public long getSegmentCount() { + return segmentCount; + } + + public void setSegmentCount(long segmentCount) { + this.segmentCount = segmentCount; + } + + public long getRuleCount() { + return ruleCount; + } + + public void setRuleCount(long ruleCount) { + this.ruleCount = ruleCount; + } + + public long getStorageDocumentCount() { + return storageDocumentCount; + } + + public void setStorageDocumentCount(long storageDocumentCount) { + this.storageDocumentCount = storageDocumentCount; + } + + public long getActiveApiKeyCount() { + return activeApiKeyCount; + } + + public void setActiveApiKeyCount(long activeApiKeyCount) { + this.activeApiKeyCount = activeApiKeyCount; + } + + public long getRestRequestCount() { + return restRequestCount; + } + + public void setRestRequestCount(long restRequestCount) { + this.restRequestCount = restRequestCount; + } + + public List getScopeUsages() { + return Collections.unmodifiableList(scopeUsages); + } + + public void setScopeUsages(List scopeUsages) { + // Copy defensively: callers (including the internal usage cache) must not be able to + // mutate this snapshot's state through a shared list reference after construction. + this.scopeUsages = scopeUsages != null ? new ArrayList<>(scopeUsages) : new ArrayList<>(); + } + + public long getCollectedAt() { + return collectedAt; + } + + public void setCollectedAt(long collectedAt) { + this.collectedAt = collectedAt; + } +} diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java new file mode 100644 index 000000000..789584683 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java @@ -0,0 +1,60 @@ +/* + * 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; + +/** + * Provides read-only per-tenant usage metrics and tenant-scoped maintenance operations + * for operators and upstream control planes. Unomi does not enforce quotas; callers use + * these metrics to apply limits upstream. + */ +public interface TenantUsageService { + + /** Calendar month containing the current instant (UTC). */ + String DEFAULT_PERIOD = "current-month"; + + /** + * Minimum retention window accepted by {@link #purgeEventsOlderThan(String, int)}, guarding + * against accidentally purging recent/active event data with a mistakenly small value. + */ + int MIN_EVENT_RETENTION_DAYS = 7; + + /** + * Returns cached usage for the tenant, refreshing on demand when no snapshot exists yet. + * + * @param tenantId tenant identifier + * @param period reporting window: {@value #DEFAULT_PERIOD}, {@code YYYY-MM}, or legacy {@code 24h} + * @return usage snapshot, or {@code null} if the tenant does not exist + */ + TenantUsage getUsage(String tenantId, String period); + + /** + * Records one authenticated REST request for the tenant (in-memory counter). + */ + void recordRestRequest(String tenantId); + + /** + * Deletes events for the tenant whose {@code timeStamp} is at least {@code retentionDays} + * days old (the boundary day itself is included). Runs under an explicit tenant execution + * context. + * + * @param tenantId tenant identifier + * @param retentionDays age cutoff in whole days (minimum {@value #MIN_EVENT_RETENTION_DAYS}) + * @return purge summary, or {@code null} if the tenant does not exist + * @throws IllegalArgumentException when {@code retentionDays} is below the minimum + */ + TenantEventPurgeResult purgeEventsOlderThan(String tenantId, int retentionDays); +} diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java index 9c6c6803c..a9ef9a595 100644 --- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java @@ -29,10 +29,12 @@ import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.unomi.api.Profile; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Event; import org.apache.unomi.api.query.Query; import org.apache.unomi.api.tenants.ApiKey; import org.apache.unomi.api.tenants.ApiKeyCreationResult; -import org.apache.unomi.api.tenants.ResourceQuota; import org.apache.unomi.api.tenants.Tenant; import org.junit.Assert; import org.junit.Before; @@ -43,6 +45,9 @@ import org.ops4j.pax.exam.spi.reactors.PerSuite; import org.apache.http.util.EntityUtils; +import java.time.Instant; +import java.time.YearMonth; +import java.time.temporal.ChronoUnit; import java.util.*; import java.util.Base64; @@ -103,10 +108,7 @@ public void testRestEndpoint() throws Exception { // Test update tenant retrievedTenant.setName("Updated Rest Test Tenant"); - ResourceQuota quota = new ResourceQuota(); - quota.setMaxProfiles(1000L); - quota.setMaxEvents(5000L); - retrievedTenant.setResourceQuota(quota); + retrievedTenant.setDescription("Updated REST test description"); HttpPut updateRequest = new HttpPut(getFullUrl(REST_ENDPOINT + "/" + retrievedTenant.getItemId())); updateRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(retrievedTenant), ContentType.APPLICATION_JSON)); @@ -119,7 +121,7 @@ public void testRestEndpoint() throws Exception { } Assert.assertEquals("Tenant name should be updated", "Updated Rest Test Tenant", updatedTenant.getName()); - Assert.assertEquals("Tenant quota should be updated", (Long) 1000L, (Long) updatedTenant.getResourceQuota().getMaxProfiles()); + Assert.assertEquals("Tenant description should be updated", "Updated REST test description", updatedTenant.getDescription()); // Test generate new API key String generateKeyUrl = String.format("%s/%s/apikeys?type=%s&validityDays=30", @@ -624,4 +626,252 @@ public void testContextJsonAuthenticationDetection() throws Exception { 200, response.getStatusLine().getStatusCode()); } } + + @Test + public void testTenantUsageEndpoint() throws Exception { + Tenant tenant = tenantService.createTenant("usage-test-tenant", Collections.emptyMap()); + try { + String usageUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/usage"); + String usageResponse; + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(usageUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Usage endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + usageResponse = EntityUtils.toString(response.getEntity()); + } + Map usage = getObjectMapper().readValue(usageResponse, Map.class); + Assert.assertEquals("Usage tenantId should match", tenant.getItemId(), usage.get("tenantId")); + Assert.assertTrue("Default period should normalize to YYYY-MM", usage.get("period").toString().matches("\\d{4}-\\d{2}")); + Assert.assertNotNull("periodStart should be present", usage.get("periodStart")); + Assert.assertNotNull("periodEnd should be present", usage.get("periodEnd")); + Assert.assertNotNull("scopeCount should be present", usage.get("scopeCount")); + Assert.assertNotNull("activeApiKeyCount should be present", usage.get("activeApiKeyCount")); + Assert.assertNotNull("scopeUsages should be present", usage.get("scopeUsages")); + Assert.assertNotNull("collectedAt should be present", usage.get("collectedAt")); + + String legacyPeriodUrl = usageUrl + "?period=24h"; + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(legacyPeriodUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Legacy 24h period should return 200", 200, response.getStatusLine().getStatusCode()); + } + + String badPeriodUrl = usageUrl + "?period=7d"; + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(badPeriodUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Unsupported period should return 400", 400, response.getStatusLine().getStatusCode()); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantEventPurgeEndpoint() throws Exception { + Tenant tenant = tenantService.createTenant("purge-test-tenant", Collections.emptyMap()); + try { + String purgeUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/purge/events?retentionDays=90"); + String purgeResponse; + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(purgeUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Purge endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + purgeResponse = EntityUtils.toString(response.getEntity()); + } + Map purge = getObjectMapper().readValue(purgeResponse, Map.class); + Assert.assertEquals("Purge tenantId should match", tenant.getItemId(), purge.get("tenantId")); + Assert.assertEquals("Retention days should match", 90, purge.get("retentionDays")); + Assert.assertNotNull("eventsMatched should be present", purge.get("eventsMatched")); + Assert.assertNotNull("purgeRequested should be present", purge.get("purgeRequested")); + + String lowRetentionUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/purge/events?retentionDays=3"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(lowRetentionUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Retention below minimum should return 400", 400, response.getStatusLine().getStatusCode()); + } + + String missingTenantUrl = getFullUrl(REST_ENDPOINT + "/missing-tenant/purge/events?retentionDays=90"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(missingTenantUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Missing tenant should return 404", 404, response.getStatusLine().getStatusCode()); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantEventPurgeEndpointDeletesOnlyEventsPastRetention() throws Exception { + Tenant tenant = tenantService.createTenant("purge-deletion-tenant", Collections.emptyMap()); + try { + Date oldTimestamp = Date.from(Instant.now().minus(100, ChronoUnit.DAYS)); + Date recentTimestamp = new Date(); + + executionContextManager.executeAsTenant(tenant.getItemId(), () -> { + Event oldEvent = new Event(); + oldEvent.setItemId("purge-old-event"); + oldEvent.setEventType("pageView"); + oldEvent.setProfileId("purge-deletion-profile"); + oldEvent.setScope("purge-scope"); + oldEvent.setTimeStamp(oldTimestamp); + persistenceService.save(oldEvent); + + Event recentEvent = new Event(); + recentEvent.setItemId("purge-recent-event"); + recentEvent.setEventType("pageView"); + recentEvent.setProfileId("purge-deletion-profile"); + recentEvent.setScope("purge-scope"); + recentEvent.setTimeStamp(recentTimestamp); + persistenceService.save(recentEvent); + }); + persistenceService.refresh(); + + String purgeUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/purge/events?retentionDays=90"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(purgeUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Purge endpoint should return 200", 200, response.getStatusLine().getStatusCode()); + } + + keepTrying("Old event should be deleted by the purge", () -> + executionContextManager.executeAsTenant(tenant.getItemId(), () -> + persistenceService.load("purge-old-event", Event.class)), + Objects::isNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + executionContextManager.executeAsTenant(tenant.getItemId(), () -> + Assert.assertNotNull("Recent event should survive the purge", + persistenceService.load("purge-recent-event", Event.class))); + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantUsageEndpointNotFound() throws Exception { + try (CloseableHttpResponse response = executeHttpRequest( + new HttpGet(getFullUrl(REST_ENDPOINT + "/missing-usage-tenant/usage")), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Missing tenant usage request should return 404", 404, + response.getStatusLine().getStatusCode()); + } + } + + @Test + public void testTenantUsageEndpointRequiresAuthentication() throws Exception { + Tenant tenant = tenantService.createTenant("usage-auth-tenant", Collections.emptyMap()); + try { + String usageUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/usage"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(usageUrl), AuthType.NONE)) { + Assert.assertEquals("Unauthenticated usage request should be rejected", 401, + response.getStatusLine().getStatusCode()); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantUsageEndpointWithExplicitPeriod() throws Exception { + Tenant tenant = tenantService.createTenant("usage-period-tenant", Collections.emptyMap()); + try { + String period = YearMonth.now(java.time.ZoneOffset.UTC).toString(); + String usageUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/usage?period=" + period); + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(usageUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Explicit period usage request should return 200", 200, + response.getStatusLine().getStatusCode()); + Map usage = getObjectMapper().readValue(EntityUtils.toString(response.getEntity()), Map.class); + Assert.assertEquals("Period should match requested month", period, usage.get("period")); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantUsageReflectsSeededTenantData() throws Exception { + Tenant tenant = tenantService.createTenant("usage-seeded-tenant", Collections.emptyMap()); + try { + executionContextManager.executeAsTenant(tenant.getItemId(), () -> { + TestUtils.createScope("usage-scope", "Usage Scope", scopeService); + Profile profile = new Profile(); + profile.setItemId("usage-profile"); + persistenceService.save(profile); + Event event = new Event(); + event.setItemId("usage-event"); + event.setEventType("pageView"); + event.setProfileId(profile.getItemId()); + event.setScope("usage-scope"); + event.setTimeStamp(new Date()); + persistenceService.save(event); + Metadata segmentMetadata = new Metadata("usage-segment"); + segmentMetadata.setScope("usage-scope"); + segmentMetadata.setEnabled(false); + Segment segment = new Segment(); + segment.setMetadata(segmentMetadata); + segment.setCondition(null); + segmentService.setSegmentDefinition(segment); + }); + + String usageUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/usage"); + Map usage = keepTrying("Usage should reflect seeded tenant data", () -> { + try (CloseableHttpResponse response = executeHttpRequest(new HttpGet(usageUrl), AuthType.JAAS_ADMIN)) { + if (response.getStatusLine().getStatusCode() != 200) { + return null; + } + Map body = getObjectMapper().readValue(EntityUtils.toString(response.getEntity()), Map.class); + Number profileCount = (Number) body.get("profileCount"); + Number eventCount = (Number) body.get("eventCount"); + Number scopeCount = (Number) body.get("scopeCount"); + if (profileCount == null || profileCount.longValue() < 1L) { + return null; + } + if (eventCount == null || eventCount.longValue() < 1L) { + return null; + } + if (scopeCount == null || scopeCount.longValue() < 1L) { + return null; + } + return body; + } catch (Exception e) { + return null; + } + }, Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Assert.assertNotNull("Usage response should be populated", usage); + Assert.assertTrue("Active API key count should include tenant keys", + ((Number) usage.get("activeApiKeyCount")).longValue() >= 2L); + List scopeUsages = (List) usage.get("scopeUsages"); + Assert.assertNotNull("scopeUsages should be present", scopeUsages); + boolean foundScope = false; + for (Object entry : scopeUsages) { + Map scopeUsage = (Map) entry; + if ("usage-scope".equals(scopeUsage.get("scopeId"))) { + foundScope = true; + Assert.assertTrue("Segment count for scope should be at least 1", + ((Number) scopeUsage.get("segmentCount")).longValue() >= 1L); + } + } + Assert.assertTrue("scopeUsages should include the seeded scope", foundScope); + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantEventPurgeEndpointRequiresAuthentication() throws Exception { + Tenant tenant = tenantService.createTenant("purge-auth-tenant", Collections.emptyMap()); + try { + String purgeUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/purge/events?retentionDays=90"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(purgeUrl), AuthType.NONE)) { + Assert.assertEquals("Unauthenticated purge request should be rejected", 401, + response.getStatusLine().getStatusCode()); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + @Test + public void testTenantEventPurgeEndpointRejectsNonPositiveRetention() throws Exception { + Tenant tenant = tenantService.createTenant("purge-invalid-tenant", Collections.emptyMap()); + try { + String purgeUrl = getFullUrl(REST_ENDPOINT + "/" + tenant.getItemId() + "/purge/events?retentionDays=0"); + try (CloseableHttpResponse response = executeHttpRequest(new HttpPost(purgeUrl), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("Non-positive retention should return 400", 400, + response.getStatusLine().getStatusCode()); + } + } finally { + tenantService.deleteTenant(tenant.getItemId()); + } + } + + } diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc index 2d98b1157..72c77cdc2 100644 --- a/manual/src/main/asciidoc/multitenancy.adoc +++ b/manual/src/main/asciidoc/multitenancy.adoc @@ -28,7 +28,7 @@ Apache Unomi provides robust multi-tenancy support, allowing multiple organizati * Complete data isolation between tenants * Dual API key system (public and private keys) * Tenant-specific configuration -* Resource quotas and limits +* Per-tenant usage metrics (quotas enforced upstream) * Migration tools for existing data * Support for both REST and GraphQL APIs @@ -433,15 +433,32 @@ For complete details on the GraphQL multi-tenancy implementation, refer to the < === Monitoring API Usage -Track tenant API usage: +Unomi exposes read-only usage metrics per tenant. Quota enforcement belongs in your upstream gateway or control plane. [source,bash] ---- -curl -X GET http://localhost:8181/cxs/tenants/my-tenant/apiCalls \ +curl -X GET "http://localhost:8181/cxs/tenants/my-tenant/usage?period=current-month" \ --user karaf:karaf \ -H "Content-Type: application/json" ---- +Supported `period` values are `current-month` (default), `YYYY-MM` (for example `2026-07`), and legacy `24h` (same as the current calendar month). + +The response includes profile, scope, segment, and rule totals, per-scope segment/rule breakdown (`scopeUsages`), monthly `eventCount` for the requested period (`periodStart` / `periodEnd` in epoch millis), active API key count, storage document count, in-memory REST request count since process start, and `collectedAt` (epoch millis). Values refresh on a background schedule and may be stale until the next collection cycle. + +=== Event retention purge + +Upstream control planes can delete old tenant events through Unomi instead of talking to the search cluster directly: + +[source,bash] +---- +curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/purge/events?retentionDays=90" \ + --user karaf:karaf \ + -H "Content-Type: application/json" +---- + +The response reports how many events matched the retention cutoff before deletion ran (`eventsMatched`) and whether the delete-by-query completed successfully (`purgeRequested`); a `purgeRequested` of `false` means the deletion failed and the request returns HTTP 500 (see server logs for the cause). The minimum accepted retention is seven days, to guard against accidentally purging recent or active event data. + === Data Migration Migrate data between tenants: @@ -463,7 +480,7 @@ curl -X POST http://localhost:8181/cxs/tenants/source-tenant/migrate/target-tena === Resource Management * Set appropriate quotas for each tenant -* Monitor resource usage through the monitoring endpoints +* Monitor resource usage through the tenant usage API (`GET /cxs/tenants/{tenantId}/usage`) * Configure alerts for quota limits * Regularly review and adjust limits based on usage patterns diff --git a/rest/pom.xml b/rest/pom.xml index 5380ef5b0..d57a09b24 100644 --- a/rest/pom.xml +++ b/rest/pom.xml @@ -200,6 +200,16 @@ junit-jupiter test + + org.mockito + mockito-junit-jupiter + test + + + org.mockito + mockito-core + test + 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 801dce2bb..1e2cb9e89 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 @@ -70,6 +70,8 @@ public class DefaultRestAuthenticationConfig implements RestAuthenticationConfig roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.deleteTenant", ADMIN_ROLES); roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.generateApiKey", ADMIN_ROLES); roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.validateApiKey", ADMIN_ROLES); + roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.getTenantUsage", TENANT_ADMIN_ROLES); + roles.put("org.apache.unomi.rest.tenants.TenantEndpoint.purgeTenantEvents", TENANT_ADMIN_ROLES); ROLES_MAPPING = Collections.unmodifiableMap(roles); } diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java index 73e316c7a..cb26707c2 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/AbstractRestExceptionMapper.java @@ -56,7 +56,7 @@ protected Response internalServerErrorResponse() { return jsonErrorResponse(Response.Status.INTERNAL_SERVER_ERROR, "internalServerError"); } - private Response jsonErrorResponse(Response.Status status, String errorMessage) { + protected Response jsonErrorResponse(Response.Status status, String errorMessage) { Map body = new HashMap<>(); body.put(ERROR_MESSAGE_KEY, errorMessage); return Response.status(status).header("Content-Type", MediaType.APPLICATION_JSON).entity(body).build(); diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java index 4c0b0c26b..3744dbf0a 100644 --- a/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java +++ b/rest/src/main/java/org/apache/unomi/rest/exception/RuntimeExceptionMapper.java @@ -20,6 +20,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import javax.ws.rs.ext.ExceptionMapper; import javax.ws.rs.ext.Provider; @@ -32,6 +33,10 @@ public class RuntimeExceptionMapper extends AbstractRestExceptionMapper implemen @Override public Response toResponse(RuntimeException exception) { + if (exception instanceof WebApplicationException) { + return webApplicationExceptionResponse((WebApplicationException) exception); + } + String requestContext = buildRequestContext(); Throwable rootCause = getRootCause(exception); String rootCauseClassName = LogSanitizer.className(rootCause != null ? rootCause.getClass().getSimpleName() : "Unknown"); @@ -56,4 +61,23 @@ public Response toResponse(RuntimeException exception) { return isClientError ? badRequestResponse() : internalServerErrorResponse(); } + + private Response webApplicationExceptionResponse(WebApplicationException exception) { + String requestContext = buildRequestContext(); + int statusCode = exception.getResponse().getStatus(); + Response.Status status = Response.Status.fromStatusCode(statusCode); + if (status == null) { + LOGGER.error("Internal server error on {} - Unrecognized status code {} from {}", requestContext, statusCode, + exception.getClass().getSimpleName()); + return internalServerErrorResponse(); + } + + String message = LogSanitizer.forLogging(exception.getMessage() != null ? exception.getMessage() : status.getReasonPhrase()); + if (status.getFamily() == Response.Status.Family.CLIENT_ERROR) { + LOGGER.warn("{} {} on {} - {}", status.getStatusCode(), status.getReasonPhrase(), requestContext, message); + } else { + LOGGER.error("{} {} on {} - {}", status.getStatusCode(), status.getReasonPhrase(), requestContext, message); + } + return jsonErrorResponse(status, message); + } } diff --git a/rest/src/main/java/org/apache/unomi/rest/scheduler/TaskEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/scheduler/TaskEndpoint.java index ff5321117..ee0536692 100644 --- a/rest/src/main/java/org/apache/unomi/rest/scheduler/TaskEndpoint.java +++ b/rest/src/main/java/org/apache/unomi/rest/scheduler/TaskEndpoint.java @@ -41,7 +41,7 @@ ) @Component(service = TaskEndpoint.class, property = "osgi.jaxrs.resource=true") @Path("/tasks") -@RequiresRole(UnomiRoles.ADMINISTRATOR) +@RequiresRole({UnomiRoles.ADMINISTRATOR, UnomiRoles.TENANT_ADMINISTRATOR}) public class TaskEndpoint { @Reference diff --git a/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java b/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java index c2b0445aa..50327fad2 100644 --- a/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java +++ b/rest/src/main/java/org/apache/unomi/rest/security/SecurityFilter.java @@ -17,8 +17,10 @@ package org.apache.unomi.rest.security; import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.tenants.TenantUsageService; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +34,7 @@ import javax.ws.rs.core.UriInfo; import javax.ws.rs.ext.Provider; import java.io.IOException; +import java.lang.annotation.Annotation; import java.lang.reflect.Method; @Provider @@ -42,11 +45,14 @@ public class SecurityFilter implements ContainerRequestFilter { private static final Logger logger = LoggerFactory.getLogger(SecurityFilter.class); /** Name of the {@code @PathParam} that identifies the tenant a {@link RequiresTenant} endpoint operates on. */ - private static final String TENANT_PATH_PARAM = "tenantId"; + static final String TENANT_PATH_PARAM = "tenantId"; @Reference private SecurityService securityService; + @Reference(cardinality = ReferenceCardinality.OPTIONAL) + private TenantUsageService tenantUsageService; + @Context private ResourceInfo resourceInfo; @@ -56,47 +62,23 @@ public class SecurityFilter implements ContainerRequestFilter { @Override public void filter(ContainerRequestContext requestContext) throws IOException { Method method = resourceInfo.getResourceMethod(); - RequiresRole roleAnnotation = method.getAnnotation(RequiresRole.class); - RequiresTenant tenantAnnotation = method.getAnnotation(RequiresTenant.class); + RequiresRole roleAnnotation = resolveAnnotation(method, RequiresRole.class); + RequiresTenant tenantAnnotation = resolveAnnotation(method, RequiresTenant.class); try { - // Check role-based access - if (roleAnnotation != null) { - String[] roles = roleAnnotation.value(); - boolean hasAccess = false; - for (String role : roles) { - if (securityService.hasRole(role)) { - hasAccess = true; - break; - } - } - if (!hasAccess) { - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) - .entity("User does not have required role") - .build()); - return; - } + if (roleAnnotation != null && !hasRequiredRole(roleAnnotation)) { + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("User does not have required role") + .build()); + return; } - // Check tenants-based access: the tenant being accessed comes from the request path - // (e.g. /tenants/{tenantId}/...), never from the caller's own subject — otherwise the - // check would just compare the subject's tenant against itself and always pass. - if (tenantAnnotation != null) { - String requestedTenantId = uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM); - if (requestedTenantId == null) { - requestContext.abortWith(Response.status(Response.Status.BAD_REQUEST) - .entity("Tenant ID is required") - .build()); - return; - } - if (!securityService.hasTenantAccess(requestedTenantId)) { - requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) - .entity("User does not have access to tenant") - .build()); - return; - } + if (tenantAnnotation != null && !hasRequiredTenantAccess(requestContext)) { + return; } + recordAuthenticatedRestRequest(tenantAnnotation); + } catch (Exception e) { logger.error("Error during security check", e); requestContext.abortWith(Response.status(Response.Status.INTERNAL_SERVER_ERROR) @@ -104,4 +86,59 @@ public void filter(ContainerRequestContext requestContext) throws IOException { .build()); } } + + private boolean hasRequiredRole(RequiresRole roleAnnotation) { + for (String role : roleAnnotation.value()) { + if (securityService.hasRole(role)) { + return true; + } + } + return false; + } + + private boolean hasRequiredTenantAccess(ContainerRequestContext requestContext) { + // The tenant being accessed must come from the request path (e.g. /tenants/{tenantId}/...), + // never from the caller's own subject — otherwise this check would just compare the + // subject's tenant against itself and always pass. + String requestedTenantId = uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM); + if (requestedTenantId == null) { + requestContext.abortWith(Response.status(Response.Status.BAD_REQUEST) + .entity("Tenant ID is required") + .build()); + return false; + } + if (!securityService.hasTenantAccess(requestedTenantId)) { + requestContext.abortWith(Response.status(Response.Status.FORBIDDEN) + .entity("User does not have access to tenant") + .build()); + return false; + } + return true; + } + + private void recordAuthenticatedRestRequest(RequiresTenant tenantAnnotation) { + if (tenantUsageService == null) { + return; + } + String tenantId = null; + if (tenantAnnotation != null) { + tenantId = uriInfo.getPathParameters().getFirst(TENANT_PATH_PARAM); + } + if (tenantId == null || tenantId.isEmpty()) { + if (!securityService.isOperatingOnSystemTenant()) { + tenantId = securityService.getCurrentSubjectTenantId(); + } + } + if (tenantId != null && !tenantId.isEmpty()) { + tenantUsageService.recordRestRequest(tenantId); + } + } + + static A resolveAnnotation(Method method, Class annotationType) { + A annotation = method.getAnnotation(annotationType); + if (annotation == null) { + annotation = method.getDeclaringClass().getAnnotation(annotationType); + } + return annotation; + } } diff --git a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java index adb1d5693..3a61a09b7 100644 --- a/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java +++ b/rest/src/main/java/org/apache/unomi/rest/tenants/TenantEndpoint.java @@ -22,7 +22,11 @@ import org.apache.unomi.api.tenants.ApiKeyCreationResult; import org.apache.unomi.api.tenants.Tenant; import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.api.tenants.TenantEventPurgeResult; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; import org.apache.unomi.rest.security.RequiresRole; +import org.apache.unomi.rest.security.RequiresTenant; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Reference; @@ -49,6 +53,9 @@ public class TenantEndpoint { @Reference private TenantService tenantService; + @Reference + private TenantUsageService tenantUsageService; + /** * Retrieves all tenants in the system. * @@ -68,6 +75,7 @@ public List getTenants() { */ @GET @Path("/{tenantId}") + @RequiresTenant @Produces(MediaType.APPLICATION_JSON) public Response getTenant(@PathParam("tenantId") String tenantId) { Tenant tenant = tenantService.getTenant(tenantId); @@ -107,6 +115,7 @@ public Tenant createTenant(TenantRequest request) { */ @PUT @Path("/{tenantId}") + @RequiresTenant @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Tenant updateTenant(@PathParam("tenantId") String tenantId, Tenant tenant) { @@ -131,6 +140,7 @@ public Tenant updateTenant(@PathParam("tenantId") String tenantId, Tenant tenant */ @DELETE @Path("/{tenantId}") + @RequiresTenant public Response deleteTenant(@PathParam("tenantId") String tenantId) { if (tenantService.getTenant(tenantId) == null) { throw new WebApplicationException("Tenant not found", Response.Status.NOT_FOUND); @@ -151,6 +161,7 @@ public Response deleteTenant(@PathParam("tenantId") String tenantId) { */ @POST @Path("/{tenantId}/apikeys") + @RequiresTenant @Produces(MediaType.APPLICATION_JSON) public ApiKeyCreationResult generateApiKey(@PathParam("tenantId") String tenantId, @QueryParam("type") ApiKey.ApiKeyType type, @@ -180,6 +191,7 @@ public ApiKeyCreationResult generateApiKey(@PathParam("tenantId") String tenantI */ @GET @Path("/{tenantId}/apikeys/validate") + @RequiresTenant public Response validateApiKey(@PathParam("tenantId") String tenantId, @QueryParam("key") String apiKey, @QueryParam("type") ApiKey.ApiKeyType type) { @@ -190,4 +202,66 @@ public Response validateApiKey(@PathParam("tenantId") String tenantId, return Response.status(Response.Status.UNAUTHORIZED).build(); } } + + /** + * Returns a read-only usage snapshot for the tenant. Quota enforcement is upstream; + * this endpoint exposes measured usage for operators and control planes. + * + * @param tenantId tenant identifier + * @param period reporting window: {@code current-month}, {@code YYYY-MM}, or legacy {@code 24h} + * @return usage snapshot, 404 if tenant is missing, 400 for unsupported period + */ + @GET + @Path("/{tenantId}/usage") + @RequiresTenant + @RequiresRole({UnomiRoles.ADMINISTRATOR, UnomiRoles.TENANT_ADMINISTRATOR}) + @Produces(MediaType.APPLICATION_JSON) + public Response getTenantUsage(@PathParam("tenantId") String tenantId, + @QueryParam("period") @DefaultValue(TenantUsageService.DEFAULT_PERIOD) String period) { + try { + TenantUsage usage = tenantUsageService.getUsage(tenantId, period); + if (usage == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + return Response.ok(usage).build(); + } catch (IllegalArgumentException e) { + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); + } + } + + /** + * Deletes events for the tenant at least {@code retentionDays} days old. + * Destructive operation; intended for upstream retention jobs. + * + * @param tenantId tenant identifier + * @param retentionDays age cutoff in whole days (minimum {@link TenantUsageService#MIN_EVENT_RETENTION_DAYS}) + * @return purge summary, 404 if tenant is missing, 400 for invalid retention, 500 if the + * persistence layer failed to complete the deletion (see {@code purgeRequested}) + */ + @POST + @Path("/{tenantId}/purge/events") + @RequiresTenant + @RequiresRole({UnomiRoles.ADMINISTRATOR, UnomiRoles.TENANT_ADMINISTRATOR}) + @Produces(MediaType.APPLICATION_JSON) + public Response purgeTenantEvents(@PathParam("tenantId") String tenantId, + @QueryParam("retentionDays") int retentionDays) { + if (retentionDays <= 0) { + throw new WebApplicationException("retentionDays must be positive", Response.Status.BAD_REQUEST); + } + try { + TenantEventPurgeResult result = tenantUsageService.purgeEventsOlderThan(tenantId, retentionDays); + if (result == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + if (!result.isPurgeRequested()) { + // The persistence layer accepted the request but failed to complete the deletion + // (see server logs); callers checking only the status code must see a failure here, + // not a false-positive 200. + return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(result).build(); + } + return Response.ok(result).build(); + } catch (IllegalArgumentException e) { + throw new WebApplicationException(e.getMessage(), Response.Status.BAD_REQUEST); + } + } } diff --git a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java index 1306f17e6..a01d842fc 100644 --- a/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java +++ b/rest/src/test/java/org/apache/unomi/rest/exception/RestExceptionMapperTest.java @@ -22,6 +22,7 @@ import org.junit.jupiter.api.Test; import javax.ws.rs.InternalServerErrorException; +import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import java.util.Map; @@ -123,6 +124,26 @@ void internalServerError_withIllegalArgumentCause_mapsToBadRequest() { assertErrorResponse(response, 400, "badRequest"); } + @Test + void webApplicationException_withBadRequestStatus_preservesStatusAndMessage() { + Response response = new RuntimeExceptionMapper() + .toResponse(new WebApplicationException("Tenant ID is required", Response.Status.BAD_REQUEST)); + assertErrorResponse(response, 400, "Tenant ID is required"); + } + + @Test + void webApplicationException_withNotFoundStatus_preservesStatusAndMessage() { + Response response = new RuntimeExceptionMapper() + .toResponse(new WebApplicationException("Tenant not found", Response.Status.NOT_FOUND)); + assertErrorResponse(response, 404, "Tenant not found"); + } + + @Test + void webApplicationException_withUnrecognizedStatusCode_mapsToInternalServerError() { + Response response = new RuntimeExceptionMapper().toResponse(new WebApplicationException("weird", 499)); + assertErrorResponse(response, 500, "internalServerError"); + } + @Test void internalServerError_withBadSegmentConditionCause_mapsToBadRequest() { InternalServerErrorException exception = new InternalServerErrorException("wrapped", diff --git a/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java b/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java new file mode 100644 index 000000000..3954a4b21 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java @@ -0,0 +1,186 @@ +/* + * 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.security; + +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.tenants.TenantUsageService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.container.ResourceInfo; +import javax.ws.rs.core.MultivaluedHashMap; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SecurityFilterTest { + + @Mock + private SecurityService securityService; + + @Mock + private TenantUsageService tenantUsageService; + + @Mock + private ResourceInfo resourceInfo; + + @Mock + private UriInfo uriInfo; + + @Mock + private ContainerRequestContext requestContext; + + private SecurityFilter filter; + + @BeforeEach + void setUp() throws Exception { + filter = new SecurityFilter(); + setField(filter, "securityService", securityService); + setField(filter, "tenantUsageService", tenantUsageService); + setField(filter, "resourceInfo", resourceInfo); + setField(filter, "uriInfo", uriInfo); + } + + @Test + void requiresTenantChecksPathTenantId() throws Exception { + Method method = TenantScopedResource.class.getMethod("tenantOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + MultivaluedHashMap pathParams = new MultivaluedHashMap<>(); + pathParams.add(SecurityFilter.TENANT_PATH_PARAM, "requested-tenant"); + when(uriInfo.getPathParameters()).thenReturn(pathParams); + when(securityService.hasTenantAccess("requested-tenant")).thenReturn(false); + + filter.filter(requestContext); + + verify(securityService).hasTenantAccess("requested-tenant"); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(responseCaptor.capture()); + assertEquals(Response.Status.FORBIDDEN.getStatusCode(), responseCaptor.getValue().getStatus()); + } + + @Test + void requiresTenantRejectsMissingPathTenantId() throws Exception { + Method method = TenantScopedResource.class.getMethod("tenantOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(uriInfo.getPathParameters()).thenReturn(new MultivaluedHashMap<>()); + + filter.filter(requestContext); + + verify(securityService, never()).hasTenantAccess(any()); + ArgumentCaptor responseCaptor = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(responseCaptor.capture()); + assertEquals(Response.Status.BAD_REQUEST.getStatusCode(), responseCaptor.getValue().getStatus()); + } + + @Test + void requiresRoleResolvesClassLevelAnnotation() throws Exception { + Method method = ClassScopedRoleResource.class.getMethod("anyOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(securityService.hasRole("ROLE_ADMIN")).thenReturn(true); + + filter.filter(requestContext); + + verify(securityService).hasRole("ROLE_ADMIN"); + verify(requestContext, never()).abortWith(any()); + } + + @Test + void resolveAnnotationPrefersMethodOverClass() throws Exception { + Method method = ClassScopedRoleResource.class.getMethod("overriddenOperation"); + RequiresRole resolved = SecurityFilter.resolveAnnotation(method, RequiresRole.class); + assertEquals("ROLE_TENANT", resolved.value()[0]); + } + + @Test + void recordsRequestAgainstPathTenantForTenantScopedEndpoint() throws Exception { + Method method = TenantScopedResource.class.getMethod("tenantOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + MultivaluedHashMap pathParams = new MultivaluedHashMap<>(); + pathParams.add(SecurityFilter.TENANT_PATH_PARAM, "path-tenant"); + when(uriInfo.getPathParameters()).thenReturn(pathParams); + when(securityService.hasTenantAccess("path-tenant")).thenReturn(true); + + filter.filter(requestContext); + + verify(tenantUsageService).recordRestRequest("path-tenant"); + verify(securityService, never()).getCurrentSubjectTenantId(); + } + + @Test + void recordsRequestAgainstSubjectTenantForNonTenantScopedEndpoint() throws Exception { + Method method = UnscopedResource.class.getMethod("anyOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(securityService.isOperatingOnSystemTenant()).thenReturn(false); + when(securityService.getCurrentSubjectTenantId()).thenReturn("subject-tenant"); + + filter.filter(requestContext); + + verify(tenantUsageService).recordRestRequest("subject-tenant"); + } + + @Test + void doesNotRecordRequestWhenOperatingOnSystemTenant() throws Exception { + Method method = UnscopedResource.class.getMethod("anyOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + when(securityService.isOperatingOnSystemTenant()).thenReturn(true); + + filter.filter(requestContext); + + verify(securityService, never()).getCurrentSubjectTenantId(); + verify(tenantUsageService, never()).recordRestRequest(any()); + } + + static class TenantScopedResource { + @RequiresTenant + public void tenantOperation() { + } + } + + static class UnscopedResource { + public void anyOperation() { + } + } + + @RequiresRole("ROLE_ADMIN") + static class ClassScopedRoleResource { + public void anyOperation() { + } + + @RequiresRole("ROLE_TENANT") + public void overriddenOperation() { + } + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTest.java b/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTest.java new file mode 100644 index 000000000..6462307b9 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/tenants/TenantEndpointTest.java @@ -0,0 +1,145 @@ +/* + * 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.tenants; + +import org.apache.unomi.api.tenants.TenantEventPurgeResult; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; +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 javax.ws.rs.WebApplicationException; +import javax.ws.rs.core.Response; +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TenantEndpointTest { + + @Mock + private TenantUsageService tenantUsageService; + + private TenantEndpoint endpoint; + + @BeforeEach + void setUp() throws Exception { + endpoint = new TenantEndpoint(); + Field field = TenantEndpoint.class.getDeclaredField("tenantUsageService"); + field.setAccessible(true); + field.set(endpoint, tenantUsageService); + } + + @Test + void getTenantUsageReturnsOkWithUsageSnapshot() { + TenantUsage usage = new TenantUsage(); + usage.setTenantId("tenant-a"); + usage.setPeriod("2026-07"); + when(tenantUsageService.getUsage("tenant-a", "current-month")).thenReturn(usage); + + Response response = endpoint.getTenantUsage("tenant-a", "current-month"); + + assertEquals(200, response.getStatus()); + assertEquals(usage, response.getEntity()); + } + + @Test + void getTenantUsageReturnsNotFoundWhenTenantMissing() { + when(tenantUsageService.getUsage("missing", "current-month")).thenReturn(null); + + Response response = endpoint.getTenantUsage("missing", "current-month"); + + assertEquals(404, response.getStatus()); + } + + @Test + void getTenantUsageReturnsBadRequestForUnsupportedPeriod() { + when(tenantUsageService.getUsage(eq("tenant-a"), eq("7d"))) + .thenThrow(new IllegalArgumentException("Unsupported usage period: 7d")); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> endpoint.getTenantUsage("tenant-a", "7d")); + + assertEquals(400, ex.getResponse().getStatus()); + } + + @Test + void purgeTenantEventsReturnsOkWithResult() { + TenantEventPurgeResult result = new TenantEventPurgeResult(); + result.setTenantId("tenant-a"); + result.setRetentionDays(90); + result.setEventsMatched(10L); + result.setPurgeRequested(true); + when(tenantUsageService.purgeEventsOlderThan("tenant-a", 90)).thenReturn(result); + + Response response = endpoint.purgeTenantEvents("tenant-a", 90); + + assertEquals(200, response.getStatus()); + assertEquals(result, response.getEntity()); + } + + @Test + void purgeTenantEventsReturnsServerErrorWhenPurgeNotAccepted() { + TenantEventPurgeResult result = new TenantEventPurgeResult(); + result.setTenantId("tenant-a"); + result.setRetentionDays(90); + result.setEventsMatched(10L); + result.setPurgeRequested(false); + when(tenantUsageService.purgeEventsOlderThan("tenant-a", 90)).thenReturn(result); + + Response response = endpoint.purgeTenantEvents("tenant-a", 90); + + assertEquals(500, response.getStatus()); + assertEquals(result, response.getEntity()); + } + + @Test + void purgeTenantEventsReturnsNotFoundWhenTenantMissing() { + when(tenantUsageService.purgeEventsOlderThan("missing", 90)).thenReturn(null); + + Response response = endpoint.purgeTenantEvents("missing", 90); + + assertEquals(404, response.getStatus()); + } + + @Test + void purgeTenantEventsRejectsNonPositiveRetention() { + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> endpoint.purgeTenantEvents("tenant-a", 0)); + + assertEquals(400, ex.getResponse().getStatus()); + assertNotNull(ex.getMessage()); + } + + @Test + void purgeTenantEventsReturnsBadRequestWhenRetentionBelowMinimum() { + when(tenantUsageService.purgeEventsOlderThan("tenant-a", 3)) + .thenThrow(new IllegalArgumentException("retentionDays must be at least 7")); + + WebApplicationException ex = assertThrows(WebApplicationException.class, + () -> endpoint.purgeTenantEvents("tenant-a", 3)); + + assertEquals(400, ex.getResponse().getStatus()); + } +} diff --git a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java index f945d90f2..24e79f375 100644 --- a/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java +++ b/services-common/src/main/java/org/apache/unomi/services/common/security/KarafSecurityService.java @@ -168,7 +168,7 @@ public void setCurrentSubject(Subject subject) { @Override public void clearCurrentSubject() { - currentSubject.remove(); + clearRequestSubject(); privilegedSubject.remove(); } diff --git a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java index 686b0f03e..6841adf85 100644 --- a/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java +++ b/services-common/src/test/java/org/apache/unomi/services/common/security/KarafSecurityServiceTest.java @@ -117,6 +117,33 @@ public void testSystemSubjectImmutableAfterReconfiguration() { extractRoles(updatedSystemSubject.getPrincipals()).contains(UnomiRoles.SYSTEM_MAINTENANCE)); } + @Test + public void testRequestSubjectIsIndependentOfPrivilegedSubject() { + Subject requestSubject = createTestSubject("requestUser", UnomiRoles.USER); + Subject privileged = createTestSubject("privUser", UnomiRoles.ADMINISTRATOR); + securityService.setCurrentSubject(requestSubject); + securityService.setPrivilegedSubject(privileged); + + assertEquals("Privileged subject wins for getCurrentSubject()", privileged, + securityService.getCurrentSubject()); + assertEquals("Request slot is unchanged", requestSubject, securityService.getRequestSubject()); + + securityService.clearRequestSubject(); + assertNull("Request slot cleared", securityService.getRequestSubject()); + assertEquals("Privileged subject still active", privileged, securityService.getCurrentSubject()); + } + + @Test + public void testClearCurrentSubjectClearsRequestAndPrivilegedSlots() { + securityService.setCurrentSubject(createTestSubject("user", UnomiRoles.USER)); + securityService.setPrivilegedSubject(createTestSubject("admin", UnomiRoles.ADMINISTRATOR)); + + securityService.clearCurrentSubject(); + + assertNull("Request slot cleared", securityService.getRequestSubject()); + assertNull("No effective subject after full clear", securityService.getCurrentSubject()); + } + @Test public void testCurrentSubjectManagement() { // Test initial state diff --git a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java deleted file mode 100644 index d296fe647..000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMetrics.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.services.impl.tenants; - -/** - * Stores metrics for a tenant including profile count, event count, storage size and API calls. - */ -public class TenantMetrics { - private long profileCount; - private long eventCount; - private long storageSize; - private long apiCallCount; - - public long getProfileCount() { - return profileCount; - } - - public void setProfileCount(long profileCount) { - this.profileCount = profileCount; - } - - public long getEventCount() { - return eventCount; - } - - public void setEventCount(long eventCount) { - this.eventCount = eventCount; - } - - public long getStorageSize() { - return storageSize; - } - - public void setStorageSize(long storageSize) { - this.storageSize = storageSize; - } - - public long getApiCallCount() { - return apiCallCount; - } - - public void setApiCallCount(long apiCallCount) { - this.apiCallCount = apiCallCount; - } -} diff --git a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java deleted file mode 100644 index a491c7952..000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantMonitoringService.java +++ /dev/null @@ -1,187 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.services.impl.tenants; - -import org.apache.unomi.api.Event; -import org.apache.unomi.api.Profile; -import org.apache.unomi.api.conditions.Condition; -import org.apache.unomi.api.conditions.ConditionType; -import org.apache.unomi.api.services.DefinitionsService; -import org.apache.unomi.api.services.ExecutionContextManager; -import org.apache.unomi.api.tenants.Tenant; -import org.apache.unomi.api.tenants.TenantService; -import org.apache.unomi.persistence.spi.PersistenceService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class TenantMonitoringService { - - private static final Logger logger = LoggerFactory.getLogger(TenantMonitoringService.class); - - private PersistenceService persistenceService; - private DefinitionsService definitionsService; - private TenantService tenantService; - private ExecutionContextManager contextManager; - - private final Map metricsCache = new ConcurrentHashMap<>(); - private ScheduledExecutorService executor; - private volatile boolean shutdownNow = false; - - public void setPersistenceService(PersistenceService persistenceService) { - this.persistenceService = persistenceService; - } - - public void setTenantService(TenantService tenantService) { - this.tenantService = tenantService; - } - - public void setDefinitionsService(DefinitionsService definitionsService) { - this.definitionsService = definitionsService; - } - - public void setContextManager(ExecutionContextManager contextManager) { - this.contextManager = contextManager; - } - - public void activate() { - shutdownNow = false; - startMetricsCollection(); - } - - public void deactivate() { - shutdownNow = true; - stopMetricsCollection(); - } - - public TenantMetrics getMetrics(String tenantId) { - return metricsCache.get(tenantId); - } - - private void startMetricsCollection() { - executor = Executors.newScheduledThreadPool(1, r -> { - Thread t = new Thread(r, "Tenant-Metrics-Collector"); - t.setDaemon(true); - return t; - }); - - executor.scheduleAtFixedRate(() -> { - try { - if (shutdownNow) { - return; - } - - if (contextManager == null) { - logger.warn("Context manager not available, skipping metrics collection"); - return; - } - - contextManager.executeAsSystem(() -> { - try { - if (!shutdownNow && tenantService != null && persistenceService != null) { - updateMetrics(); - } - } catch (Exception e) { - logger.error("Error updating metrics", e); - } - }); - } catch (Exception e) { - logger.error("Error executing metrics update as system subject", e); - } - }, 0, 5, TimeUnit.MINUTES); - } - - private void updateMetrics() { - if (shutdownNow) { - return; - } - - // Check if required condition types are available before updating metrics - if (definitionsService == null) { - logger.debug("DefinitionsService not available, skipping metrics update"); - return; - } - - ConditionType profilePropertyConditionType = definitionsService.getConditionType("profilePropertyCondition"); - ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); - - if (profilePropertyConditionType == null || eventPropertyConditionType == null) { - logger.debug("Required condition types not available (profilePropertyCondition: {}, eventPropertyCondition: {}), skipping metrics update", - profilePropertyConditionType != null, eventPropertyConditionType != null); - return; - } - - try { - List tenants = tenantService.getAllTenants(); - for (Tenant tenant : tenants) { - if (shutdownNow) return; - - TenantMetrics metrics = new TenantMetrics(); - metrics.setProfileCount(countProfiles(tenant.getItemId(), profilePropertyConditionType)); - metrics.setEventCount(countEvents(tenant.getItemId(), eventPropertyConditionType)); - metrics.setStorageSize(persistenceService.calculateStorageSize(tenant.getItemId())); - metrics.setApiCallCount(persistenceService.getApiCallCount(tenant.getItemId())); - - metricsCache.put(tenant.getItemId(), metrics); - } - } catch (Exception e) { - logger.error("Error updating tenant metrics", e); - } - } - - private long countProfiles(String tenantId, ConditionType conditionType) { - Condition condition = new Condition(); - condition.setConditionTypeId("profilePropertyCondition"); - condition.setConditionType(conditionType); - condition.setParameter("propertyName", "tenantId"); - condition.setParameter("comparisonOperator", "equals"); - condition.setParameter("propertyValue", tenantId); - return persistenceService.queryCount(condition, Profile.ITEM_TYPE); - } - - private long countEvents(String tenantId, ConditionType conditionType) { - Condition condition = new Condition(); - condition.setConditionTypeId("eventPropertyCondition"); - condition.setConditionType(conditionType); - condition.setParameter("propertyName", "tenantId"); - condition.setParameter("comparisonOperator", "equals"); - condition.setParameter("propertyValue", tenantId); - return persistenceService.queryCount(condition, Event.ITEM_TYPE); - } - - private void stopMetricsCollection() { - if (executor != null) { - try { - executor.shutdownNow(); - if (!executor.awaitTermination(3, TimeUnit.SECONDS)) { - logger.warn("Executor did not terminate in time, some tasks may have been canceled"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.warn("Interrupted while shutting down the monitoring executor"); - } finally { - executor = null; - } - } - } -} 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 deleted file mode 100644 index 0b0e7c017..000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantQuotaService.java +++ /dev/null @@ -1,180 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.services.impl.tenants; - -import org.apache.unomi.api.services.ExecutionContextManager; -import org.apache.unomi.api.tenants.ResourceQuota; -import org.apache.unomi.api.tenants.Tenant; -import org.apache.unomi.api.tenants.TenantService; -import org.apache.unomi.persistence.spi.PersistenceService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -public class TenantQuotaService { - - private static final Logger logger = LoggerFactory.getLogger(TenantQuotaService.class); - - private PersistenceService persistenceService; - private TenantService tenantService; - private ExecutionContextManager contextManager; - - private Map usageCache = new ConcurrentHashMap<>(); - private ScheduledExecutorService executor; - private volatile boolean shutdownNow = false; - - public void setPersistenceService(PersistenceService persistenceService) { - this.persistenceService = persistenceService; - } - - public void setTenantService(TenantService tenantService) { - this.tenantService = tenantService; - } - - public void setContextManager(ExecutionContextManager contextManager) { - this.contextManager = contextManager; - } - - public void activate() { - shutdownNow = false; // Reset shutdown flag - // Start usage monitoring - startUsageMonitoring(); - } - - public void deactivate() { - shutdownNow = true; // Set shutdown flag before stopping - stopUsageMonitoring(); - } - - private ResourceQuota getTenantQuota(String tenantId) { - Tenant tenant; - try { - tenant = persistenceService.load(tenantId, Tenant.class); - } catch (Exception e) { - // Distinguish "failed to load" from "tenant has no quota configured" in the logs: - // both currently fail open (unlimited) below, but a load failure should be visible - // to operators instead of silently looking identical to an unconfigured quota. - logger.error("Failed to load tenant {} while checking quota; failing open for this check", tenantId, e); - return null; - } - return tenant != null ? tenant.getResourceQuota() : null; - } - - private TenantUsage getUsage(String tenantId) { - return usageCache.computeIfAbsent(tenantId, k -> new TenantUsage()); - } - - public boolean checkQuota(String tenantId, String quotaType, long increment) { - ResourceQuota quota = getTenantQuota(tenantId); - if (quota == null) { - return true; - } - TenantUsage usage = getUsage(tenantId); - - switch (quotaType) { - case "profiles": - return (usage.getProfileCount() + increment) <= quota.getMaxProfiles(); - case "events": - return (usage.getEventCount() + increment) <= quota.getMaxEvents(); - case "storage": - return (usage.getStorageSize() + increment) <= quota.getMaxStorageSize(); - default: - if (quota.getCustomQuotas().containsKey(quotaType)) { - return (usage.getCustomUsage(quotaType) + increment) <= - quota.getCustomQuotas().get(quotaType); - } - return true; - } - } - - private void updateUsageStatistics() { - if (shutdownNow || persistenceService == null) { - return; // Skip if shutting down or persistence service is unavailable - } - - for (String tenantId : usageCache.keySet()) { - if (shutdownNow) return; // Check shutdown flag during iteration - - try { - TenantUsage usage = usageCache.get(tenantId); - usage.setProfileCount(persistenceService.getAllItemsCount("profile", tenantId)); - usage.setEventCount(persistenceService.getAllItemsCount("event", tenantId)); - // Note: Storage size calculation would require additional implementation - } catch (Exception e) { - // Isolate failures per tenant so one tenant's error (e.g. a not-yet-ready index) - // doesn't leave every other tenant's usage counts stale for this refresh cycle. - logger.error("Error updating usage statistics for tenant {}", tenantId, e); - } - } - } - - private void startUsageMonitoring() { - executor = Executors.newSingleThreadScheduledExecutor(r -> { - Thread t = new Thread(r, "Tenant-Usage-Monitor"); - t.setDaemon(true); // Make it daemon so it doesn't prevent JVM shutdown - return t; - }); - - executor.scheduleAtFixedRate(() -> { - try { - if (shutdownNow) { - return; // Skip execution if shutting down - } - - if (contextManager == null) { - logger.warn("Context manager not available, skipping usage statistics update"); - return; - } - - contextManager.executeAsSystem(() -> { - try { - if (!shutdownNow && persistenceService != null) { - updateUsageStatistics(); - } - } catch (Exception e) { - logger.error("Error updating usage statistics", e); - } - }); - } catch (Exception e) { - logger.error("Error executing usage statistics update as system subject", e); - } - }, 0, 1, TimeUnit.HOURS); - } - - private void stopUsageMonitoring() { - if (executor != null) { - try { - // Use shutdownNow instead of shutdown for immediate interruption - executor.shutdownNow(); - // Reduce wait time to avoid blocking OSGi shutdown - if (!executor.awaitTermination(3, TimeUnit.SECONDS)) { - logger.warn("Executor did not terminate in time, some tasks may have been canceled"); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - logger.warn("Interrupted while shutting down the monitoring executor"); - } finally { - executor = null; - } - } - } -} diff --git a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java deleted file mode 100644 index bedbf8b8f..000000000 --- a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsage.java +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.apache.unomi.services.impl.tenants; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -public class TenantUsage { - private long profileCount; - private long eventCount; - private long storageSize; - private Map customUsage = new ConcurrentHashMap<>(); - - public long getProfileCount() { - return profileCount; - } - - public void setProfileCount(long profileCount) { - this.profileCount = profileCount; - } - - public long getEventCount() { - return eventCount; - } - - public void setEventCount(long eventCount) { - this.eventCount = eventCount; - } - - public long getStorageSize() { - return storageSize; - } - - public void setStorageSize(long storageSize) { - this.storageSize = storageSize; - } - - public long getCustomUsage(String type) { - return customUsage.getOrDefault(type, 0L); - } - - public void setCustomUsage(String type, long value) { - customUsage.put(type, value); - } -} diff --git a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java new file mode 100644 index 000000000..5a63e2b22 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java @@ -0,0 +1,459 @@ +/* + * 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.api.Event; +import org.apache.unomi.api.Metadata; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.Scope; +import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.segments.Segment; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.tenants.TenantEventPurgeResult; +import org.apache.unomi.api.tenants.TenantScopeUsage; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.aggregate.TermsAggregate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.xml.bind.DatatypeConverter; +import java.time.Instant; +import java.time.YearMonth; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.Collections; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +public class TenantUsageServiceImpl implements TenantUsageService { + + private static final Logger logger = LoggerFactory.getLogger(TenantUsageServiceImpl.class); + private static final Pattern MONTH_PERIOD = Pattern.compile("^\\d{4}-\\d{2}$"); + + private PersistenceService persistenceService; + private DefinitionsService definitionsService; + private TenantService tenantService; + private ExecutionContextManager contextManager; + + private final Map usageCache = new ConcurrentHashMap<>(); + private final Map restRequestCounts = new ConcurrentHashMap<>(); + private ScheduledExecutorService executor; + private volatile boolean shutdownNow = false; + + public void setPersistenceService(PersistenceService persistenceService) { + this.persistenceService = persistenceService; + } + + public void setTenantService(TenantService tenantService) { + this.tenantService = tenantService; + } + + public void setDefinitionsService(DefinitionsService definitionsService) { + this.definitionsService = definitionsService; + } + + public void setContextManager(ExecutionContextManager contextManager) { + this.contextManager = contextManager; + } + + public void activate() { + shutdownNow = false; + startMetricsCollection(); + } + + public void deactivate() { + shutdownNow = true; + stopMetricsCollection(); + } + + @Override + public TenantUsage getUsage(String tenantId, String period) { + UsagePeriod usagePeriod = resolvePeriod(period); + Tenant tenant = tenantService.getTenant(tenantId); + if (tenant == null) { + return null; + } + String cacheKey = cacheKey(tenantId, usagePeriod.getLabel()); + UsageSnapshot snapshot = usageCache.get(cacheKey); + if (snapshot == null) { + refreshTenantUsage(tenantId, usagePeriod); + snapshot = usageCache.get(cacheKey); + } + if (snapshot == null) { + return null; + } + return toDto(tenant, usagePeriod, snapshot); + } + + @Override + public void recordRestRequest(String tenantId) { + if (tenantId != null && !tenantId.isEmpty()) { + restRequestCounts.computeIfAbsent(tenantId, id -> new AtomicLong()).incrementAndGet(); + } + } + + @Override + public TenantEventPurgeResult purgeEventsOlderThan(String tenantId, int retentionDays) { + if (tenantService.getTenant(tenantId) == null) { + return null; + } + if (retentionDays < MIN_EVENT_RETENTION_DAYS) { + throw new IllegalArgumentException( + "retentionDays must be at least " + MIN_EVENT_RETENTION_DAYS + " (requested " + retentionDays + ")"); + } + if (contextManager == null || definitionsService == null || persistenceService == null) { + throw new IllegalStateException("Tenant usage service is not fully initialized"); + } + + return contextManager.executeAsSystem(() -> contextManager.executeAsTenant(tenantId, () -> { + ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); + if (eventPropertyConditionType == null) { + throw new IllegalStateException("eventPropertyCondition type is not available"); + } + + Condition ageCondition = newCondition(eventPropertyConditionType); + ageCondition.setParameter("propertyName", "timeStamp"); + ageCondition.setParameter("comparisonOperator", "lessThanOrEqualTo"); + ageCondition.setParameter("propertyValueDateExpr", "now-" + retentionDays + "d"); + + long matched = persistenceService.queryCount(ageCondition, Event.ITEM_TYPE); + boolean purgeRequested = persistenceService.removeByQuery(ageCondition, Event.class); + + TenantEventPurgeResult result = new TenantEventPurgeResult(); + result.setTenantId(tenantId); + result.setRetentionDays(retentionDays); + result.setEventsMatched(matched); + result.setPurgeRequested(purgeRequested); + result.setRequestedAt(System.currentTimeMillis()); + return result; + })); + } + + private void startMetricsCollection() { + executor = Executors.newScheduledThreadPool(1, r -> { + Thread t = new Thread(r, "Tenant-Usage-Collector"); + t.setDaemon(true); + return t; + }); + + executor.scheduleAtFixedRate(() -> { + try { + if (shutdownNow) { + return; + } + + if (contextManager == null) { + logger.warn("Context manager not available, skipping usage collection"); + return; + } + + contextManager.executeAsSystem(() -> { + try { + if (!shutdownNow && tenantService != null && persistenceService != null) { + refreshAllTenants(); + } + } catch (Exception e) { + logger.error("Error updating tenant usage", e); + } + }); + } catch (Exception e) { + logger.error("Error executing tenant usage update as system subject", e); + } + }, 0, 5, TimeUnit.MINUTES); + } + + private void refreshAllTenants() { + if (shutdownNow || definitionsService == null) { + return; + } + + ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); + ConditionType booleanConditionType = definitionsService.getConditionType("booleanCondition"); + + if (eventPropertyConditionType == null || booleanConditionType == null) { + logger.debug("Required condition types not available, skipping usage update"); + return; + } + + UsagePeriod currentMonth = resolvePeriod(DEFAULT_PERIOD); + + List tenants; + try { + tenants = tenantService.getAllTenants(); + } catch (Exception e) { + logger.error("Error listing tenants for usage refresh", e); + return; + } + for (Tenant tenant : tenants) { + if (shutdownNow) { + return; + } + try { + refreshTenantUsage(tenant.getItemId(), currentMonth, eventPropertyConditionType, booleanConditionType); + } catch (Exception e) { + logger.error("Error refreshing usage for tenant {}", tenant.getItemId(), e); + } + } + } + + private void refreshTenantUsage(String tenantId, UsagePeriod usagePeriod) { + if (definitionsService == null) { + logger.warn("Definitions service not available, skipping usage refresh for tenant {}", tenantId); + return; + } + ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); + ConditionType booleanConditionType = definitionsService.getConditionType("booleanCondition"); + if (eventPropertyConditionType == null || booleanConditionType == null) { + logger.debug("Required condition types not available, skipping usage update for tenant {}", tenantId); + return; + } + refreshTenantUsage(tenantId, usagePeriod, eventPropertyConditionType, booleanConditionType); + } + + private void refreshTenantUsage(String tenantId, UsagePeriod usagePeriod, + ConditionType eventPropertyConditionType, + ConditionType booleanConditionType) { + if (shutdownNow || persistenceService == null || contextManager == null) { + return; + } + + UsageSnapshot snapshot = new UsageSnapshot(); + snapshot.profileCount = persistenceService.getAllItemsCount(Profile.ITEM_TYPE, tenantId); + // queryCount() scopes by the calling thread's execution context, not by any tenantId + // parameter inside the Condition, so the count must run under the target tenant's context + // (background refreshes run as "system", which would otherwise always match zero events). + snapshot.eventCount = contextManager.executeAsTenant(tenantId, () -> + countEventsInPeriod(tenantId, eventPropertyConditionType, booleanConditionType, + usagePeriod.getStartMillis(), usagePeriod.getEndMillis())); + snapshot.scopeCount = countCommercialScopes(tenantId); + snapshot.segmentCount = persistenceService.getAllItemsCount(Segment.ITEM_TYPE, tenantId); + snapshot.ruleCount = persistenceService.getAllItemsCount(Rule.ITEM_TYPE, tenantId); + snapshot.storageDocumentCount = persistenceService.calculateStorageSize(tenantId); + snapshot.scopeUsages = loadScopeUsages(tenantId); + snapshot.collectedAt = System.currentTimeMillis(); + usageCache.put(cacheKey(tenantId, usagePeriod.getLabel()), snapshot); + } + + private List loadScopeUsages(String tenantId) { + if (contextManager == null) { + return Collections.emptyList(); + } + + Map segmentsByScope = contextManager.executeAsTenant(tenantId, () -> + persistenceService.aggregateWithOptimizedQuery(null, new TermsAggregate("metadata.scope"), + Segment.ITEM_TYPE)); + Map rulesByScope = contextManager.executeAsTenant(tenantId, () -> + persistenceService.aggregateWithOptimizedQuery(null, new TermsAggregate("metadata.scope"), + Rule.ITEM_TYPE)); + + Set scopeIds = new TreeSet<>(); + if (segmentsByScope != null) { + scopeIds.addAll(segmentsByScope.keySet()); + } + if (rulesByScope != null) { + scopeIds.addAll(rulesByScope.keySet()); + } + scopeIds.remove("_filtered"); + scopeIds.remove(Metadata.SYSTEM_SCOPE); + scopeIds.removeIf(id -> id == null || id.isEmpty()); + + List scopeUsages = new ArrayList<>(); + for (String scopeId : scopeIds) { + TenantScopeUsage scopeUsage = new TenantScopeUsage(); + scopeUsage.setScopeId(scopeId); + scopeUsage.setSegmentCount(segmentsByScope != null ? segmentsByScope.getOrDefault(scopeId, 0L) : 0L); + scopeUsage.setRuleCount(rulesByScope != null ? rulesByScope.getOrDefault(scopeId, 0L) : 0L); + scopeUsages.add(scopeUsage); + } + return scopeUsages; + } + + private long countCommercialScopes(String tenantId) { + long total = persistenceService.getAllItemsCount(Scope.ITEM_TYPE, tenantId); + if (contextManager == null) { + return total; + } + Scope systemScope = contextManager.executeAsTenant(tenantId, + () -> persistenceService.load(Metadata.SYSTEM_SCOPE, Scope.class)); + if (systemScope != null) { + return Math.max(0L, total - 1L); + } + return total; + } + + private long countEventsInPeriod(String tenantId, ConditionType eventPropertyConditionType, + ConditionType booleanConditionType, long periodStartMillis, long periodEndMillis) { + Condition andCondition = newCondition(booleanConditionType); + andCondition.setParameter("operator", "and"); + + List subConditions = new ArrayList<>(); + subConditions.add(tenantEqualsCondition(tenantId, eventPropertyConditionType)); + + Condition startCondition = newCondition(eventPropertyConditionType); + startCondition.setParameter("propertyName", "timeStamp"); + startCondition.setParameter("comparisonOperator", "greaterThanOrEqualTo"); + startCondition.setParameter("propertyValueDate", toIsoDateTime(periodStartMillis)); + subConditions.add(startCondition); + + Condition endCondition = newCondition(eventPropertyConditionType); + endCondition.setParameter("propertyName", "timeStamp"); + endCondition.setParameter("comparisonOperator", "lessThan"); + endCondition.setParameter("propertyValueDate", toIsoDateTime(periodEndMillis)); + subConditions.add(endCondition); + + andCondition.setParameter("subConditions", subConditions); + return persistenceService.queryCount(andCondition, Event.ITEM_TYPE); + } + + private Condition tenantEqualsCondition(String tenantId, ConditionType conditionType) { + Condition condition = newCondition(conditionType); + condition.setParameter("propertyName", "tenantId"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", tenantId); + return condition; + } + + private Condition newCondition(ConditionType conditionType) { + Condition condition = new Condition(); + condition.setConditionTypeId(conditionType.getItemId()); + condition.setConditionType(conditionType); + return condition; + } + + private long currentRestRequestCount(String tenantId) { + AtomicLong counter = restRequestCounts.get(tenantId); + return counter != null ? counter.get() : 0L; + } + + private TenantUsage toDto(Tenant tenant, UsagePeriod usagePeriod, UsageSnapshot snapshot) { + TenantUsage usage = new TenantUsage(); + usage.setTenantId(tenant.getItemId()); + usage.setPeriod(usagePeriod.getLabel()); + usage.setPeriodStart(usagePeriod.getStartMillis()); + usage.setPeriodEnd(usagePeriod.getEndMillis()); + usage.setProfileCount(snapshot.profileCount); + usage.setEventCount(snapshot.eventCount); + usage.setScopeCount(snapshot.scopeCount); + usage.setSegmentCount(snapshot.segmentCount); + usage.setRuleCount(snapshot.ruleCount); + usage.setStorageDocumentCount(snapshot.storageDocumentCount); + usage.setActiveApiKeyCount(tenant.getActiveApiKeys().size()); + usage.setScopeUsages(snapshot.scopeUsages); + usage.setRestRequestCount(currentRestRequestCount(tenant.getItemId())); + usage.setCollectedAt(snapshot.collectedAt); + return usage; + } + + static UsagePeriod resolvePeriod(String period) { + String effectivePeriod = (period == null || period.trim().isEmpty()) ? DEFAULT_PERIOD : period.trim(); + if (DEFAULT_PERIOD.equals(effectivePeriod) || "24h".equals(effectivePeriod)) { + return forYearMonth(YearMonth.now(ZoneOffset.UTC)); + } + if (MONTH_PERIOD.matcher(effectivePeriod).matches()) { + try { + return forYearMonth(YearMonth.parse(effectivePeriod)); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("Unsupported usage period: " + effectivePeriod, e); + } + } + throw new IllegalArgumentException("Unsupported usage period: " + effectivePeriod); + } + + private static UsagePeriod forYearMonth(YearMonth yearMonth) { + Instant start = yearMonth.atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC); + Instant end = yearMonth.plusMonths(1).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC); + return new UsagePeriod(yearMonth.toString(), start.toEpochMilli(), end.toEpochMilli()); + } + + private static String toIsoDateTime(long epochMillis) { + Calendar calendar = GregorianCalendar.from(Instant.ofEpochMilli(epochMillis).atZone(ZoneOffset.UTC)); + return DatatypeConverter.printDateTime(calendar); + } + + private static String cacheKey(String tenantId, String periodLabel) { + return tenantId + ":" + periodLabel; + } + + private void stopMetricsCollection() { + if (executor != null) { + try { + executor.shutdownNow(); + if (!executor.awaitTermination(3, TimeUnit.SECONDS)) { + logger.warn("Tenant usage executor did not terminate in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while shutting down the tenant usage executor"); + } finally { + executor = null; + } + } + } + + static final class UsagePeriod { + private final String label; + private final long startMillis; + private final long endMillis; + + UsagePeriod(String label, long startMillis, long endMillis) { + this.label = label; + this.startMillis = startMillis; + this.endMillis = endMillis; + } + + String getLabel() { + return label; + } + + long getStartMillis() { + return startMillis; + } + + long getEndMillis() { + return endMillis; + } + } + + private static final class UsageSnapshot { + private long profileCount; + private long eventCount; + private long scopeCount; + private long segmentCount; + private long ruleCount; + private long storageDocumentCount; + private List scopeUsages = Collections.emptyList(); + private long collectedAt; + } +} diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml index 0f93bb1d1..b83b0daf8 100644 --- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml +++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml @@ -469,7 +469,7 @@ - @@ -477,13 +477,6 @@ - - - - - - @@ -670,17 +663,10 @@ - - - - - - - + - + 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 1afbb3d32..3c2795b0f 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 @@ -2315,11 +2315,15 @@ private long ipToLong(String ipAddress) { @Override public long queryCount(Condition condition, String itemType) { + // Real ES/OS scope every query (including counts) to the calling thread's execution + // context tenant on top of whatever the Condition itself matches - mirror that here so + // tests exercising the wrong execution context catch the same class of bug production would. + String currentTenantId = executionContextManager.getCurrentContext().getTenantId(); // Filter by refresh status to simulate Elasticsearch/OpenSearch behavior long count = 0; for (Map.Entry entry : itemsById.entrySet()) { Item item = entry.getValue(); - if (item.getItemType().equals(itemType)) { + if (item.getItemType().equals(itemType) && currentTenantId.equals(item.getTenantId())) { String itemKey = entry.getKey(); if (isItemAvailableForQuery(itemKey, itemType) && (condition == null || testMatch(condition, item))) { count++; @@ -2400,8 +2404,22 @@ public long getApiCallCount(String apiName) { } @Override - public long calculateStorageSize(String itemType) { - throw new UnsupportedOperationException("Not implemented"); + public long calculateStorageSize(String tenantId) { + // Real ES/OS implementations report a document count here despite the "storage size" + // name (see PersistenceService#calculateStorageSize javadoc vs. actual behavior); mirror + // that as a simple tenant-scoped item count across all types, respecting the same + // refresh-delay simulation as queryCount()/getAllItemsCount() so tests can exercise it consistently. + if (tenantId == null) { + return 0; + } + long count = 0; + for (Map.Entry entry : itemsById.entrySet()) { + Item item = entry.getValue(); + if (tenantId.equals(item.getTenantId()) && isItemAvailableForQuery(entry.getKey(), item.getItemType())) { + count++; + } + } + return count; } private Object getPropertyValue(Item item, String field) { diff --git a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java index 0f9e7857f..54ef7064c 100644 --- a/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java +++ b/services/src/test/java/org/apache/unomi/services/impl/InMemoryPersistenceServiceImplTest.java @@ -1376,6 +1376,140 @@ void shouldRespectTenantIsolationInGetAllItemsCount() { return null; }); } + + @Test + void shouldRespectTenantIsolationInQueryCount() { + // Given - profiles for two different tenants, same item type + executionContextManager.executeAsTenant("tenant1", () -> { + for (int i = 0; i < 4; i++) { + Profile profile = new Profile(); + profile.setItemId("tenant1-profile-" + i); + persistenceService.save(profile); + } + return null; + }); + + executionContextManager.executeAsTenant("tenant2", () -> { + for (int i = 0; i < 6; i++) { + Profile profile = new Profile(); + profile.setItemId("tenant2-profile-" + i); + persistenceService.save(profile); + } + return null; + }); + + // When - queryCount() is called from each tenant's own context (a plain, unfiltered + // condition should never leak the other tenant's items into the count) + long tenant1Count = executionContextManager.executeAsTenant("tenant1", () -> + TestHelper.retryUntil( + () -> persistenceService.queryCount(null, Profile.ITEM_TYPE), + c -> c == 4L + )); + + long tenant2Count = executionContextManager.executeAsTenant("tenant2", () -> + TestHelper.retryUntil( + () -> persistenceService.queryCount(null, Profile.ITEM_TYPE), + c -> c == 6L + )); + + // Then + assertEquals(4, tenant1Count, "Tenant1 should only see its own 4 profiles"); + assertEquals(6, tenant2Count, "Tenant2 should only see its own 6 profiles"); + } + + @Test + void shouldReturnZeroFromQueryCountWhenCalledUnderAnotherTenantsContext() { + // Given - a profile that only exists under tenant1 + executionContextManager.executeAsTenant("tenant1", () -> { + Profile profile = new Profile(); + profile.setItemId("tenant1-only-profile"); + persistenceService.save(profile); + return null; + }); + TestHelper.retryUntil( + () -> executionContextManager.executeAsTenant("tenant1", () -> persistenceService.queryCount(null, Profile.ITEM_TYPE)), + c -> c == 1L + ); + + // When - the same, unfiltered count is requested from a different tenant's context + long countFromOtherTenant = executionContextManager.executeAsTenant("tenant2", () -> + persistenceService.queryCount(null, Profile.ITEM_TYPE)); + + // Then - tenant1's data must not be visible from tenant2's context + assertEquals(0, countFromOtherTenant, "queryCount() must not leak items across tenant execution contexts"); + } + } + + @Nested + class StorageSizeOperations { + @Test + void shouldCountItemsAcrossAllTypesForTenant() { + // Given - a mix of item types for the same tenant + executionContextManager.executeAsTenant("tenant1", () -> { + for (int i = 0; i < 3; i++) { + Profile profile = new Profile(); + profile.setItemId("storage-profile-" + i); + persistenceService.save(profile); + } + CustomItem customItem = new CustomItem(); + customItem.setItemId("storage-custom-item"); + customItem.setItemType("storage-test-type"); + persistenceService.save(customItem); + return null; + }); + + // When - retry until refresh delay clears + long size = TestHelper.retryUntil( + () -> persistenceService.calculateStorageSize("tenant1"), + c -> c == 4L + ); + + // Then + assertEquals(4, size, "Should count all item types belonging to the tenant"); + } + + @Test + void shouldRespectTenantIsolationInCalculateStorageSize() { + // Given + executionContextManager.executeAsTenant("tenant1", () -> { + for (int i = 0; i < 2; i++) { + Profile profile = new Profile(); + profile.setItemId("tenant1-storage-profile-" + i); + persistenceService.save(profile); + } + return null; + }); + executionContextManager.executeAsTenant("tenant2", () -> { + for (int i = 0; i < 5; i++) { + Profile profile = new Profile(); + profile.setItemId("tenant2-storage-profile-" + i); + persistenceService.save(profile); + } + return null; + }); + + // When + long tenant1Size = TestHelper.retryUntil( + () -> persistenceService.calculateStorageSize("tenant1"), c -> c == 2L); + long tenant2Size = TestHelper.retryUntil( + () -> persistenceService.calculateStorageSize("tenant2"), c -> c == 5L); + + // Then + assertEquals(2, tenant1Size, "Tenant1 storage size should only include its own items"); + assertEquals(5, tenant2Size, "Tenant2 storage size should only include its own items"); + } + + @Test + void shouldReturnZeroForNullTenantId() { + assertEquals(0, persistenceService.calculateStorageSize(null), + "Should handle null tenantId gracefully"); + } + + @Test + void shouldReturnZeroForUnknownTenant() { + assertEquals(0, persistenceService.calculateStorageSize("no-such-tenant"), + "Should return 0 for a tenant with no items"); + } } @Nested 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 deleted file mode 100644 index 5125f1e38..000000000 --- a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantQuotaServiceTest.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -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 usageCache = (Map) 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 usageCache = (Map) 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"); - } -} diff --git a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTenantIsolationTest.java b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTenantIsolationTest.java new file mode 100644 index 000000000..ef9b38db2 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTenantIsolationTest.java @@ -0,0 +1,177 @@ +/* + * 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.api.Event; +import org.apache.unomi.api.Profile; +import org.apache.unomi.api.services.SchedulerService; +import org.apache.unomi.api.tenants.TenantEventPurgeResult; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.conditions.evaluator.ConditionEvaluatorDispatcher; +import org.apache.unomi.services.TestHelper; +import org.apache.unomi.services.common.security.ExecutionContextManagerImpl; +import org.apache.unomi.services.common.security.KarafSecurityService; +import org.apache.unomi.services.impl.InMemoryPersistenceServiceImpl; +import org.apache.unomi.services.impl.TestConditionEvaluators; +import org.apache.unomi.services.impl.TestTenantService; +import org.apache.unomi.services.impl.cache.MultiTypeCacheServiceImpl; +import org.apache.unomi.services.impl.definitions.DefinitionsServiceImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.osgi.framework.BundleContext; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Exercises {@link TenantUsageServiceImpl} against real {@link InMemoryPersistenceServiceImpl} and + * {@link ExecutionContextManagerImpl} implementations instead of Mockito mocks. + * + *

{@link TenantUsageServiceImplTest} mocks {@code ExecutionContextManager.executeAsTenant(...)} + * to simply invoke the given supplier, which makes it unable to catch a regression where a query + * runs under the wrong tenant context (the exact bug fixed for UNOMI-958, where profileCount/eventCount + * were silently scoped to the "system" tenant during the background refresh and always read 0). Using + * real persistence and a real execution-context manager here means these tests fail the same way + * production would if that scoping regressed.

+ */ +class TenantUsageServiceImplTenantIsolationTest { + + private static final String TENANT_A = "usage-isolation-tenant-a"; + private static final String TENANT_B = "usage-isolation-tenant-b"; + + private TestTenantService tenantService; + private PersistenceService persistenceService; + private DefinitionsServiceImpl definitionsService; + private ExecutionContextManagerImpl executionContextManager; + private KarafSecurityService securityService; + private MultiTypeCacheServiceImpl multiTypeCacheService; + private BundleContext bundleContext; + private SchedulerService schedulerService; + private TenantUsageServiceImpl tenantUsageService; + + @BeforeEach + void setUp() { + tenantService = new TestTenantService(); + tenantService.createTenant(TENANT_A, Collections.emptyMap()); + tenantService.createTenant(TENANT_B, Collections.emptyMap()); + + ConditionEvaluatorDispatcher conditionEvaluatorDispatcher = TestConditionEvaluators.createDispatcher(); + bundleContext = TestHelper.createMockBundleContext(); + securityService = TestHelper.createSecurityService(); + executionContextManager = TestHelper.createExecutionContextManager(securityService); + multiTypeCacheService = new MultiTypeCacheServiceImpl(); + persistenceService = new InMemoryPersistenceServiceImpl(executionContextManager, conditionEvaluatorDispatcher); + schedulerService = TestHelper.createSchedulerService("usage-isolation-scheduler-node", persistenceService, + executionContextManager, bundleContext, null, -1, true, true); + definitionsService = TestHelper.createDefinitionService(persistenceService, bundleContext, schedulerService, + multiTypeCacheService, executionContextManager, tenantService); + TestHelper.injectDefinitionsServiceIntoDispatcher(conditionEvaluatorDispatcher, definitionsService); + TestConditionEvaluators.getConditionTypes().forEach((key, value) -> definitionsService.setConditionType(value)); + + tenantUsageService = new TenantUsageServiceImpl(); + tenantUsageService.setPersistenceService(persistenceService); + tenantUsageService.setDefinitionsService(definitionsService); + tenantUsageService.setTenantService(tenantService); + tenantUsageService.setContextManager(executionContextManager); + } + + @AfterEach + void tearDown() throws Exception { + TestHelper.tearDown(schedulerService, multiTypeCacheService, persistenceService, tenantService, + TENANT_A, TENANT_B, "system"); + TestHelper.cleanupReferences(tenantService, securityService, executionContextManager, tenantUsageService, + persistenceService, definitionsService, schedulerService, multiTypeCacheService, bundleContext); + } + + @Test + void profileAndEventCountsStayIsolatedPerTenantEvenWhenRefreshedUnderSystemContext() { + seedTenant(TENANT_A, 2, 3); + seedTenant(TENANT_B, 5, 1); + persistenceService.refresh(); + + // The scheduled background collector refreshes usage from inside executeAsSystem(), not + // from inside the target tenant's own context. That's exactly the call shape that used to + // leave profileCount/eventCount scoped to "system" and reading 0 for every real tenant. + TenantUsage usageA = executionContextManager.executeAsSystem(() -> + tenantUsageService.getUsage(TENANT_A, TenantUsageService.DEFAULT_PERIOD)); + TenantUsage usageB = executionContextManager.executeAsSystem(() -> + tenantUsageService.getUsage(TENANT_B, TenantUsageService.DEFAULT_PERIOD)); + + assertNotNull(usageA); + assertEquals(2L, usageA.getProfileCount()); + assertEquals(3L, usageA.getEventCount()); + + assertNotNull(usageB); + assertEquals(5L, usageB.getProfileCount()); + assertEquals(1L, usageB.getEventCount()); + } + + @Test + void purgeEventsOlderThanOnlyDeletesTheTargetTenantsMatchingEvents() { + Date oldTimestamp = Date.from(Instant.now().minus(100, ChronoUnit.DAYS)); + Date recentTimestamp = new Date(); + + executionContextManager.executeAsTenant(TENANT_A, () -> { + persistenceService.save(eventAt("tenant-a-old-event", oldTimestamp)); + persistenceService.save(eventAt("tenant-a-recent-event", recentTimestamp)); + }); + executionContextManager.executeAsTenant(TENANT_B, () -> + persistenceService.save(eventAt("tenant-b-old-event", oldTimestamp))); + + TenantEventPurgeResult result = tenantUsageService.purgeEventsOlderThan(TENANT_A, 90); + + assertNotNull(result); + assertTrue(result.isPurgeRequested()); + executionContextManager.executeAsTenant(TENANT_A, () -> { + assertNull(persistenceService.load("tenant-a-old-event", Event.class)); + assertNotNull(persistenceService.load("tenant-a-recent-event", Event.class)); + }); + executionContextManager.executeAsTenant(TENANT_B, () -> + assertNotNull(persistenceService.load("tenant-b-old-event", Event.class))); + } + + private void seedTenant(String tenantId, int profileCount, int eventCount) { + executionContextManager.executeAsTenant(tenantId, () -> { + for (int i = 0; i < profileCount; i++) { + Profile profile = new Profile(); + profile.setItemId(tenantId + "-profile-" + i); + persistenceService.save(profile); + } + for (int i = 0; i < eventCount; i++) { + persistenceService.save(eventAt(tenantId + "-event-" + i, new Date())); + } + }); + } + + private Event eventAt(String itemId, Date timestamp) { + Event event = new Event(); + event.setItemId(itemId); + event.setEventType("pageView"); + event.setTimeStamp(timestamp); + return event; + } +} diff --git a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java new file mode 100644 index 000000000..b1be815c3 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java @@ -0,0 +1,404 @@ +/* + * 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.api.Event; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.Tenant; +import org.apache.unomi.api.tenants.TenantService; +import org.apache.unomi.api.tenants.TenantEventPurgeResult; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; +import org.apache.unomi.persistence.spi.PersistenceService; +import org.apache.unomi.persistence.spi.aggregate.BaseAggregate; +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.time.YearMonth; +import java.time.ZoneOffset; +import java.util.Collections; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TenantUsageServiceImplTest { + + @Mock + private PersistenceService persistenceService; + + @Mock + private DefinitionsService definitionsService; + + @Mock + private TenantService tenantService; + + @Mock + private ExecutionContextManager contextManager; + + private TenantUsageServiceImpl tenantUsageService; + + private ConditionType eventPropertyConditionType; + private ConditionType booleanConditionType; + + @BeforeEach + void setUp() { + tenantUsageService = new TenantUsageServiceImpl(); + tenantUsageService.setPersistenceService(persistenceService); + tenantUsageService.setDefinitionsService(definitionsService); + tenantUsageService.setTenantService(tenantService); + tenantUsageService.setContextManager(contextManager); + + eventPropertyConditionType = new ConditionType(); + eventPropertyConditionType.setItemId("eventPropertyCondition"); + booleanConditionType = new ConditionType(); + booleanConditionType.setItemId("booleanCondition"); + } + + @Test + void resolvePeriodAcceptsCurrentMonthAndLegacyAlias() { + TenantUsageServiceImpl.UsagePeriod current = TenantUsageServiceImpl.resolvePeriod("current-month"); + assertEquals(YearMonth.now(ZoneOffset.UTC).toString(), current.getLabel()); + assertTrue(current.getEndMillis() > current.getStartMillis()); + + TenantUsageServiceImpl.UsagePeriod legacy = TenantUsageServiceImpl.resolvePeriod("24h"); + assertEquals(current.getLabel(), legacy.getLabel()); + } + + @Test + void resolvePeriodAcceptsYearMonth() { + TenantUsageServiceImpl.UsagePeriod period = TenantUsageServiceImpl.resolvePeriod("2026-03"); + assertEquals("2026-03", period.getLabel()); + } + + @Test + void resolvePeriodRejectsUnsupportedValue() { + assertThrows(IllegalArgumentException.class, () -> TenantUsageServiceImpl.resolvePeriod("7d")); + } + + @Test + void resolvePeriodRejectsOutOfRangeMonth() { + // "2026-13" matches the YYYY-MM shape but isn't a real month; must surface as a client + // error (IllegalArgumentException -> 400), not an unhandled DateTimeParseException -> 500. + assertThrows(IllegalArgumentException.class, () -> TenantUsageServiceImpl.resolvePeriod("2026-13")); + } + + @Test + void getUsageReturnsNullForMissingTenant() { + when(tenantService.getTenant("missing")).thenReturn(null); + + assertNull(tenantUsageService.getUsage("missing", TenantUsageService.DEFAULT_PERIOD)); + } + + @Test + void getUsageRejectsUnsupportedPeriod() { + assertThrows(IllegalArgumentException.class, + () -> tenantUsageService.getUsage("tenant-a", "7d")); + } + + @Test + void getUsageRefreshesOnDemandWhenCacheEmpty() { + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + stubRefreshCounts("tenant-a", 12L, 34L, 2L, 3L, 5L, 99L); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertNotNull(usage); + assertEquals("tenant-a", usage.getTenantId()); + assertEquals(YearMonth.now(ZoneOffset.UTC).toString(), usage.getPeriod()); + assertTrue(usage.getPeriodEnd() > usage.getPeriodStart()); + assertEquals(12L, usage.getProfileCount()); + assertEquals(34L, usage.getEventCount()); + assertEquals(2L, usage.getScopeCount()); + assertEquals(3L, usage.getSegmentCount()); + assertEquals(5L, usage.getRuleCount()); + assertEquals(99L, usage.getStorageDocumentCount()); + assertEquals(2L, usage.getActiveApiKeyCount()); + assertNotNull(usage.getScopeUsages()); + } + + @Test + void recordRestRequestIncrementsCounter() { + tenantUsageService.recordRestRequest("tenant-a"); + tenantUsageService.recordRestRequest("tenant-a"); + + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + stubRefreshCounts("tenant-a", 0L, 0L, 0L, 0L, 0L, 0L); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(2L, usage.getRestRequestCount()); + } + + @Test + void purgeEventsOlderThanReturnsNullForMissingTenant() { + when(tenantService.getTenant("missing")).thenReturn(null); + + assertNull(tenantUsageService.purgeEventsOlderThan("missing", 30)); + } + + @Test + void purgeEventsOlderThanRejectsRetentionBelowMinimum() { + when(tenantService.getTenant("tenant-a")).thenReturn(tenantWithId("tenant-a")); + + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> tenantUsageService.purgeEventsOlderThan("tenant-a", 3)); + assertTrue(ex.getMessage().contains(String.valueOf(TenantUsageService.MIN_EVENT_RETENTION_DAYS))); + } + + @Test + void purgeEventsOlderThanCountsAndDeletesUnderTenantContext() { + when(tenantService.getTenant("tenant-a")).thenReturn(tenantWithId("tenant-a")); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + when(contextManager.executeAsSystem(any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(0); + return supplier.get(); + }); + when(contextManager.executeAsTenant(eq("tenant-a"), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.queryCount(any(), eq(Event.ITEM_TYPE))).thenReturn(42L); + when(persistenceService.removeByQuery(any(), eq(Event.class))).thenReturn(true); + + TenantEventPurgeResult result = tenantUsageService.purgeEventsOlderThan("tenant-a", 90); + + assertNotNull(result); + assertEquals("tenant-a", result.getTenantId()); + assertEquals(90, result.getRetentionDays()); + assertEquals(42L, result.getEventsMatched()); + assertTrue(result.isPurgeRequested()); + verify(persistenceService).removeByQuery(any(), eq(Event.class)); + } + + @Test + void purgeEventsOlderThanReturnsFalseWhenDeleteNotAccepted() { + when(tenantService.getTenant("tenant-a")).thenReturn(tenantWithId("tenant-a")); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + when(contextManager.executeAsSystem(any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(0); + return supplier.get(); + }); + when(contextManager.executeAsTenant(eq("tenant-a"), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.queryCount(any(), eq(Event.ITEM_TYPE))).thenReturn(0L); + when(persistenceService.removeByQuery(any(), eq(Event.class))).thenReturn(false); + + TenantEventPurgeResult result = tenantUsageService.purgeEventsOlderThan("tenant-a", 30); + + assertNotNull(result); + assertFalse(result.isPurgeRequested()); + } + + + @Test + void resolvePeriodNullAndBlankDefaultToCurrentMonth() { + TenantUsageServiceImpl.UsagePeriod fromNull = TenantUsageServiceImpl.resolvePeriod(null); + TenantUsageServiceImpl.UsagePeriod fromBlank = TenantUsageServiceImpl.resolvePeriod(" "); + assertEquals(YearMonth.now(ZoneOffset.UTC).toString(), fromNull.getLabel()); + assertEquals(fromNull.getLabel(), fromBlank.getLabel()); + } + + @Test + void resolvePeriodYearMonthComputesUtcBounds() { + TenantUsageServiceImpl.UsagePeriod period = TenantUsageServiceImpl.resolvePeriod("2026-03"); + assertEquals("2026-03", period.getLabel()); + assertEquals(YearMonth.of(2026, 3).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(), + period.getStartMillis()); + assertEquals(YearMonth.of(2026, 4).atDay(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli(), + period.getEndMillis()); + } + + @Test + void getUsageWithHistoricalMonthUsesRequestedPeriodLabel() { + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + stubRefreshCounts("tenant-a", 1L, 2L, 1L, 1L, 1L, 1L); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", "2025-11"); + + assertNotNull(usage); + assertEquals("2025-11", usage.getPeriod()); + } + + @Test + void getUsageUsesCacheForSamePeriod() { + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + stubRefreshCounts("tenant-a", 1L, 2L, 1L, 1L, 1L, 1L); + + tenantUsageService.getUsage("tenant-a", "2025-11"); + tenantUsageService.getUsage("tenant-a", "2025-11"); + + org.mockito.Mockito.verify(persistenceService, org.mockito.Mockito.times(1)) + .getAllItemsCount(eq("segment"), eq("tenant-a")); + } + + @Test + void getUsageMergesScopeUsagesAndExcludesSystemScope() { + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + when(persistenceService.getAllItemsCount(eq("profile"), eq("tenant-a"))).thenReturn(0L); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(0L); + when(persistenceService.getAllItemsCount(eq("scope"), eq("tenant-a"))).thenReturn(1L); + when(persistenceService.getAllItemsCount(eq("segment"), eq("tenant-a"))).thenReturn(1L); + when(persistenceService.getAllItemsCount(eq("rule"), eq("tenant-a"))).thenReturn(1L); + when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(0L); + when(contextManager.executeAsTenant(eq("tenant-a"), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("segment"))) + .thenReturn(java.util.Map.of("site-a", 2L, org.apache.unomi.api.Metadata.SYSTEM_SCOPE, 99L)); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("rule"))) + .thenReturn(java.util.Map.of("site-a", 3L)); + when(persistenceService.load(eq(org.apache.unomi.api.Metadata.SYSTEM_SCOPE), eq(org.apache.unomi.api.Scope.class))) + .thenReturn(null); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(1, usage.getScopeUsages().size()); + assertEquals("site-a", usage.getScopeUsages().get(0).getScopeId()); + assertEquals(2L, usage.getScopeUsages().get(0).getSegmentCount()); + assertEquals(3L, usage.getScopeUsages().get(0).getRuleCount()); + } + + @Test + void getUsageSubtractsSystemScopeFromScopeCount() { + Tenant tenant = tenantWithId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + stubConditionTypes(); + when(persistenceService.getAllItemsCount(eq("profile"), eq("tenant-a"))).thenReturn(0L); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(0L); + when(persistenceService.getAllItemsCount(eq("scope"), eq("tenant-a"))).thenReturn(3L); + when(persistenceService.getAllItemsCount(eq("segment"), eq("tenant-a"))).thenReturn(0L); + when(persistenceService.getAllItemsCount(eq("rule"), eq("tenant-a"))).thenReturn(0L); + when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(0L); + when(contextManager.executeAsTenant(eq("tenant-a"), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("segment"))) + .thenReturn(Collections.emptyMap()); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("rule"))) + .thenReturn(Collections.emptyMap()); + org.apache.unomi.api.Scope systemScope = new org.apache.unomi.api.Scope(); + when(persistenceService.load(eq(org.apache.unomi.api.Metadata.SYSTEM_SCOPE), eq(org.apache.unomi.api.Scope.class))) + .thenReturn(systemScope); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(2L, usage.getScopeCount()); + } + + @Test + void recordRestRequestIgnoresNullAndBlankTenantId() { + tenantUsageService.recordRestRequest(null); + tenantUsageService.recordRestRequest(""); + tenantUsageService.recordRestRequest("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenantWithId("tenant-a")); + stubConditionTypes(); + stubRefreshCounts("tenant-a", 0L, 0L, 0L, 0L, 0L, 0L); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(1L, usage.getRestRequestCount()); + } + + @Test + void purgeEventsOlderThanAcceptsMinimumRetentionDays() { + when(tenantService.getTenant("tenant-a")).thenReturn(tenantWithId("tenant-a")); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + when(contextManager.executeAsSystem(any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(0); + return supplier.get(); + }); + when(contextManager.executeAsTenant(eq("tenant-a"), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.queryCount(any(), eq(Event.ITEM_TYPE))).thenReturn(0L); + when(persistenceService.removeByQuery(any(), eq(Event.class))).thenReturn(true); + + TenantEventPurgeResult result = tenantUsageService.purgeEventsOlderThan("tenant-a", + TenantUsageService.MIN_EVENT_RETENTION_DAYS); + + assertNotNull(result); + assertEquals(TenantUsageService.MIN_EVENT_RETENTION_DAYS, result.getRetentionDays()); + } + + + private Tenant tenantWithId(String tenantId) { + Tenant tenant = new Tenant(); + tenant.setItemId(tenantId); + org.apache.unomi.api.tenants.ApiKey publicKey = new org.apache.unomi.api.tenants.ApiKey(); + publicKey.setKeyType(org.apache.unomi.api.tenants.ApiKey.ApiKeyType.PUBLIC); + publicKey.setRevoked(false); + org.apache.unomi.api.tenants.ApiKey privateKey = new org.apache.unomi.api.tenants.ApiKey(); + privateKey.setKeyType(org.apache.unomi.api.tenants.ApiKey.ApiKeyType.PRIVATE); + privateKey.setRevoked(false); + tenant.setApiKeys(java.util.Arrays.asList(publicKey, privateKey)); + return tenant; + } + + private void stubConditionTypes() { + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventPropertyConditionType); + when(definitionsService.getConditionType("booleanCondition")).thenReturn(booleanConditionType); + } + + private void stubRefreshCounts(String tenantId, long profiles, long events, long scopes, long segments, + long rules, long storage) { + when(persistenceService.getAllItemsCount(eq("profile"), eq(tenantId))).thenReturn(profiles); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(events); + when(persistenceService.getAllItemsCount(eq("scope"), eq(tenantId))).thenReturn(scopes); + when(persistenceService.getAllItemsCount(eq("segment"), eq(tenantId))).thenReturn(segments); + when(persistenceService.getAllItemsCount(eq("rule"), eq(tenantId))).thenReturn(rules); + when(persistenceService.calculateStorageSize(tenantId)).thenReturn(storage); + when(contextManager.executeAsTenant(eq(tenantId), any(Supplier.class))).thenAnswer(invocation -> { + Supplier supplier = invocation.getArgument(1); + return supplier.get(); + }); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("segment"))) + .thenReturn(Collections.emptyMap()); + when(persistenceService.aggregateWithOptimizedQuery(any(), any(BaseAggregate.class), eq("rule"))) + .thenReturn(Collections.emptyMap()); + when(persistenceService.load(eq("systemscope"), eq(org.apache.unomi.api.Scope.class))).thenReturn(null); + } +} diff --git a/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java index 0ce0e785a..48088d720 100644 --- a/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java +++ b/tools/shell-dev-commands/src/main/java/org/apache/unomi/shell/dev/commands/tenants/TenantCrudCommand.java @@ -22,7 +22,6 @@ import java.io.PrintStream; import org.apache.unomi.api.PartialList; import org.apache.unomi.api.query.Query; -import org.apache.unomi.api.tenants.ResourceQuota; import org.apache.unomi.api.tenants.Tenant; import org.apache.unomi.api.tenants.TenantService; import org.apache.unomi.api.tenants.TenantStatus; @@ -47,7 +46,7 @@ public class TenantCrudCommand extends BaseCrudCommand { private static final Logger LOGGER = LoggerFactory.getLogger(TenantCrudCommand.class.getName()); private static final ObjectMapper OBJECT_MAPPER = new CustomObjectMapper(); private static final List PROPERTY_NAMES = List.of( - "itemId", "name", "description", "status", "creationDate", "lastModificationDate", "resourceQuota", "properties", "restrictedEventPermissions", "authorizedIPs" + "itemId", "name", "description", "status", "creationDate", "lastModificationDate", "properties", "restrictedEventPermissions", "authorizedIPs" ); @Reference @@ -198,9 +197,6 @@ public void update(String id, Map properties) { if (properties.containsKey("status")) { tenant.setStatus(Enum.valueOf(TenantStatus.class, (String) properties.get("status"))); } - if (properties.containsKey("resourceQuota")) { - tenant.setResourceQuota(OBJECT_MAPPER.convertValue(properties.get("resourceQuota"), ResourceQuota.class)); - } if (properties.containsKey("properties")) { @SuppressWarnings("unchecked") Map props = (Map) properties.get("properties"); @@ -248,7 +244,6 @@ public String getPropertiesHelp() { "Optional properties:", "- description: A description of the tenant's purpose or usage", "- status: The tenant's status (ACTIVE, DISABLED, etc.)", - "- resourceQuota: Resource quota limits for the tenant (profiles, events, requests)", "- properties: Additional custom properties for the tenant", "- restrictedEventPermissions: List of event types that require special permissions", "- authorizedIPs: List of IP addresses or CIDR ranges authorized to make requests"