This is an automated email from the ASF dual-hosted git repository. sergehuber pushed a commit to branch UNOMI-958-usage-rest in repository https://gitbox.apache.org/repos/asf/unomi.git
commit c93685526844a5ef4196cde93cba15e1b9ceb3ae Author: Serge Huber <[email protected]> AuthorDate: Wed Jul 8 00:40:06 2026 +0200 UNOMI-958: Expose per-tenant usage over REST and remove dead quota code Operators and upstream control planes need read-only usage metrics per tenant. Unomi measures usage; plan limits stay outside the CDP. This adds GET /tenants/{id}/usage, replaces internal monitoring/quota services, and hardens tenant-scoped REST security. --- .../apache/unomi/api/tenants/ResourceQuota.java | 258 --------------------- .../java/org/apache/unomi/api/tenants/Tenant.java | 92 +++----- .../org/apache/unomi/api/tenants/TenantUsage.java | 108 +++++++++ .../unomi/api/tenants/TenantUsageService.java | 40 ++++ .../java/org/apache/unomi/itests/TenantIT.java | 34 ++- manual/src/main/asciidoc/multitenancy.adoc | 8 +- rest/pom.xml | 10 + .../impl/DefaultRestAuthenticationConfig.java | 1 + .../apache/unomi/rest/security/SecurityFilter.java | 105 ++++++--- .../apache/unomi/rest/tenants/TenantEndpoint.java | 37 +++ .../unomi/rest/security/SecurityFilterTest.java | 137 +++++++++++ .../common/security/KarafSecurityService.java | 2 +- .../common/security/KarafSecurityServiceTest.java | 27 +++ .../unomi/services/impl/tenants/TenantMetrics.java | 59 ----- .../impl/tenants/TenantMonitoringService.java | 187 --------------- .../services/impl/tenants/TenantQuotaService.java | 180 -------------- .../unomi/services/impl/tenants/TenantUsage.java | 59 ----- .../impl/tenants/TenantUsageServiceImpl.java | 250 ++++++++++++++++++++ .../resources/OSGI-INF/blueprint/blueprint.xml | 22 +- .../impl/tenants/TenantQuotaServiceTest.java | 85 ------- .../impl/tenants/TenantUsageServiceImplTest.java | 163 +++++++++++++ .../dev/commands/tenants/TenantCrudCommand.java | 7 +- 22 files changed, 908 insertions(+), 963 deletions(-) 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<String, Long> 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<String, Long> 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<String, Long> 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..6919756fc 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 org.apache.unomi.api.Item; import java.util.*; import java.util.stream.Collectors; +import java.util.stream.Stream; import javax.xml.bind.annotation.XmlTransient; @@ -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 class Tenant extends Item { 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 class Tenant extends Item { */ @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 class Tenant extends Item { */ @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 class Tenant extends Item { */ @XmlTransient public List<ApiKey> 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 class Tenant extends Item { */ @XmlTransient public List<ApiKey> 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 class Tenant extends Item { 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<ApiKey> 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/TenantUsage.java b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java new file mode 100644 index 000000000..d76b79433 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsage.java @@ -0,0 +1,108 @@ +/* + * 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; + +/** + * 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. + */ +public class TenantUsage { + + private String tenantId; + private String period; + private long profileCount; + private long eventCount; + private long segmentCount; + private long ruleCount; + /** Document count across tenant indices (not byte size). */ + private long storageDocumentCount; + /** In-memory REST request counter for this tenant since the Unomi process started. */ + private long restRequestCount; + 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 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 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 getRestRequestCount() { + return restRequestCount; + } + + public void setRestRequestCount(long restRequestCount) { + this.restRequestCount = restRequestCount; + } + + 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..8d90122c3 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantUsageService.java @@ -0,0 +1,40 @@ +/* + * 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 for operators and upstream control planes. + * Unomi does not enforce quotas; callers use these metrics to apply limits upstream. + */ +public interface TenantUsageService { + + String DEFAULT_PERIOD = "24h"; + + /** + * Returns cached usage for the tenant, refreshing on demand when no snapshot exists yet. + * + * @param tenantId tenant identifier + * @param period reporting window label (currently only {@value #DEFAULT_PERIOD} is supported) + * @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); +} 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..240c19afe 100644 --- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java @@ -32,7 +32,6 @@ import org.apache.unomi.api.Profile; 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; @@ -103,10 +102,7 @@ public class TenantIT extends BaseIT { // 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 +115,7 @@ public class TenantIT extends BaseIT { } 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 +620,28 @@ public class TenantIT extends BaseIT { 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.assertEquals("Default period should be 24h", "24h", usage.get("period")); + Assert.assertNotNull("collectedAt should be present", usage.get("collectedAt")); + + 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()); + } + } + diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc index 2d98b1157..41e57f98b 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,17 @@ 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=24h" \ --user karaf:karaf \ -H "Content-Type: application/json" ---- +The response includes profile, event, segment, and rule counts, 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. + === Data Migration Migrate data between tenants: 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 @@ <artifactId>junit-jupiter</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-junit-jupiter</artifactId> + <scope>test</scope> + </dependency> + <dependency> + <groupId>org.mockito</groupId> + <artifactId>mockito-core</artifactId> + <scope>test</scope> + </dependency> </dependencies> <build> 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..6f6477404 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,7 @@ 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_MAPPING = Collections.unmodifiableMap(roles); } 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..b1836d531 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,6 +17,7 @@ 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.slf4j.Logger; @@ -32,6 +33,7 @@ import javax.ws.rs.core.Response; 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 +44,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 = org.osgi.service.component.annotations.ReferenceCardinality.OPTIONAL) + private TenantUsageService tenantUsageService; + @Context private ResourceInfo resourceInfo; @@ -56,47 +61,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 +85,56 @@ public class SecurityFilter implements ContainerRequestFilter { .build()); } } + + private boolean hasRequiredRole(RequiresRole roleAnnotation) { + for (String role : roleAnnotation.value()) { + if (securityService.hasRole(role)) { + return true; + } + } + return false; + } + + private boolean hasRequiredTenantAccess(ContainerRequestContext requestContext) { + 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 extends Annotation> A resolveAnnotation(Method method, Class<A> 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..095a6b13d 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,10 @@ import org.apache.unomi.api.tenants.ApiKey; 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.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 +52,9 @@ public class TenantEndpoint { @Reference private TenantService tenantService; + @Reference + private TenantUsageService tenantUsageService; + /** * Retrieves all tenants in the system. * @@ -68,6 +74,7 @@ public class TenantEndpoint { */ @GET @Path("/{tenantId}") + @RequiresTenant @Produces(MediaType.APPLICATION_JSON) public Response getTenant(@PathParam("tenantId") String tenantId) { Tenant tenant = tenantService.getTenant(tenantId); @@ -107,6 +114,7 @@ public class TenantEndpoint { */ @PUT @Path("/{tenantId}") + @RequiresTenant @Consumes(MediaType.APPLICATION_JSON) @Produces(MediaType.APPLICATION_JSON) public Tenant updateTenant(@PathParam("tenantId") String tenantId, Tenant tenant) { @@ -131,6 +139,7 @@ public class TenantEndpoint { */ @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 +160,7 @@ public class TenantEndpoint { */ @POST @Path("/{tenantId}/apikeys") + @RequiresTenant @Produces(MediaType.APPLICATION_JSON) public ApiKeyCreationResult generateApiKey(@PathParam("tenantId") String tenantId, @QueryParam("type") ApiKey.ApiKeyType type, @@ -180,6 +190,7 @@ public class TenantEndpoint { */ @GET @Path("/{tenantId}/apikeys/validate") + @RequiresTenant public Response validateApiKey(@PathParam("tenantId") String tenantId, @QueryParam("key") String apiKey, @QueryParam("type") ApiKey.ApiKeyType type) { @@ -190,4 +201,30 @@ public class TenantEndpoint { 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 label (currently only {@code 24h} is supported) + * @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); + } + } } 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..afcebc64d --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/security/SecurityFilterTest.java @@ -0,0 +1,137 @@ +/* + * 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.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 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, "resourceInfo", resourceInfo); + setField(filter, "uriInfo", uriInfo); + } + + @Test + void requiresTenantChecksPathTenantId() throws Exception { + Method method = TenantScopedResource.class.getMethod("tenantOperation"); + when(resourceInfo.getResourceMethod()).thenReturn(method); + MultivaluedHashMap<String, String> 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<Response> 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<Response> 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]); + } + + static class TenantScopedResource { + @RequiresTenant + public void tenantOperation() { + } + } + + @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/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 class KarafSecurityService implements SecurityService { @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 class KarafSecurityServiceTest { 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<String, TenantMetrics> 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<Tenant> 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<String, TenantUsage> 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<String, Long> 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..dd1fc3d40 --- /dev/null +++ b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImpl.java @@ -0,0 +1,250 @@ +/* + * 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.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.TenantService; +import org.apache.unomi.api.tenants.TenantUsage; +import org.apache.unomi.api.tenants.TenantUsageService; +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; +import java.util.concurrent.atomic.AtomicLong; + +public class TenantUsageServiceImpl implements TenantUsageService { + + private static final Logger logger = LoggerFactory.getLogger(TenantUsageServiceImpl.class); + + private PersistenceService persistenceService; + private DefinitionsService definitionsService; + private TenantService tenantService; + private ExecutionContextManager contextManager; + + private final Map<String, UsageSnapshot> usageCache = new ConcurrentHashMap<>(); + private final Map<String, AtomicLong> 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) { + String effectivePeriod = period == null || period.isEmpty() ? DEFAULT_PERIOD : period; + if (!DEFAULT_PERIOD.equals(effectivePeriod)) { + throw new IllegalArgumentException("Unsupported usage period: " + effectivePeriod); + } + if (tenantService.getTenant(tenantId) == null) { + return null; + } + UsageSnapshot snapshot = usageCache.get(tenantId); + if (snapshot == null) { + refreshTenantUsage(tenantId); + snapshot = usageCache.get(tenantId); + } + if (snapshot == null) { + return null; + } + return toDto(tenantId, effectivePeriod, snapshot); + } + + @Override + public void recordRestRequest(String tenantId) { + if (tenantId != null && !tenantId.isEmpty()) { + restRequestCounts.computeIfAbsent(tenantId, id -> new AtomicLong()).incrementAndGet(); + } + } + + 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 profilePropertyConditionType = definitionsService.getConditionType("profilePropertyCondition"); + ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); + + if (profilePropertyConditionType == null || eventPropertyConditionType == null) { + logger.debug("Required condition types not available, skipping usage update"); + return; + } + + try { + List<Tenant> tenants = tenantService.getAllTenants(); + for (Tenant tenant : tenants) { + if (shutdownNow) { + return; + } + refreshTenantUsage(tenant.getItemId(), profilePropertyConditionType, eventPropertyConditionType); + } + } catch (Exception e) { + logger.error("Error refreshing tenant usage", e); + } + } + + private void refreshTenantUsage(String tenantId) { + if (definitionsService == null) { + return; + } + ConditionType profilePropertyConditionType = definitionsService.getConditionType("profilePropertyCondition"); + ConditionType eventPropertyConditionType = definitionsService.getConditionType("eventPropertyCondition"); + if (profilePropertyConditionType == null || eventPropertyConditionType == null) { + return; + } + refreshTenantUsage(tenantId, profilePropertyConditionType, eventPropertyConditionType); + } + + private void refreshTenantUsage(String tenantId, ConditionType profilePropertyConditionType, + ConditionType eventPropertyConditionType) { + if (shutdownNow || persistenceService == null) { + return; + } + + UsageSnapshot snapshot = new UsageSnapshot(); + snapshot.profileCount = countByTenantProperty(tenantId, profilePropertyConditionType, Profile.ITEM_TYPE); + snapshot.eventCount = countByTenantProperty(tenantId, eventPropertyConditionType, Event.ITEM_TYPE); + snapshot.segmentCount = persistenceService.getAllItemsCount(Segment.ITEM_TYPE, tenantId); + snapshot.ruleCount = persistenceService.getAllItemsCount(Rule.ITEM_TYPE, tenantId); + snapshot.storageDocumentCount = persistenceService.calculateStorageSize(tenantId); + snapshot.collectedAt = System.currentTimeMillis(); + usageCache.put(tenantId, snapshot); + } + + private long countByTenantProperty(String tenantId, ConditionType conditionType, String itemType) { + Condition condition = new Condition(); + condition.setConditionTypeId(conditionType.getItemId()); + condition.setConditionType(conditionType); + condition.setParameter("propertyName", "tenantId"); + condition.setParameter("comparisonOperator", "equals"); + condition.setParameter("propertyValue", tenantId); + return persistenceService.queryCount(condition, itemType); + } + + private long currentRestRequestCount(String tenantId) { + AtomicLong counter = restRequestCounts.get(tenantId); + return counter != null ? counter.get() : 0L; + } + + private TenantUsage toDto(String tenantId, String period, UsageSnapshot snapshot) { + TenantUsage usage = new TenantUsage(); + usage.setTenantId(tenantId); + usage.setPeriod(period); + usage.setProfileCount(snapshot.profileCount); + usage.setEventCount(snapshot.eventCount); + usage.setSegmentCount(snapshot.segmentCount); + usage.setRuleCount(snapshot.ruleCount); + usage.setStorageDocumentCount(snapshot.storageDocumentCount); + usage.setRestRequestCount(currentRestRequestCount(tenantId)); + usage.setCollectedAt(snapshot.collectedAt); + return usage; + } + + 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; + } + } + } + + private static final class UsageSnapshot { + private long profileCount; + private long eventCount; + private long segmentCount; + private long ruleCount; + private long storageDocumentCount; + 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 @@ <property name="tenantService" ref="tenantServiceImpl"/> </bean> - <bean id="tenantMonitoringServiceImpl" class="org.apache.unomi.services.impl.tenants.TenantMonitoringService" + <bean id="tenantUsageServiceImpl" class="org.apache.unomi.services.impl.tenants.TenantUsageServiceImpl" init-method="activate" destroy-method="deactivate"> <property name="persistenceService" ref="persistenceService"/> <property name="definitionsService" ref="definitionsServiceImpl"/> @@ -477,13 +477,6 @@ <property name="contextManager" ref="executionContextManager"/> </bean> - <bean id="tenantQuotaServiceImpl" class="org.apache.unomi.services.impl.tenants.TenantQuotaService" - init-method="activate"> - <property name="persistenceService" ref="persistenceService"/> - <property name="tenantService" ref="tenantServiceImpl"/> - <property name="contextManager" ref="executionContextManager"/> - </bean> - <!-- Value Type Validator Beans --> <bean id="stringValueTypeValidator" class="org.apache.unomi.services.impl.validation.validators.StringValueTypeValidator"/> <bean id="integerValueTypeValidator" class="org.apache.unomi.services.impl.validation.validators.IntegerValueTypeValidator"/> @@ -670,17 +663,10 @@ </service-properties> </service> - <service id="tenantMonitoringService" ref="tenantMonitoringServiceImpl" - interface="org.apache.unomi.services.impl.tenants.TenantMonitoringService"> - <service-properties> - <entry key="service.exported.interfaces" value="org.apache.unomi.services.impl.tenants.TenantMonitoringService"/> - </service-properties> - </service> - - <service id="tenantQuotaService" ref="tenantQuotaServiceImpl" - interface="org.apache.unomi.services.impl.tenants.TenantQuotaService"> + <service id="tenantUsageService" ref="tenantUsageServiceImpl" + interface="org.apache.unomi.api.tenants.TenantUsageService"> <service-properties> - <entry key="service.exported.interfaces" value="org.apache.unomi.services.impl.tenants.TenantQuotaService"/> + <entry key="service.exported.interfaces" value="org.apache.unomi.api.tenants.TenantUsageService"/> </service-properties> </service> 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<String, TenantUsage> usageCache = (Map<String, TenantUsage>) usageCacheField.get(tenantQuotaService); - usageCache.put("tenant-a", new TenantUsage()); - usageCache.put("tenant-b", new TenantUsage()); - } - - @Test - public void updateUsageStatisticsUsesPerTenantCounts() throws Exception { - when(persistenceService.getAllItemsCount(eq("profile"), eq("tenant-a"))).thenReturn(10L); - when(persistenceService.getAllItemsCount(eq("event"), eq("tenant-a"))).thenReturn(20L); - when(persistenceService.getAllItemsCount(eq("profile"), eq("tenant-b"))).thenReturn(100L); - when(persistenceService.getAllItemsCount(eq("event"), eq("tenant-b"))).thenReturn(200L); - - Method updateMethod = TenantQuotaService.class.getDeclaredMethod("updateUsageStatistics"); - updateMethod.setAccessible(true); - updateMethod.invoke(tenantQuotaService); - - Field usageCacheField = TenantQuotaService.class.getDeclaredField("usageCache"); - usageCacheField.setAccessible(true); - @SuppressWarnings("unchecked") - Map<String, TenantUsage> usageCache = (Map<String, TenantUsage>) usageCacheField.get(tenantQuotaService); - - assertEquals(10L, usageCache.get("tenant-a").getProfileCount()); - assertEquals(20L, usageCache.get("tenant-a").getEventCount()); - assertEquals(100L, usageCache.get("tenant-b").getProfileCount()); - assertEquals(200L, usageCache.get("tenant-b").getEventCount()); - - verify(persistenceService).getAllItemsCount("profile", "tenant-a"); - verify(persistenceService).getAllItemsCount("event", "tenant-a"); - verify(persistenceService).getAllItemsCount("profile", "tenant-b"); - verify(persistenceService).getAllItemsCount("event", "tenant-b"); - verify(persistenceService, never()).getAllItemsCount("profile"); - verify(persistenceService, never()).getAllItemsCount("event"); - } -} 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..0de9ed568 --- /dev/null +++ b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantUsageServiceImplTest.java @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.services.impl.tenants; + +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.services.DefinitionsService; +import org.apache.unomi.api.tenants.Tenant; +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.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.Method; + +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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TenantUsageServiceImplTest { + + @Mock + private PersistenceService persistenceService; + + @Mock + private DefinitionsService definitionsService; + + @Mock + private TenantService tenantService; + + private TenantUsageServiceImpl tenantUsageService; + + @BeforeEach + void setUp() { + tenantUsageService = new TenantUsageServiceImpl(); + tenantUsageService.setPersistenceService(persistenceService); + tenantUsageService.setDefinitionsService(definitionsService); + tenantUsageService.setTenantService(tenantService); + } + + @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 = new Tenant(); + tenant.setItemId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + + ConditionType profileConditionType = new ConditionType(); + profileConditionType.setItemId("profilePropertyCondition"); + ConditionType eventConditionType = new ConditionType(); + eventConditionType.setItemId("eventPropertyCondition"); + when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(profileConditionType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventConditionType); + when(persistenceService.queryCount(any(), eq("profile"))).thenReturn(12L); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(34L); + when(persistenceService.getAllItemsCount(eq("segment"), eq("tenant-a"))).thenReturn(3L); + when(persistenceService.getAllItemsCount(eq("rule"), eq("tenant-a"))).thenReturn(5L); + when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(99L); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertNotNull(usage); + assertEquals("tenant-a", usage.getTenantId()); + assertEquals(TenantUsageService.DEFAULT_PERIOD, usage.getPeriod()); + assertEquals(12L, usage.getProfileCount()); + assertEquals(34L, usage.getEventCount()); + assertEquals(3L, usage.getSegmentCount()); + assertEquals(5L, usage.getRuleCount()); + assertEquals(99L, usage.getStorageDocumentCount()); + } + + @Test + void recordRestRequestIncrementsCounter() { + tenantUsageService.recordRestRequest("tenant-a"); + tenantUsageService.recordRestRequest("tenant-a"); + + Tenant tenant = new Tenant(); + tenant.setItemId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + + ConditionType profileConditionType = new ConditionType(); + profileConditionType.setItemId("profilePropertyCondition"); + ConditionType eventConditionType = new ConditionType(); + eventConditionType.setItemId("eventPropertyCondition"); + when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(profileConditionType); + when(definitionsService.getConditionType("eventPropertyCondition")).thenReturn(eventConditionType); + when(persistenceService.queryCount(any(), eq("profile"))).thenReturn(0L); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(0L); + 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); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(2L, usage.getRestRequestCount()); + } + + @Test + void refreshTenantUsageUsesTenantScopedCounts() throws Exception { + ConditionType profileConditionType = new ConditionType(); + profileConditionType.setItemId("profilePropertyCondition"); + ConditionType eventConditionType = new ConditionType(); + eventConditionType.setItemId("eventPropertyCondition"); + + when(persistenceService.queryCount(any(), eq("profile"))).thenReturn(10L); + when(persistenceService.queryCount(any(), eq("event"))).thenReturn(20L); + when(persistenceService.getAllItemsCount(eq("segment"), eq("tenant-a"))).thenReturn(1L); + when(persistenceService.getAllItemsCount(eq("rule"), eq("tenant-a"))).thenReturn(2L); + when(persistenceService.calculateStorageSize("tenant-a")).thenReturn(50L); + + Method refreshMethod = TenantUsageServiceImpl.class.getDeclaredMethod( + "refreshTenantUsage", String.class, ConditionType.class, ConditionType.class); + refreshMethod.setAccessible(true); + refreshMethod.invoke(tenantUsageService, "tenant-a", profileConditionType, eventConditionType); + + Tenant tenant = new Tenant(); + tenant.setItemId("tenant-a"); + when(tenantService.getTenant("tenant-a")).thenReturn(tenant); + + TenantUsage usage = tenantUsageService.getUsage("tenant-a", TenantUsageService.DEFAULT_PERIOD); + + assertEquals(10L, usage.getProfileCount()); + assertEquals(20L, usage.getEventCount()); + assertEquals(1L, usage.getSegmentCount()); + assertEquals(2L, usage.getRuleCount()); + assertEquals(50L, usage.getStorageDocumentCount()); + } +} 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 org.apache.karaf.shell.support.table.ShellTable; 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<String> 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 class TenantCrudCommand extends BaseCrudCommand { 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<String, Object> props = (Map<String, Object>) properties.get("properties"); @@ -248,7 +244,6 @@ public class TenantCrudCommand extends BaseCrudCommand { "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"
