diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java new file mode 100644 index 00000000..d467204c --- /dev/null +++ b/sdk/src/main/java/io/opentdf/platform/sdk/KASAllowlistCache.java @@ -0,0 +1,58 @@ +package io.opentdf.platform.sdk; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +// Mirrors the KASKeyCache pattern: null returns, LocalDateTime.now(), and 5-minute TTL. +class KASAllowlistCache { + private static final Logger log = LoggerFactory.getLogger(KASAllowlistCache.class); + final Map cache = new ConcurrentHashMap<>(); + + public void clear() { + cache.clear(); + } + + public Set get(String platformURL) { + log.debug("retrieving allowlist for platformURL = [{}]", platformURL); + TimeStampedAllowList cachedValue = cache.get(platformURL); + if (cachedValue == null) { + log.debug("didn't find allowlist for platformURL = [{}]", platformURL); + return null; + } + + LocalDateTime fiveMinAgo = LocalDateTime.now().minus(5, ChronoUnit.MINUTES); + if (fiveMinAgo.isAfter(cachedValue.timestamp)) { + log.debug("cached allowlist is too old timestamp = [{}] for platformURL = [{}]", + cachedValue.timestamp, platformURL); + cache.remove(platformURL); + return null; + } + + log.debug("successfully returned allowlist for platformURL = [{}]", platformURL); + return new HashSet<>(cachedValue.allowlist); + } + + public void store(String platformURL, Set allowlist) { + log.debug("storing allowlist into the cache for platformURL = [{}]", platformURL); + cache.put(platformURL, new TimeStampedAllowList( + Collections.unmodifiableSet(new HashSet<>(allowlist)), LocalDateTime.now())); + } +} + +class TimeStampedAllowList { + Set allowlist; + LocalDateTime timestamp; + + public TimeStampedAllowList(Set allowlist, LocalDateTime timestamp) { + this.allowlist = allowlist; + this.timestamp = timestamp; + } +} diff --git a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java index 16c7a35a..da78b58b 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -18,6 +18,8 @@ import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse; import io.opentdf.platform.policy.attributes.ListAttributesRequest; import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClientInterface; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersRequest; +import io.opentdf.platform.policy.kasregistry.ListKeyAccessServersResponse; import io.opentdf.platform.policy.namespaces.NamespaceServiceClientInterface; import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceClientInterface; import io.opentdf.platform.policy.subjectmapping.SubjectMappingServiceClientInterface; @@ -30,9 +32,11 @@ import java.nio.channels.SeekableByteChannel; import java.util.ArrayList; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.stream.Collectors; /** @@ -57,6 +61,7 @@ public class SDK implements AutoCloseable { private final String platformUrl; private final ProtocolClient platformServicesClient; private final SrtSigner srtSigner; + private final KASAllowlistCache kasAllowlistCache = new KASAllowlistCache(); /** * Closes the SDK, including its associated services. @@ -136,10 +141,42 @@ public Optional getBaseKey() { } public TDF.Reader loadTDF(SeekableByteChannel channel, Config.TDFReaderConfig config) throws SDKException, IOException { + resolveKasAllowlist(config); var tdf = new TDF(services); return tdf.loadTDF(channel, config, platformUrl); } + private void resolveKasAllowlist(Config.TDFReaderConfig config) throws SDKException { + if (config.ignoreKasAllowlist + || (config.kasAllowlist != null && !config.kasAllowlist.isEmpty())) { + return; + } + + var cached = kasAllowlistCache.get(platformUrl); + if (cached != null) { + config.kasAllowlist = cached; + return; + } + + var request = ListKeyAccessServersRequest.newBuilder().build(); + ListKeyAccessServersResponse response; + try { + response = RequestHelper.getOrThrow( + services.kasRegistry().listKeyAccessServersBlocking(request, Collections.emptyMap()).execute()); + } catch (com.connectrpc.ConnectException e) { + throw new SDKException("error getting kas servers", e); + } + + var allowlist = new HashSet(); + for (var entry : response.getKeyAccessServersList()) { + allowlist.add(Config.getKasAddress(entry.getUri())); + } + allowlist.add(Config.getKasAddress(platformUrl)); + + config.kasAllowlist = allowlist; + kasAllowlistCache.store(platformUrl, allowlist); + } + public Manifest createTDF(InputStream payload, OutputStream outputStream, Config.TDFConfig config) throws SDKException, IOException { var tdf = new TDF(services); return tdf.createTDF(payload, outputStream, config).getManifest(); diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java new file mode 100644 index 00000000..730afdec --- /dev/null +++ b/sdk/src/test/java/io/opentdf/platform/sdk/KASAllowlistCacheTest.java @@ -0,0 +1,89 @@ +package io.opentdf.platform.sdk; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class KASAllowlistCacheTest { + + private KASAllowlistCache cache; + + @BeforeEach + void setUp() { + cache = new KASAllowlistCache(); + } + + @Test + void testStoreAndGet_WithinTimeLimit() { + Set allowlist = Set.of("https://kas1.example.org", "https://kas2.example.org"); + cache.store("https://platform.example.org", allowlist); + + Set result = cache.get("https://platform.example.org"); + + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains("https://kas1.example.org")); + assertTrue(result.contains("https://kas2.example.org")); + } + + @Test + void testStoreAndGet_AfterTimeLimit() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + TimeStampedAllowList expired = new TimeStampedAllowList(allowlist, LocalDateTime.now().minus(6, ChronoUnit.MINUTES)); + cache.cache.put("https://platform.example.org", expired); + + Set result = cache.get("https://platform.example.org"); + + assertNull(result); + } + + @Test + void testGet_EmptyCache() { + Set result = cache.get("https://platform.example.org"); + assertNull(result); + } + + @Test + void testGet_DifferentKey() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + Set result = cache.get("https://other.example.org"); + + assertNull(result); + } + + @Test + void testClearCache() { + Set allowlist = Set.of("https://kas.example.org"); + cache.store("https://platform.example.org", allowlist); + + cache.clear(); + + Set result = cache.get("https://platform.example.org"); + assertNull(result); + } + + @Test + void testStoreMultipleAndGet() { + Set allowlist1 = Set.of("https://kas1.example.org"); + Set allowlist2 = Set.of("https://kas2.example.org"); + cache.store("https://platform1.example.org", allowlist1); + cache.store("https://platform2.example.org", allowlist2); + + Set result1 = cache.get("https://platform1.example.org"); + Set result2 = cache.get("https://platform2.example.org"); + + assertNotNull(result1); + assertTrue(result1.contains("https://kas1.example.org")); + assertNotNull(result2); + assertTrue(result2.contains("https://kas2.example.org")); + } +}