This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new 969521404 UNOMI-1016: Index API key hashes to tenants in memory (#932)
969521404 is described below
commit 96952140416dff90e2d93b02eebd949b1ff10142
Author: Serge Huber <[email protected]>
AuthorDate: Wed Oct 28 20:17:34 2026 +0100
UNOMI-1016: Index API key hashes to tenants in memory (#932)
Co-authored-by: Claude Opus 5.5 <[email protected]>
---
.../apache/unomi/api/tenants/TenantService.java | 5 +-
docker/README.md | 6 +
manual/src/main/asciidoc/multitenancy.adoc | 18 ++
manual/src/main/asciidoc/whats-new.adoc | 1 +
.../main/resources/etc/custom.system.properties | 6 +
.../services/impl/tenants/TenantApiKeyIndex.java | 277 +++++++++++++++++++++
.../services/impl/tenants/TenantServiceImpl.java | 88 ++++++-
.../resources/OSGI-INF/blueprint/blueprint.xml | 4 +
.../main/resources/org.apache.unomi.services.cfg | 5 +
.../impl/tenants/TenantApiKeyIndexTest.java | 268 ++++++++++++++++++++
.../impl/tenants/TenantServiceImplTest.java | 141 ++++++++++-
11 files changed, 796 insertions(+), 23 deletions(-)
diff --git a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
index a5db6e557..718217bf6 100644
--- a/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
+++ b/api/src/main/java/org/apache/unomi/api/tenants/TenantService.java
@@ -160,7 +160,7 @@ public interface TenantService {
* Retrieves a tenant by its API key.
*
* @param key the API key to look up
- * @return the Tenant object if found, null otherwise
+ * @return the Tenant object if found, null otherwise, including when the
key is revoked or expired
*/
Tenant getTenantByApiKey(String key);
@@ -169,7 +169,8 @@ public interface TenantService {
*
* @param key the API key to look up
* @param requiredType the required type of the API key (PUBLIC or PRIVATE)
- * @return the Tenant object if found and key type matches, null otherwise
+ * @return the Tenant object if found and key type matches, null
otherwise, including when the key is
+ * revoked or expired
*/
Tenant getTenantByApiKey(String key, ApiKey.ApiKeyType requiredType);
diff --git a/docker/README.md b/docker/README.md
index 6f276c67d..0728743d3 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -139,6 +139,12 @@ Note: Linux doesn't support the host.docker.internal DNS
lookup method yet, it s
See "Websites on other origins" in the manual's configuration chapter.
+### API keys
+- `UNOMI_TENANT_APIKEY_REFRESH_INTERVAL`: How often, in milliseconds, each
node reloads its list of API keys to pick up keys created on other nodes
(default: `30000`)
+- `UNOMI_TENANT_APIKEY_MISS_REFRESH_INTERVAL`: Minimum time, in milliseconds,
between reloads triggered by unknown keys (default: `5000`)
+
+Revoked, replaced and expired keys are refused at once on every node whatever
these are set to. See "When a key change takes effect" in the manual's
multi-tenancy chapter.
+
### ElasticSearch-specific Variables
- `UNOMI_ELASTICSEARCH_ADDRESSES`: ElasticSearch host:port (default:
localhost:9200)
- `UNOMI_ELASTICSEARCH_USERNAME`: Optional username for ElasticSearch
diff --git a/manual/src/main/asciidoc/multitenancy.adoc
b/manual/src/main/asciidoc/multitenancy.adoc
index 89799171c..3474e8910 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -424,6 +424,23 @@ curl -X POST
"http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE"
NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets
expiration; omit it (or use `0`) for no expiration.
+When a key change takes effect:
+
+* A new key works at once on the node that generated it. Other nodes accept it
on its first request, or at the latest after the miss refresh interval below (5
seconds by default).
+* The key it replaces is refused at once on every node, as are revoked and
expired keys and the keys of a deleted tenant.
+* While a node that has just started reads its tenants, it refuses API keys
with `401` instead of making requests wait. Clients should retry.
+
+Two settings control how other nodes learn about new keys:
+
+[options="header"]
+|===
+|Property |Environment variable |Default |Meaning
+|`org.apache.unomi.tenant.apikey.refresh.interval`
|`UNOMI_TENANT_APIKEY_REFRESH_INTERVAL` |`30000` |How often, in milliseconds,
each node reloads its list of API keys.
+|`org.apache.unomi.tenant.apikey.miss.refresh.interval`
|`UNOMI_TENANT_APIKEY_MISS_REFRESH_INTERVAL` |`5000` |Minimum time, in
milliseconds, between reloads triggered by unknown keys. Lower values let a key
created on another node work sooner, but each reload reads every tenant, so
this also limits the load that requests with wrong keys can cause.
+|===
+
+Set them in `etc/custom.system.properties` or through the environment
variables; they must be positive and are read at startup. They never delay
refusing a revoked, replaced or expired key.
+
===== Deleting a Tenant
[source,bash]
@@ -530,6 +547,7 @@ See <<_migrate_from_3_0_to_4_0,Migrate from 3.0 to 4.0>>
and <<_shell_commands,S
* Verify API key is correct
* Check if using public key for private endpoint
* Ensure tenant ID matches the API key
+* If the key was just generated on another node, or this node has just
started, retry after a few seconds
====== 400 Bad Request
* Check if API key header is present
diff --git a/manual/src/main/asciidoc/whats-new.adoc
b/manual/src/main/asciidoc/whats-new.adoc
index 480c9c4c3..0c1a80f63 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -23,6 +23,7 @@ Complete tenant isolation for profiles, events, segments,
rules, and schemas. Pu
* Operator guide: <<_multitenancy,Multi-tenancy>>
* Migration: <<_migrate_from_3_0_to_4_0,Migrate from 3.0 to 4.0>> (includes
<<_client_facing_hardening_4_0,client-facing hardening>>)
* Migrating from Unomi 2.x: <<_v2_compatibility_mode,single-tenant
compatibility mode>> (`singletenantcompatibility.enabled` in
`org.apache.unomi.rest.authentication.cfg`)
+* Public and GraphQL API key checks no longer read every tenant on each
request, and refuse revoked and expired keys
(https://issues.apache.org/jira/browse/UNOMI-1016[UNOMI-1016])
==== Security hardening (credentials, profile binding, privileged APIs)
diff --git a/package/src/main/resources/etc/custom.system.properties
b/package/src/main/resources/etc/custom.system.properties
index f18379dab..fe48818e1 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -291,6 +291,12 @@
org.apache.unomi.rules.statistics.refresh.interval=${env:UNOMI_RULES_STATISTICS_
org.apache.unomi.rules.optimizationActivated=${env:UNOMI_RULES_OPTIMIZATION_ACTIVATED:-true}
# The number of threads to compose the pool size of the scheduler.
org.apache.unomi.scheduler.thread.poolSize=${env:UNOMI_SCHEDULER_THREAD_POOL_SIZE:-5}
+# How often, in milliseconds, each node reloads its API key -> tenant index,
to pick up keys created on other nodes.
+# Revoked, replaced and expired keys are refused at once on every node
whatever this is set to.
+org.apache.unomi.tenant.apikey.refresh.interval=${env:UNOMI_TENANT_APIKEY_REFRESH_INTERVAL:-30000}
+# Minimum time, in milliseconds, between index reloads triggered by unknown
API keys. Lower values let a key created on
+# another node work sooner; each reload reads every tenant, so this also
bounds the load requests with wrong keys cause.
+org.apache.unomi.tenant.apikey.miss.refresh.interval=${env:UNOMI_TENANT_APIKEY_MISS_REFRESH_INTERVAL:-5000}
#######################################################################################################################
## Third Party server settings
##
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndex.java
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndex.java
new file mode 100644
index 000000000..6b6448852
--- /dev/null
+++
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndex.java
@@ -0,0 +1,277 @@
+/*
+ * 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.security.SecretHashService;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.function.LongSupplier;
+import java.util.function.Supplier;
+
+/**
+ * In-memory map from API key hash to tenant id, so {@link
TenantServiceImpl#getTenantByApiKey} does not
+ * load every tenant on each request (UNOMI-1016). Same refresh pattern as the
tenant origins snapshot:
+ * <ul>
+ * <li>Loaded in full on the first lookup. Lookups never wait on a load
another request is running:
+ * during the first one they are refused, and the load is retried on a
miss at most once per miss
+ * interval if it fails.</li>
+ * <li>Updated in place when a tenant is saved or removed through this
node.</li>
+ * <li>Reloaded in full once per refresh interval by one request while
others keep
+ * the current state, to pick up keys created on other nodes.</li>
+ * <li>Reloaded on a miss at most once per miss interval.</li>
+ * </ul>
+ * Both intervals are configurable (defaults {@value
#DEFAULT_REFRESH_INTERVAL_MILLIS} and
+ * {@value #DEFAULT_MISS_REFRESH_INTERVAL_MILLIS} ms); see {@link
TenantServiceImpl#setApiKeyRefreshInterval}.
+ * The index only says which tenant a hash may belong to. A hit is confirmed
against that tenant freshly
+ * loaded by id, so a key revoked, rotated or expired — or a tenant deleted —
on any node is refused on
+ * the next request, not on the next refresh. Key hashes are deterministic
(SHA-256), so a plaintext key
+ * hashes to the same value as the stored digest.
+ */
+final class TenantApiKeyIndex {
+
+ private static final Logger LOGGER =
LoggerFactory.getLogger(TenantApiKeyIndex.class);
+
+ static final long DEFAULT_REFRESH_INTERVAL_MILLIS = 30_000;
+ static final long DEFAULT_MISS_REFRESH_INTERVAL_MILLIS = 5_000;
+
+ private final Supplier<List<Tenant>> loader;
+ private final Function<String, Tenant> tenantById;
+ private final LongSupplier clock;
+ private final long refreshNanos;
+ private final long missRefreshNanos;
+ /** Serializes full loads. Not held by saves and removals, so they never
wait on a slow load. */
+ private final ReentrantLock loadLock = new ReentrantLock();
+ /** Guards {@link #tenantIdByKeyHash} updates and {@link
#changesDuringLoad}. */
+ private final Object stateLock = new Object();
+
+ /** Key hash → tenant id; replaced as a whole. Duplicate hashes keep the
last tenant seen. */
+ private volatile Map<String, String> tenantIdByKeyHash =
Collections.emptyMap();
+ /**
+ * Saves (id → key hashes) and removals (id → {@code null}) made while a
full load runs, reapplied on
+ * top of its result so a load that read the store before them cannot undo
them. {@code null} when no
+ * load is running; guarded by {@link #stateLock}.
+ */
+ private Map<String, List<String>> changesDuringLoad;
+ private volatile boolean attempted;
+ /** When the next periodic reload is due; meaningful once {@link
#attempted}. */
+ private volatile long nextRefreshAt;
+ /** When the last full load was tried; meaningful once {@link #attempted}.
*/
+ private volatile long lastAttemptAt;
+
+ TenantApiKeyIndex(Supplier<List<Tenant>> loader, Function<String, Tenant>
tenantById, LongSupplier clock) {
+ this(loader, tenantById, clock, DEFAULT_REFRESH_INTERVAL_MILLIS,
DEFAULT_MISS_REFRESH_INTERVAL_MILLIS);
+ }
+
+ /**
+ * @param refreshMillis how often the index is reloaded in full, in
milliseconds
+ * @param missRefreshMillis minimum time between reloads triggered by
unknown keys, in milliseconds
+ */
+ TenantApiKeyIndex(Supplier<List<Tenant>> loader, Function<String, Tenant>
tenantById, LongSupplier clock,
+ long refreshMillis, long missRefreshMillis) {
+ this.loader = loader;
+ this.tenantById = tenantById;
+ this.clock = clock;
+ this.refreshNanos = TimeUnit.MILLISECONDS.toNanos(refreshMillis);
+ this.missRefreshNanos =
TimeUnit.MILLISECONDS.toNanos(missRefreshMillis);
+ }
+
+ /**
+ * Resolves a plaintext API key to its tenant, refreshing the index when
due or on a miss.
+ *
+ * @param plainTextKey the key from the request; may be {@code null}
+ * @param keyType required type, or {@code null} for any type
+ * @param hashService hashes the plaintext the same way keys are stored
+ * @return the matching tenant, or {@code null} when missing, revoked,
expired, or type-mismatched
+ */
+ Tenant find(String plainTextKey, ApiKey.ApiKeyType keyType,
SecretHashService hashService) {
+ if (plainTextKey == null || hashService == null) {
+ return null;
+ }
+ String keyHash = hashService.hash(plainTextKey);
+ String tenantId = current().get(keyHash);
+ if (tenantId == null) {
+ tenantId = afterMiss().get(keyHash);
+ }
+ if (tenantId == null) {
+ return null;
+ }
+ Tenant tenant = tenantById.apply(tenantId);
+ return accepts(tenant, keyHash, keyType) ? tenant : null;
+ }
+
+ private static boolean accepts(Tenant tenant, String keyHash,
ApiKey.ApiKeyType keyType) {
+ if (tenant == null || tenant.getApiKeys() == null) {
+ return false;
+ }
+ Date now = new Date();
+ for (ApiKey apiKey : tenant.getApiKeys()) {
+ if (apiKey == null || !keyHash.equals(apiKey.getKeyHash())) {
+ continue;
+ }
+ if (keyType != null && apiKey.getKeyType() != keyType) {
+ continue;
+ }
+ if (apiKey.isRevoked()) {
+ continue;
+ }
+ if (apiKey.getExpirationDate() != null &&
!apiKey.getExpirationDate().after(now)) {
+ continue;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * The current hash → tenant id map, refreshed first if the periodic
reload is due. Never waits: while
+ * another request runs a load, this one answers from the map as it is —
so until the first load has
+ * finished, keys this node has not seen saved are refused rather than
queued behind it.
+ */
+ Map<String, String> current() {
+ if (!attempted || clock.getAsLong() - nextRefreshAt >= 0) {
+ tryReload();
+ }
+ return tenantIdByKeyHash;
+ }
+
+ /**
+ * Reloads now unless a full load was tried within the miss interval or
+ * another reload is running, and returns the resulting map.
+ */
+ Map<String, String> afterMiss() {
+ if (!attempted || clock.getAsLong() - lastAttemptAt >=
missRefreshNanos) {
+ tryReload();
+ }
+ return tenantIdByKeyHash;
+ }
+
+ /** Full load, waiting for any reload in progress. Useful from tests. */
+ void load() {
+ loadLock.lock();
+ try {
+ doLoad();
+ } finally {
+ loadLock.unlock();
+ }
+ }
+
+ /** A tenant was created or updated through this node: replace its key
hashes from the saved object. */
+ void tenantSaved(Tenant tenant) {
+ if (tenant == null || tenant.getItemId() == null) {
+ return;
+ }
+ applyChange(tenant.getItemId(), hashesOf(tenant));
+ }
+
+ /** A tenant was removed through this node: drop its key hashes. */
+ void tenantRemoved(String tenantId) {
+ if (tenantId == null) {
+ return;
+ }
+ applyChange(tenantId, null);
+ }
+
+ private void applyChange(String tenantId, List<String> hashes) {
+ synchronized (stateLock) {
+ Map<String, String> byHash = new HashMap<>(tenantIdByKeyHash);
+ replaceHashes(byHash, tenantId, hashes);
+ tenantIdByKeyHash = Collections.unmodifiableMap(byHash);
+ if (changesDuringLoad != null) {
+ changesDuringLoad.put(tenantId, hashes);
+ }
+ }
+ }
+
+ private void tryReload() {
+ if (loadLock.tryLock()) {
+ try {
+ doLoad();
+ } finally {
+ loadLock.unlock();
+ }
+ }
+ }
+
+ /** Runs with {@link #loadLock} held. */
+ private void doLoad() {
+ long now = clock.getAsLong();
+ attempted = true;
+ lastAttemptAt = now;
+ synchronized (stateLock) {
+ changesDuringLoad = new LinkedHashMap<>();
+ }
+ try {
+ List<Tenant> tenants = loader.get();
+ Map<String, String> byHash = new HashMap<>();
+ for (Tenant tenant : tenants) {
+ if (tenant != null && tenant.getItemId() != null) {
+ for (String hash : hashesOf(tenant)) {
+ byHash.put(hash, tenant.getItemId());
+ }
+ }
+ }
+ synchronized (stateLock) {
+ changesDuringLoad.forEach((tenantId, hashes) ->
replaceHashes(byHash, tenantId, hashes));
+ changesDuringLoad = null;
+ tenantIdByKeyHash = Collections.unmodifiableMap(byHash);
+ }
+ nextRefreshAt = now + refreshNanos;
+ } catch (RuntimeException e) {
+ synchronized (stateLock) {
+ changesDuringLoad = null;
+ }
+ nextRefreshAt = now + missRefreshNanos;
+ LOGGER.warn("Could not load the API key → tenant index; keeping
the previous one: {}", e.getMessage());
+ LOGGER.debug("API key index load failure", e);
+ }
+ }
+
+ private static List<String> hashesOf(Tenant tenant) {
+ List<String> hashes = new ArrayList<>();
+ if (tenant.getApiKeys() != null) {
+ for (ApiKey apiKey : tenant.getApiKeys()) {
+ if (apiKey != null && apiKey.getKeyHash() != null) {
+ hashes.add(apiKey.getKeyHash());
+ }
+ }
+ }
+ return hashes;
+ }
+
+ /** Drops the tenant's hashes, then maps {@code hashes} to it; {@code
null} means the tenant is gone. */
+ private static void replaceHashes(Map<String, String> byHash, String
tenantId, List<String> hashes) {
+ byHash.values().removeIf(tenantId::equals);
+ if (hashes != null) {
+ for (String hash : hashes) {
+ byHash.put(hash, tenantId);
+ }
+ }
+ }
+}
diff --git
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
index 4841c85d3..5fd8bd9d7 100644
---
a/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
+++
b/services/src/main/java/org/apache/unomi/services/impl/tenants/TenantServiceImpl.java
@@ -67,6 +67,13 @@ public class TenantServiceImpl implements TenantService {
private PersistenceService persistenceService;
private ExecutionContextManager executionContextManager;
private SecretHashService secretHashService;
+ /**
+ * Hash → tenant index for {@link #getTenantByApiKey}. Built once
persistence and the execution
+ * context manager are set. Package-visible so unit tests can drive
refresh timing.
+ */
+ TenantApiKeyIndex apiKeyIndex;
+ private long apiKeyRefreshInterval =
TenantApiKeyIndex.DEFAULT_REFRESH_INTERVAL_MILLIS;
+ private long apiKeyMissRefreshInterval =
TenantApiKeyIndex.DEFAULT_MISS_REFRESH_INTERVAL_MILLIS;
/**
* Sets the persistence service via Blueprint dependency injection.
@@ -75,6 +82,7 @@ public class TenantServiceImpl implements TenantService {
*/
public void setPersistenceService(PersistenceService persistenceService) {
this.persistenceService = persistenceService;
+ rebuildApiKeyIndex();
}
/**
@@ -84,6 +92,7 @@ public class TenantServiceImpl implements TenantService {
*/
public void setExecutionContextManager(ExecutionContextManager
executionContextManager) {
this.executionContextManager = executionContextManager;
+ rebuildApiKeyIndex();
}
/**
@@ -95,6 +104,62 @@ public class TenantServiceImpl implements TenantService {
this.secretHashService = secretHashService;
}
+ /**
+ * How often, in milliseconds, the API key index is reloaded in full to
pick up keys created on other
+ * nodes ({@code tenant.apikey.refresh.interval}).
+ *
+ * @param apiKeyRefreshInterval a positive interval; anything else keeps
the default
+ */
+ public void setApiKeyRefreshInterval(long apiKeyRefreshInterval) {
+ this.apiKeyRefreshInterval =
positiveOrDefault("tenant.apikey.refresh.interval", apiKeyRefreshInterval,
+ TenantApiKeyIndex.DEFAULT_REFRESH_INTERVAL_MILLIS);
+ rebuildApiKeyIndex();
+ }
+
+ /**
+ * Minimum time, in milliseconds, between API key index reloads triggered
by unknown keys
+ * ({@code tenant.apikey.miss.refresh.interval}). Bounds the load requests
with invalid keys can cause.
+ *
+ * @param apiKeyMissRefreshInterval a positive interval; anything else
keeps the default
+ */
+ public void setApiKeyMissRefreshInterval(long apiKeyMissRefreshInterval) {
+ this.apiKeyMissRefreshInterval =
positiveOrDefault("tenant.apikey.miss.refresh.interval",
apiKeyMissRefreshInterval,
+ TenantApiKeyIndex.DEFAULT_MISS_REFRESH_INTERVAL_MILLIS);
+ rebuildApiKeyIndex();
+ }
+
+ private static long positiveOrDefault(String name, long value, long
defaultValue) {
+ if (value > 0) {
+ return value;
+ }
+ LOGGER.warn("{} must be a positive number of milliseconds, got {};
using {}", name, value, defaultValue);
+ return defaultValue;
+ }
+
+ private void rebuildApiKeyIndex() {
+ if (persistenceService == null || executionContextManager == null) {
+ return;
+ }
+ apiKeyIndex = new TenantApiKeyIndex(
+ () -> executionContextManager.executeAsSystem(() ->
persistenceService.getAllItems(Tenant.class)),
+ tenantId -> executionContextManager.executeAsSystem(() ->
persistenceService.load(tenantId, Tenant.class)),
+ nanoClock, apiKeyRefreshInterval, apiKeyMissRefreshInterval);
+ }
+
+ private void notifyApiKeyIndexSaved(Tenant tenant) {
+ TenantApiKeyIndex index = apiKeyIndex;
+ if (index != null) {
+ index.tenantSaved(tenant);
+ }
+ }
+
+ private void notifyApiKeyIndexRemoved(String tenantId) {
+ TenantApiKeyIndex index = apiKeyIndex;
+ if (index != null) {
+ index.tenantRemoved(tenantId);
+ }
+ }
+
/**
* Registers a tenant lifecycle listener.
*
@@ -227,6 +292,7 @@ public class TenantServiceImpl implements TenantService {
// Roll back rather than leave a partially-initialized tenant
(e.g. missing its
// private key) persisted after a failure partway through key
generation.
persistenceService.remove(tenant.getItemId(), Tenant.class);
+ notifyApiKeyIndexRemoved(tenant.getItemId());
throw e;
}
@@ -237,6 +303,7 @@ public class TenantServiceImpl implements TenantService {
if (reloadedTenant == null) {
throw new IllegalStateException("Failed to reload tenant after
creation: " + tenant.getItemId());
}
+ notifyApiKeyIndexSaved(reloadedTenant);
return reloadedTenant;
});
}
@@ -277,6 +344,7 @@ public class TenantServiceImpl implements TenantService {
if (persistenceService.save(tenant, false, false)) {
persistenceService.refreshIndex(Tenant.class);
invalidateGetOrCreateCache(tenantId);
+ notifyApiKeyIndexSaved(tenant);
break;
}
if (attempt == WRITE_ATTEMPTS) {
@@ -307,6 +375,7 @@ public class TenantServiceImpl implements TenantService {
});
if (tenant != null) {
invalidateGetOrCreateCache(tenant.getItemId());
+ notifyApiKeyIndexSaved(tenant);
}
}
@@ -339,6 +408,9 @@ public class TenantServiceImpl implements TenantService {
throw new ConcurrentModificationException("Tenant " + tenantId + "
kept changing while its settings were being updated");
});
invalidateGetOrCreateCache(tenantId);
+ if (saved != null) {
+ notifyApiKeyIndexSaved(saved);
+ }
return saved;
}
@@ -361,6 +433,7 @@ public class TenantServiceImpl implements TenantService {
persistenceService.refreshIndex(Tenant.class);
});
invalidateGetOrCreateCache(tenantId);
+ notifyApiKeyIndexRemoved(tenantId);
}
@Override
@@ -410,13 +483,12 @@ public class TenantServiceImpl implements TenantService {
@Override
public Tenant getTenantByApiKey(String apiKey, ApiKey.ApiKeyType keyType) {
- return executionContextManager.executeAsSystem(() -> {
- List<Tenant> tenants =
persistenceService.getAllItems(Tenant.class);
- return tenants.stream()
- .filter(tenant -> tenant.getApiKeys() != null &&
tenant.getApiKeys().stream()
- .anyMatch(key -> (keyType == null || key.getKeyType() ==
keyType) && matchesKey(key, apiKey)))
- .findFirst()
- .orElse(null);
- });
+ TenantApiKeyIndex index = apiKeyIndex;
+ if (index == null) {
+ return null;
+ }
+ // Hash lookup stays outside executeAsSystem: the index's loaders
already run as system. A hit is
+ // confirmed against the tenant loaded by id, so revoked, expired and
deleted keys are refused.
+ return index.find(apiKey, keyType, secretHashService);
}
}
diff --git a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 96d69dfff..e5d416fc2 100644
--- a/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/services/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -117,6 +117,8 @@
<cm:property name="scheduler.thread.poolSize" value="5"/>
<cm:property name="scheduler.executorNode" value="true" />
<cm:property name="scheduler.completedTaskTtlDays" value="30" />
+ <cm:property name="tenant.apikey.refresh.interval" value="30000"/>
+ <cm:property name="tenant.apikey.miss.refresh.interval"
value="5000"/>
<cm:property name="scheduler.nodeId" value="" />
<cm:property name="scheduler.lockTimeout" value="10000" />
<cm:property name="scheduler.purgeTaskEnabled" value="true" />
@@ -463,6 +465,8 @@
<property name="persistenceService" ref="persistenceService"/>
<property name="executionContextManager"
ref="executionContextManager"/>
<property name="secretHashService" ref="secretHashServiceImpl"/>
+ <property name="apiKeyRefreshInterval"
value="${services.tenant.apikey.refresh.interval}"/>
+ <property name="apiKeyMissRefreshInterval"
value="${services.tenant.apikey.miss.refresh.interval}"/>
</bean>
<bean id="tenantMigrationServiceImpl"
class="org.apache.unomi.services.impl.tenants.TenantMigrationService">
diff --git a/services/src/main/resources/org.apache.unomi.services.cfg
b/services/src/main/resources/org.apache.unomi.services.cfg
index dccffafda..3af0557e4 100644
--- a/services/src/main/resources/org.apache.unomi.services.cfg
+++ b/services/src/main/resources/org.apache.unomi.services.cfg
@@ -105,3 +105,8 @@
goals.refresh.interval=${org.apache.unomi.goals.refresh.interval:-5000}
# The interval in milliseconds to use to reload the campaigns
campaigns.refresh.interval=${org.apache.unomi.campaigns.refresh.interval:-5000}
+
+# How often, in milliseconds, each node reloads its API key -> tenant index to
pick up keys created on other nodes
+tenant.apikey.refresh.interval=${org.apache.unomi.tenant.apikey.refresh.interval:-30000}
+# Minimum time, in milliseconds, between index reloads triggered by unknown
API keys
+tenant.apikey.miss.refresh.interval=${org.apache.unomi.tenant.apikey.miss.refresh.interval:-5000}
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndexTest.java
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndexTest.java
new file mode 100644
index 000000000..251177e9d
--- /dev/null
+++
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantApiKeyIndexTest.java
@@ -0,0 +1,268 @@
+/*
+ * 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.security.SecretHashService;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.Tenant;
+import org.apache.unomi.services.security.SecretHashServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TenantApiKeyIndexTest {
+
+ private static final long REFRESH =
TimeUnit.MILLISECONDS.toNanos(TenantApiKeyIndex.DEFAULT_REFRESH_INTERVAL_MILLIS);
+ private static final long MISS =
TimeUnit.MILLISECONDS.toNanos(TenantApiKeyIndex.DEFAULT_MISS_REFRESH_INTERVAL_MILLIS);
+
+ private final List<Tenant> tenants = new CopyOnWriteArrayList<>();
+ private final AtomicInteger loads = new AtomicInteger();
+ private volatile RuntimeException failure;
+ /** When set, a full load blocks on it after reading {@link #tenants}. */
+ private volatile CountDownLatch loadGate;
+ /** Counted down once a full load has read {@link #tenants}. */
+ private volatile CountDownLatch loadRead;
+ private final long[] now = {TimeUnit.DAYS.toNanos(3)};
+ private final SecretHashService hashService = new SecretHashServiceImpl();
+ private TenantApiKeyIndex index;
+
+ @BeforeEach
+ void setUp() {
+ tenants.add(tenantWithKey("acme", "plain-public",
ApiKey.ApiKeyType.PUBLIC));
+ index = new TenantApiKeyIndex(() -> {
+ loads.incrementAndGet();
+ if (failure != null) {
+ throw failure;
+ }
+ List<Tenant> read = new ArrayList<>(tenants);
+ if (loadRead != null) {
+ loadRead.countDown();
+ }
+ if (loadGate != null) {
+ try {
+ loadGate.await(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return read;
+ }, this::byId, () -> now[0]);
+ }
+
+ @Test
+ void refreshesExactlyWhenDue() {
+ index.load();
+ assertEquals(1, loads.get());
+ now[0] += REFRESH - 1;
+ index.current();
+ assertEquals(1, loads.get());
+
+ now[0] += 1;
+ index.current();
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void usesConfiguredIntervals() {
+ TenantApiKeyIndex custom = new TenantApiKeyIndex(() -> {
+ loads.incrementAndGet();
+ return new ArrayList<>(tenants);
+ }, this::byId, () -> now[0], 2_000, 500);
+ custom.load();
+ now[0] += TimeUnit.MILLISECONDS.toNanos(500);
+ custom.afterMiss();
+ assertEquals(2, loads.get());
+ // Any full load restarts the refresh interval.
+ now[0] += TimeUnit.MILLISECONDS.toNanos(2_000) - 1;
+ custom.current();
+ assertEquals(2, loads.get());
+ now[0] += 1;
+ custom.current();
+ assertEquals(3, loads.get());
+ }
+
+ @Test
+ void missReloadsAreLimited() {
+ index.load();
+ now[0] += MISS - 1;
+ index.afterMiss();
+ assertEquals(1, loads.get());
+
+ now[0] += 1;
+ index.afterMiss();
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void findResolvesWithoutFurtherLoads() {
+ index.load();
+ assertEquals(1, loads.get());
+
+ Tenant found = index.find("plain-public", ApiKey.ApiKeyType.PUBLIC,
hashService);
+ assertEquals("acme", found.getItemId());
+ assertEquals(1, loads.get(), "A hit must not reload tenants");
+
+ assertNull(index.find("plain-public", ApiKey.ApiKeyType.PRIVATE,
hashService));
+ assertEquals(1, loads.get());
+ }
+
+ @Test
+ void missTriggersReloadThenFindsKeyFromOtherNode() {
+ index.load();
+ tenants.add(tenantWithKey("beta", "beta-key",
ApiKey.ApiKeyType.PUBLIC));
+
+ assertNull(index.find("beta-key", null, hashService));
+ now[0] += MISS;
+ Tenant found = index.find("beta-key", null, hashService);
+ assertEquals("beta", found.getItemId());
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void tenantSavedUpdatesIndexImmediately() {
+ index.load();
+ Tenant updated = tenantWithKey("acme", "rotated-key",
ApiKey.ApiKeyType.PUBLIC);
+ tenants.set(0, updated);
+ index.tenantSaved(updated);
+
+ assertNull(index.find("plain-public", null, hashService));
+ assertSame(updated, index.find("rotated-key", null, hashService));
+ assertEquals(1, loads.get());
+ }
+
+ @Test
+ void tenantRemovedDropsKeysImmediately() {
+ index.load();
+ index.tenantRemoved("acme");
+ assertNull(index.current().get(hashService.hash("plain-public")));
+ }
+
+ @Test
+ void
refusesKeysRevokedExpiredOrDeletedOnAnotherNodeWithoutWaitingForRefresh() {
+ index.load();
+ Tenant revoked = tenantWithKey("acme", "plain-public",
ApiKey.ApiKeyType.PUBLIC);
+ revoked.getApiKeys().get(0).setRevoked(true);
+ tenants.set(0, revoked);
+ assertNull(index.find("plain-public", null, hashService));
+
+ Tenant expired = tenantWithKey("acme", "plain-public",
ApiKey.ApiKeyType.PUBLIC);
+ expired.getApiKeys().get(0).setExpirationDate(new
Date(System.currentTimeMillis() - 60_000));
+ tenants.set(0, expired);
+ assertNull(index.find("plain-public", null, hashService));
+
+ tenants.clear();
+ assertNull(index.find("plain-public", null, hashService));
+ assertEquals(1, loads.get(), "Refusals must come from the tenant
loaded by id, not a refresh");
+ }
+
+ @Test
+ void lookupsDuringALoadAreRefusedWithoutWaiting() throws Exception {
+ loadGate = new CountDownLatch(1);
+ loadRead = new CountDownLatch(1);
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ Future<Tenant> first = pool.submit(() ->
index.find("plain-public", null, hashService));
+ assertTrue(loadRead.await(10, TimeUnit.SECONDS));
+ // The first load is still running: answer at once instead of
queueing behind it.
+ assertNull(index.find("plain-public", null, hashService));
+ loadGate.countDown();
+ assertEquals("acme", first.get(10, TimeUnit.SECONDS).getItemId());
+ } finally {
+ pool.shutdownNow();
+ }
+ assertEquals("acme", index.find("plain-public", null,
hashService).getItemId());
+ assertEquals(1, loads.get());
+ }
+
+ @Test
+ void manyUnknownKeysTriggerAtMostOneLoadPerMissWindow() {
+ index.load();
+ for (int i = 0; i < 1_000; i++) {
+ assertNull(index.find("unknown-" + i, null, hashService));
+ }
+ assertEquals(1, loads.get());
+ now[0] += MISS;
+ for (int i = 0; i < 1_000; i++) {
+ assertNull(index.find("unknown-" + i, null, hashService));
+ }
+ assertEquals(2, loads.get());
+ }
+
+ @Test
+ void savesAndRemovalsDuringALoadAreNotUndoneByIt() throws Exception {
+ index.load();
+ loadGate = new CountDownLatch(1);
+ loadRead = new CountDownLatch(1);
+ now[0] += REFRESH;
+ ExecutorService pool = Executors.newSingleThreadExecutor();
+ try {
+ Future<?> reload = pool.submit(() -> index.current());
+ assertTrue(loadRead.await(10, TimeUnit.SECONDS));
+ // The reload has read the store; these must neither wait for it
nor be lost when it finishes.
+ Tenant beta = tenantWithKey("beta", "beta-key",
ApiKey.ApiKeyType.PUBLIC);
+ tenants.add(beta);
+ index.tenantSaved(beta);
+ index.tenantRemoved("acme");
+ loadGate.countDown();
+ reload.get(10, TimeUnit.SECONDS);
+ } finally {
+ pool.shutdownNow();
+ }
+ assertEquals(2, loads.get());
+ assertEquals("beta",
index.current().get(hashService.hash("beta-key")));
+ assertNull(index.current().get(hashService.hash("plain-public")));
+ }
+
+ @Test
+ void keepsPreviousStateWhenLoadFails() {
+ index.load();
+ failure = new RuntimeException("store down");
+ now[0] += REFRESH;
+ index.current();
+ assertEquals(2, loads.get());
+ assertEquals("acme", index.find("plain-public", null,
hashService).getItemId());
+ }
+
+ private Tenant byId(String id) {
+ return tenants.stream().filter(t ->
id.equals(t.getItemId())).findFirst().orElse(null);
+ }
+
+ private Tenant tenantWithKey(String id, String plainTextKey,
ApiKey.ApiKeyType type) {
+ Tenant tenant = new Tenant();
+ tenant.setItemId(id);
+ ApiKey apiKey = new ApiKey();
+ apiKey.setKeyHash(hashService.hash(plainTextKey));
+ apiKey.setKeyType(type);
+ tenant.setApiKeys(new ArrayList<>(List.of(apiKey)));
+ return tenant;
+ }
+}
diff --git
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
index 2bdcb4a3f..528cf1d5c 100644
---
a/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
+++
b/services/src/test/java/org/apache/unomi/services/impl/tenants/TenantServiceImplTest.java
@@ -327,7 +327,7 @@ public class TenantServiceImplTest {
apiKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
tenantWithKeys.setApiKeys(new ArrayList<>(List.of(apiKey)));
-
when(persistenceService.getAllItems(Tenant.class)).thenReturn(List.of(tenantWithoutKeys,
tenantWithKeys));
+ storeTenants(tenantWithoutKeys, tenantWithKeys);
Tenant found = tenantService.getTenantByApiKey("valid-key");
assertEquals("tenant-with-keys", found.getItemId(), "Should find
tenant with matching API key");
@@ -347,7 +347,7 @@ public class TenantServiceImplTest {
apiKey.setKeyType(ApiKey.ApiKeyType.PRIVATE);
tenantWithKeys.setApiKeys(new ArrayList<>(List.of(apiKey)));
-
when(persistenceService.getAllItems(Tenant.class)).thenReturn(List.of(tenantWithoutKeys,
tenantWithKeys));
+ storeTenants(tenantWithoutKeys, tenantWithKeys);
Tenant found = tenantService.getTenantByApiKey("valid-key",
ApiKey.ApiKeyType.PRIVATE);
assertEquals("tenant-with-keys", found.getItemId(), "Should find
tenant with matching typed API key");
@@ -356,7 +356,7 @@ public class TenantServiceImplTest {
}
@Test
- public void getTenantByApiKeyRejectsWhenVerifyFails() {
+ public void getTenantByApiKeyRejectsUnknownKey() {
Tenant tenant = new Tenant();
tenant.setItemId("tenant-with-keys");
ApiKey apiKey = new ApiKey();
@@ -364,30 +364,145 @@ public class TenantServiceImplTest {
apiKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
tenant.setApiKeys(new ArrayList<>(List.of(apiKey)));
-
when(persistenceService.getAllItems(Tenant.class)).thenReturn(List.of(tenant));
- when(secretHashService.verify("wrong-key",
"stored-hash")).thenReturn(false);
+ storeTenants(tenant);
assertNull(tenantService.getTenantByApiKey("wrong-key"),
- "A key that fails verification should be rejected");
- verify(secretHashService).verify("wrong-key", "stored-hash");
+ "A key whose hash is not in the index should be rejected");
+ verify(secretHashService).hash("wrong-key");
+ verify(secretHashService, never()).verify(anyString(), anyString());
}
@Test
- public void getTenantByApiKeyAcceptsWhenVerifySucceeds() {
+ public void getTenantByApiKeyResolvesByHashWithoutScanningTenantsAgain() {
Tenant tenant = new Tenant();
tenant.setItemId("tenant-with-keys");
ApiKey apiKey = new ApiKey();
- apiKey.setKeyHash("stored-hash");
+ apiKey.setKeyHash("candidate-key");
apiKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
tenant.setApiKeys(new ArrayList<>(List.of(apiKey)));
-
when(persistenceService.getAllItems(Tenant.class)).thenReturn(List.of(tenant));
- when(secretHashService.verify("candidate-key",
"stored-hash")).thenReturn(true);
+ storeTenants(tenant);
Tenant found = tenantService.getTenantByApiKey("candidate-key");
assertEquals("tenant-with-keys", found.getItemId(),
- "A verified key should resolve to its tenant");
- verify(secretHashService).verify("candidate-key", "stored-hash");
+ "A hashed key should resolve to its tenant");
+ // Second lookup must use the in-memory index (UNOMI-1016).
+ assertEquals("tenant-with-keys",
tenantService.getTenantByApiKey("candidate-key").getItemId());
+ verify(persistenceService, times(1)).getAllItems(Tenant.class);
+ verify(secretHashService, never()).verify(anyString(), anyString());
+ }
+
+ @Test
+ public void getTenantByApiKeySeesNewlySavedKeyWithoutWaitingForRefresh() {
+
when(persistenceService.getAllItems(Tenant.class)).thenReturn(Collections.emptyList());
+ assertNull(tenantService.getTenantByApiKey("new-key"));
+
+ Tenant tenant = new Tenant();
+ tenant.setItemId("fresh-tenant");
+ ApiKey apiKey = new ApiKey();
+ apiKey.setKeyHash("new-key");
+ apiKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ tenant.setApiKeys(new ArrayList<>(List.of(apiKey)));
+ tenantService.saveTenant(tenant);
+ when(persistenceService.load("fresh-tenant",
Tenant.class)).thenReturn(tenant);
+
+ assertEquals("fresh-tenant",
tenantService.getTenantByApiKey("new-key").getItemId());
+ verify(persistenceService, times(1)).getAllItems(Tenant.class);
+ }
+
+ @Test
+ public void getTenantByApiKeyRefusesRevokedKey() {
+ Tenant tenant = new Tenant();
+ tenant.setItemId("tenant-with-keys");
+ ApiKey apiKey = new ApiKey();
+ apiKey.setKeyHash("revoked-key");
+ apiKey.setKeyType(ApiKey.ApiKeyType.PUBLIC);
+ apiKey.setRevoked(true);
+ tenant.setApiKeys(new ArrayList<>(List.of(apiKey)));
+
+ storeTenants(tenant);
+
+ assertNull(tenantService.getTenantByApiKey("revoked-key"),
+ "A revoked key must not authenticate");
+ }
+
+ @Test
+ public void getTenantByApiKeyRefusesKeyRevokedOnAnotherNodeBeforeRefresh()
{
+ Tenant tenant = tenantWithKey("shared-key");
+ storeTenants(tenant);
+ assertEquals("acme",
tenantService.getTenantByApiKey("shared-key").getItemId());
+
+ Tenant revokedElsewhere = tenantWithKey("shared-key");
+ revokedElsewhere.getApiKeys().get(0).setRevoked(true);
+ when(persistenceService.load("acme",
Tenant.class)).thenReturn(revokedElsewhere);
+
+ assertNull(tenantService.getTenantByApiKey("shared-key"));
+ verify(persistenceService, times(1)).getAllItems(Tenant.class);
+ }
+
+ @Test
+ public void getTenantByApiKeyRefusesKeyOfDeletedTenant() {
+ Tenant tenant = tenantWithKey("doomed-key");
+ storeTenants(tenant);
+ assertEquals("acme",
tenantService.getTenantByApiKey("doomed-key").getItemId());
+
+ tenantService.deleteTenant("acme");
+ when(persistenceService.load("acme", Tenant.class)).thenReturn(null);
+
+ assertNull(tenantService.getTenantByApiKey("doomed-key"));
+ }
+
+ @Test
+ public void getTenantByApiKeyFollowsKeyRotation() {
+ Tenant stored = tenantWithKey("old-hash");
+ storeTenants(stored);
+ assertEquals("acme",
tenantService.getTenantByApiKey("old-hash").getItemId());
+
+ // The mocked hash is the identity, so the new key's hash is also its
plaintext.
+ String newHash = tenantService.generateApiKeyWithType("acme",
ApiKey.ApiKeyType.PUBLIC, null)
+ .getApiKey().getKeyHash();
+
+ assertNull(tenantService.getTenantByApiKey("old-hash"), "The replaced
key must stop working");
+ assertEquals("acme",
tenantService.getTenantByApiKey(newHash).getItemId(),
+ "The new key must work without waiting for a refresh");
+ verify(persistenceService, times(1)).getAllItems(Tenant.class);
+ }
+
+ @Test
+ public void getTenantByApiKeyStillWorksAfterSettingsUpdate() {
+ Tenant stored = tenantWithKey("kept-key");
+ storeTenants(stored);
+ assertEquals("acme",
tenantService.getTenantByApiKey("kept-key").getItemId());
+ Tenant settings = new Tenant();
+ settings.setItemId("acme");
+
+ tenantService.updateTenantSettings(settings);
+
+ assertEquals("acme",
tenantService.getTenantByApiKey("kept-key").getItemId());
+ }
+
+ @Test
+ public void apiKeyMissRefreshIntervalIsConfigurableAndMustBePositive() {
+ storeTenants();
+ tenantService.setApiKeyMissRefreshInterval(1_000);
+ assertNull(tenantService.getTenantByApiKey("unknown"));
+ clock.addAndGet(TimeUnit.MILLISECONDS.toNanos(1_000));
+ assertNull(tenantService.getTenantByApiKey("unknown"));
+ verify(persistenceService, times(2)).getAllItems(Tenant.class);
+
+ // A non-positive value falls back to the default instead of reloading
on every unknown key.
+ tenantService.setApiKeyMissRefreshInterval(0);
+ assertNull(tenantService.getTenantByApiKey("unknown"));
+ assertNull(tenantService.getTenantByApiKey("unknown"));
+ verify(persistenceService, times(3)).getAllItems(Tenant.class);
+ }
+
+ /** Backs {@code getAllItems} and load-by-id with the given tenants. */
+ private void storeTenants(Tenant... tenants) {
+
when(persistenceService.getAllItems(Tenant.class)).thenReturn(List.of(tenants));
+ for (Tenant tenant : tenants) {
+ when(persistenceService.load(tenant.getItemId(),
Tenant.class)).thenReturn(tenant);
+ }
}
@Test