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 9e80b0ab..99c87634 100644
--- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
+++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java
@@ -1,11 +1,20 @@
package io.opentdf.platform.sdk;
import com.connectrpc.Interceptor;
-
+import com.connectrpc.ResponseMessageKt;
import com.connectrpc.impl.ProtocolClient;
import io.opentdf.platform.authorization.AuthorizationServiceClientInterface;
+import io.opentdf.platform.authorization.Entity;
+import io.opentdf.platform.authorization.EntityEntitlements;
+import io.opentdf.platform.authorization.GetEntitlementsRequest;
+import io.opentdf.platform.authorization.GetEntitlementsResponse;
+import io.opentdf.platform.policy.Attribute;
+import io.opentdf.platform.policy.PageRequest;
import io.opentdf.platform.policy.SimpleKasKey;
import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
+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.namespaces.NamespaceServiceClientInterface;
import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceClientInterface;
@@ -17,7 +26,12 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.channels.SeekableByteChannel;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
import java.util.Optional;
+import java.util.stream.Collectors;
/**
* The SDK class represents a software development kit for interacting with the
@@ -26,6 +40,15 @@
* platform.
*/
public class SDK implements AutoCloseable {
+
+ // Caps the pagination loop in listAttributes to prevent unbounded memory growth
+ // if a server repeatedly returns a non-zero next_offset.
+ private static final int MAX_LIST_ATTRIBUTES_PAGES = 1000;
+
+ // Matches the server-side limit on GetAttributeValuesByFqns so callers get a
+ // clear local error instead of a cryptic server rejection.
+ private static final int MAX_VALIDATE_FQNS = 250;
+
private final Services services;
private final TrustManager trustManager;
private final Interceptor authInterceptor;
@@ -179,6 +202,121 @@ public String getPlatformUrl() {
return platformUrl;
}
+ /**
+ * Lists all active attributes available on the platform, auto-paginating through all results.
+ * An optional namespace name or ID may be provided to filter results.
+ *
+ *
Use this before calling {@code createTDF()} to see what attributes are available for data tagging.
+ *
+ * @param namespace optional namespace name or ID to filter results
+ * @return list of all active {@link Attribute} objects
+ * @throws SDKException if a service error occurs or pagination exceeds the maximum page limit
+ */
+ public List listAttributes(String... namespace) {
+ ListAttributesRequest.Builder reqBuilder = ListAttributesRequest.newBuilder();
+ if (namespace.length > 0 && namespace[0] != null) {
+ reqBuilder.setNamespace(namespace[0]);
+ }
+ List result = new ArrayList<>();
+ for (int pages = 0; pages < MAX_LIST_ATTRIBUTES_PAGES; pages++) {
+ var resp = ResponseMessageKt.getOrThrow(
+ services.attributes()
+ .listAttributesBlocking(reqBuilder.build(), Collections.emptyMap())
+ .execute());
+ result.addAll(resp.getAttributesList());
+ int nextOffset = resp.getPagination().getNextOffset();
+ if (nextOffset == 0) {
+ return result;
+ }
+ reqBuilder.setPagination(PageRequest.newBuilder().setOffset(nextOffset).build());
+ }
+ throw new SDKException("listing attributes: exceeded maximum page limit (" + MAX_LIST_ATTRIBUTES_PAGES + ")");
+ }
+
+ /**
+ * Checks that all provided attribute value FQNs exist on the platform.
+ * Validates FQN format first, then verifies existence via the platform API.
+ *
+ * Use this before {@code createTDF()} to catch missing or misspelled attributes early
+ * instead of discovering the problem at decryption time.
+ *
+ * @param fqns list of attribute value FQNs in the form
+ * {@code https:///attr//value/}
+ * @throws AttributeNotFoundException if any FQNs are not found on the platform
+ * @throws SDKException if input validation fails or a service error occurs
+ */
+ public void validateAttributes(List fqns) {
+ if (fqns == null || fqns.isEmpty()) {
+ return;
+ }
+ if (fqns.size() > MAX_VALIDATE_FQNS) {
+ throw new SDKException("too many attribute FQNs: " + fqns.size()
+ + " exceeds maximum of " + MAX_VALIDATE_FQNS);
+ }
+ for (String fqn : fqns) {
+ try {
+ new Autoconfigure.AttributeValueFQN(fqn);
+ } catch (AutoConfigureException e) {
+ throw new SDKException("invalid attribute value FQN \"" + fqn + "\": " + e.getMessage(), e);
+ }
+ }
+ GetAttributeValuesByFqnsResponse resp = ResponseMessageKt.getOrThrow(
+ services.attributes()
+ .getAttributeValuesByFqnsBlocking(
+ GetAttributeValuesByFqnsRequest.newBuilder().addAllFqns(fqns).build(),
+ Collections.emptyMap())
+ .execute());
+ Map found = resp.getFqnAttributeValuesMap();
+ List missing = fqns.stream()
+ .filter(f -> !found.containsKey(f))
+ .collect(Collectors.toList());
+ if (!missing.isEmpty()) {
+ throw new AttributeNotFoundException("attribute not found: " + String.join(", ", missing));
+ }
+ }
+
+ /**
+ * Returns the attribute value FQNs assigned to an entity (person or non-person entity).
+ *
+ * Use this to inspect what attributes a user, service account, or other entity has been
+ * granted before making authorization decisions or constructing access policies.
+ *
+ * @param entity the entity to look up; must not be null
+ * @return list of attribute value FQNs assigned to the entity, or an empty list if none
+ * @throws SDKException if entity is null or a service error occurs
+ */
+ public List getEntityAttributes(Entity entity) {
+ if (entity == null) {
+ throw new SDKException("entity must not be null");
+ }
+ GetEntitlementsResponse resp = ResponseMessageKt.getOrThrow(
+ services.authorization()
+ .getEntitlementsBlocking(
+ GetEntitlementsRequest.newBuilder().addEntities(entity).build(),
+ Collections.emptyMap())
+ .execute());
+ String entityId = entity.getId();
+ for (EntityEntitlements e : resp.getEntitlementsList()) {
+ if (entityId.isEmpty() || e.getEntityId().equals(entityId)) {
+ return e.getAttributeValueFqnsList();
+ }
+ }
+ return Collections.emptyList();
+ }
+
+ /**
+ * Checks that a single attribute value FQN is valid in format and exists on the platform.
+ *
+ * This is a convenience wrapper around {@link #validateAttributes(List)} for the single-FQN case.
+ *
+ * @param fqn the attribute value FQN to validate
+ * @throws AttributeNotFoundException if the FQN does not exist on the platform
+ * @throws SDKException if the FQN format is invalid or a service error occurs
+ */
+ public void validateAttributeValue(String fqn) {
+ validateAttributes(Collections.singletonList(fqn));
+ }
+
/**
* Indicates that the TDF is malformed in some way
*/
@@ -284,4 +422,15 @@ public AssertionException(String errorMessage, String id) {
super("assertion id: "+ id + "; " + errorMessage);
}
}
+
+ /**
+ * {@link AttributeNotFoundException} is thrown by {@link #validateAttributes(List)} and
+ * {@link #validateAttributeValue(String)} when one or more attribute FQNs are not found
+ * on the platform.
+ */
+ public static class AttributeNotFoundException extends SDKException {
+ public AttributeNotFoundException(String errorMessage) {
+ super(errorMessage);
+ }
+ }
}
diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java
new file mode 100644
index 00000000..805a7bc8
--- /dev/null
+++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java
@@ -0,0 +1,310 @@
+package io.opentdf.platform.sdk;
+
+import io.opentdf.platform.authorization.Entity;
+import io.opentdf.platform.authorization.EntityEntitlements;
+import io.opentdf.platform.authorization.GetEntitlementsResponse;
+import io.opentdf.platform.authorization.AuthorizationServiceClientInterface;
+import io.opentdf.platform.policy.Attribute;
+import io.opentdf.platform.policy.PageResponse;
+import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface;
+import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
+import io.opentdf.platform.policy.attributes.ListAttributesRequest;
+import io.opentdf.platform.policy.attributes.ListAttributesResponse;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class DiscoveryTest {
+
+ // Helper: build a minimal SDK wired with mock services.
+ private SDK sdkWith(AttributesServiceClientInterface attrSvc,
+ AuthorizationServiceClientInterface authzSvc) {
+ var services = new FakeServicesBuilder()
+ .setAttributesService(attrSvc)
+ .setAuthorizationService(authzSvc)
+ .build();
+ return new SDK(services, null, null, null, null, null);
+ }
+
+ // Helper: build a ListAttributesResponse with optional next_offset.
+ private ListAttributesResponse listResponse(List attrs, int nextOffset) {
+ var builder = ListAttributesResponse.newBuilder().addAllAttributes(attrs);
+ if (nextOffset != 0) {
+ builder.setPagination(PageResponse.newBuilder().setNextOffset(nextOffset).build());
+ }
+ return builder.build();
+ }
+
+ // Helper: build a GetAttributeValuesByFqns response containing exactly the given FQNs.
+ private GetAttributeValuesByFqnsResponse fqnResponse(String... presentFqns) {
+ var builder = GetAttributeValuesByFqnsResponse.newBuilder();
+ for (String fqn : presentFqns) {
+ builder.putFqnAttributeValues(fqn,
+ GetAttributeValuesByFqnsResponse.AttributeAndValue.getDefaultInstance());
+ }
+ return builder.build();
+ }
+
+ // Helper: build a minimal Attribute proto with a given FQN.
+ private Attribute attr(String fqn) {
+ return Attribute.newBuilder().setFqn(fqn).build();
+ }
+
+ // --- listAttributes ---
+
+ @Test
+ void listAttributes_emptyResult() {
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.listAttributesBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(listResponse(Collections.emptyList(), 0)));
+
+ var sdk = sdkWith(attrSvc, null);
+ List result = sdk.listAttributes();
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void listAttributes_singlePage() {
+ var expected = List.of(
+ attr("https://example.com/attr/level/value/high"),
+ attr("https://example.com/attr/level/value/low"));
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.listAttributesBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(listResponse(expected, 0)));
+
+ var sdk = sdkWith(attrSvc, null);
+ assertThat(sdk.listAttributes()).containsExactlyElementsOf(expected);
+ }
+
+ @Test
+ void listAttributes_multiPage() {
+ var page1 = List.of(attr("https://example.com/attr/a/value/1"));
+ var page2 = List.of(attr("https://example.com/attr/b/value/2"));
+
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ var callCount = new AtomicInteger(0);
+ when(attrSvc.listAttributesBlocking(any(), any())).thenAnswer(invocation -> {
+ int call = callCount.getAndIncrement();
+ if (call == 0) {
+ return TestUtil.successfulUnaryCall(listResponse(page1, 1));
+ }
+ return TestUtil.successfulUnaryCall(listResponse(page2, 0));
+ });
+
+ var sdk = sdkWith(attrSvc, null);
+ var result = sdk.listAttributes();
+ assertThat(callCount.get()).as("should have paginated twice").isEqualTo(2);
+ var expected = new ArrayList<>(page1);
+ expected.addAll(page2);
+ assertThat(result).containsExactlyElementsOf(expected);
+ }
+
+ @Test
+ void listAttributes_namespaceFilter() {
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ var capturedReq = new ListAttributesRequest[1];
+ when(attrSvc.listAttributesBlocking(any(), any())).thenAnswer(invocation -> {
+ capturedReq[0] = (ListAttributesRequest) invocation.getArgument(0);
+ return TestUtil.successfulUnaryCall(listResponse(Collections.emptyList(), 0));
+ });
+
+ var sdk = sdkWith(attrSvc, null);
+ sdk.listAttributes("my-namespace");
+ assertThat(capturedReq[0].getNamespace()).isEqualTo("my-namespace");
+ }
+
+ @Test
+ void listAttributes_pageLimitExceeded() {
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ // Always return a non-zero next_offset to simulate a runaway server.
+ when(attrSvc.listAttributesBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(
+ listResponse(List.of(attr("https://example.com/attr/a/value/1")), 1)));
+
+ var sdk = sdkWith(attrSvc, null);
+ assertThatThrownBy(sdk::listAttributes)
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("exceeded maximum page limit");
+ }
+
+ // --- validateAttributes ---
+
+ @Test
+ void validateAttributes_nullInput_noOp() {
+ var sdk = sdkWith(null, null);
+ // Should not throw and must not call any service.
+ sdk.validateAttributes(null);
+ }
+
+ @Test
+ void validateAttributes_emptyInput_noOp() {
+ var sdk = sdkWith(null, null);
+ sdk.validateAttributes(Collections.emptyList());
+ }
+
+ @Test
+ void validateAttributes_allFound() {
+ var fqns = List.of(
+ "https://example.com/attr/level/value/high",
+ "https://example.com/attr/type/value/secret");
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(fqnResponse(fqns.toArray(new String[0]))));
+
+ var sdk = sdkWith(attrSvc, null);
+ // Should complete without exception.
+ sdk.validateAttributes(fqns);
+ }
+
+ @Test
+ void validateAttributes_someMissing() {
+ var fqns = List.of(
+ "https://example.com/attr/level/value/high",
+ "https://example.com/attr/type/value/missing");
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ // Only return the first FQN.
+ when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(fqnResponse(fqns.get(0))));
+
+ var sdk = sdkWith(attrSvc, null);
+ assertThatThrownBy(() -> sdk.validateAttributes(fqns))
+ .isInstanceOf(SDK.AttributeNotFoundException.class)
+ .hasMessageContaining("https://example.com/attr/type/value/missing");
+ }
+
+ @Test
+ void validateAttributes_allMissing() {
+ var fqns = List.of(
+ "https://example.com/attr/a/value/x",
+ "https://example.com/attr/b/value/y");
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(fqnResponse()));
+
+ var sdk = sdkWith(attrSvc, null);
+ assertThatThrownBy(() -> sdk.validateAttributes(fqns))
+ .isInstanceOf(SDK.AttributeNotFoundException.class);
+ }
+
+ @Test
+ void validateAttributes_tooManyFqns() {
+ var fqns = new ArrayList();
+ for (int i = 0; i <= 250; i++) {
+ fqns.add("https://example.com/attr/level/value/v" + i);
+ }
+ var sdk = sdkWith(null, null);
+ assertThatThrownBy(() -> sdk.validateAttributes(fqns))
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("too many attribute FQNs");
+ }
+
+ @Test
+ void validateAttributes_invalidFqnFormat() {
+ var sdk = sdkWith(null, null);
+ assertThatThrownBy(() -> sdk.validateAttributes(List.of("not-a-valid-fqn")))
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("invalid attribute value FQN");
+ }
+
+ // --- getEntityAttributes ---
+
+ @Test
+ void getEntityAttributes_nullEntity() {
+ var sdk = sdkWith(null, null);
+ assertThatThrownBy(() -> sdk.getEntityAttributes(null))
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("entity must not be null");
+ }
+
+ @Test
+ void getEntityAttributes_found() {
+ var expectedFqns = List.of(
+ "https://example.com/attr/clearance/value/secret",
+ "https://example.com/attr/country/value/us");
+ var authzSvc = mock(AuthorizationServiceClientInterface.class);
+ when(authzSvc.getEntitlementsBlocking(any(), any())).thenReturn(
+ TestUtil.successfulUnaryCall(GetEntitlementsResponse.newBuilder()
+ .addEntitlements(EntityEntitlements.newBuilder()
+ .setEntityId("e1")
+ .addAllAttributeValueFqns(expectedFqns)
+ .build())
+ .build()));
+
+ var sdk = sdkWith(null, authzSvc);
+ var entity = Entity.newBuilder().setId("e1").setEmailAddress("alice@example.com").build();
+ assertThat(sdk.getEntityAttributes(entity)).containsExactlyElementsOf(expectedFqns);
+ }
+
+ @Test
+ void getEntityAttributes_noEntitlements() {
+ var authzSvc = mock(AuthorizationServiceClientInterface.class);
+ when(authzSvc.getEntitlementsBlocking(any(), any())).thenReturn(
+ TestUtil.successfulUnaryCall(GetEntitlementsResponse.newBuilder().build()));
+
+ var sdk = sdkWith(null, authzSvc);
+ var entity = Entity.newBuilder().setId("e1").setClientId("my-service").build();
+ assertThat(sdk.getEntityAttributes(entity)).isEmpty();
+ }
+
+ @Test
+ void getEntityAttributes_idMismatch() {
+ // Server returns entitlements for a different entity ID than requested.
+ var authzSvc = mock(AuthorizationServiceClientInterface.class);
+ when(authzSvc.getEntitlementsBlocking(any(), any())).thenReturn(
+ TestUtil.successfulUnaryCall(GetEntitlementsResponse.newBuilder()
+ .addEntitlements(EntityEntitlements.newBuilder()
+ .setEntityId("other-entity")
+ .addAttributeValueFqns("https://example.com/attr/a/value/x")
+ .build())
+ .build()));
+
+ var sdk = sdkWith(null, authzSvc);
+ var entity = Entity.newBuilder().setId("e1").setEmailAddress("alice@example.com").build();
+ assertThat(sdk.getEntityAttributes(entity))
+ .as("should return empty when no entitlement matches the requested entity ID")
+ .isEmpty();
+ }
+
+ // --- validateAttributeValue ---
+
+ @Test
+ void validateAttributeValue_validAndExists() {
+ var fqn = "https://example.com/attr/level/value/high";
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(fqnResponse(fqn)));
+
+ var sdk = sdkWith(attrSvc, null);
+ // Should complete without exception.
+ sdk.validateAttributeValue(fqn);
+ }
+
+ @Test
+ void validateAttributeValue_validButMissing() {
+ var fqn = "https://example.com/attr/level/value/nonexistent";
+ var attrSvc = mock(AttributesServiceClientInterface.class);
+ when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any()))
+ .thenReturn(TestUtil.successfulUnaryCall(fqnResponse()));
+
+ var sdk = sdkWith(attrSvc, null);
+ assertThatThrownBy(() -> sdk.validateAttributeValue(fqn))
+ .isInstanceOf(SDK.AttributeNotFoundException.class);
+ }
+
+ @Test
+ void validateAttributeValue_invalidFormat() {
+ var sdk = sdkWith(null, null);
+ assertThatThrownBy(() -> sdk.validateAttributeValue("bad-fqn-format"))
+ .isInstanceOf(SDKException.class)
+ .hasMessageContaining("invalid attribute value FQN");
+ }
+}