From c0165177bd065d5ea02b093ce30a876bcb9512a2 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 17 Feb 2026 13:54:13 -0800 Subject: [PATCH 1/9] feat(sdk): DSPX-2418 add attribute discovery methods --- .../java/io/opentdf/platform/sdk/SDK.java | 165 +++++++++- .../opentdf/platform/sdk/DiscoveryTest.java | 310 ++++++++++++++++++ 2 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java 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..91745db1 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,21 @@ package io.opentdf.platform.sdk; +import com.connectrpc.ConnectException; 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 +27,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 @@ -179,6 +194,143 @@ public String getPlatformUrl() { return platformUrl; } + // 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; + + /** + * 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++) { + try { + 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()); + } catch (ConnectException e) { + throw new SDKException("listing attributes: " + e.getMessage(), e); + } + } + 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; + try { + resp = ResponseMessageKt.getOrThrow( + services.attributes() + .getAttributeValuesByFqnsBlocking( + GetAttributeValuesByFqnsRequest.newBuilder().addAllFqns(fqns).build(), + Collections.emptyMap()) + .execute()); + } catch (ConnectException e) { + throw new SDKException("validating attributes: " + e.getMessage(), e); + } + Map found = resp.getFqnAttributeValuesMap(); + List missing = fqns.stream() + .filter(fqn -> !found.containsKey(fqn)) + .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; + try { + resp = ResponseMessageKt.getOrThrow( + services.authorization() + .getEntitlementsBlocking( + GetEntitlementsRequest.newBuilder().addEntities(entity).build(), + Collections.emptyMap()) + .execute()); + } catch (ConnectException e) { + throw new SDKException("getting entity attributes: " + e.getMessage(), e); + } + 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 +436,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"); + } +} From c079a2ca0d60b1b8ac811b7fa0e05fef790ad5ab Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 17 Feb 2026 14:13:15 -0800 Subject: [PATCH 2/9] style(sdk): move constants to class top; rename lambda parameter Move MAX_LIST_ATTRIBUTES_PAGES and MAX_VALIDATE_FQNS to the top of the class body (before instance fields), matching standard Java placement for private static final constants. Rename the lambda parameter in the validateAttributes stream filter from 'fqn' to 'f' to avoid the same name being used for both the enhanced-for loop variable and the lambda parameter in the same method. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Mary Dickson --- .../java/io/opentdf/platform/sdk/SDK.java | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) 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 91745db1..fda2bdfb 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -41,6 +41,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; @@ -194,14 +203,6 @@ public String getPlatformUrl() { return platformUrl; } - // 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; - /** * 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. @@ -277,7 +278,7 @@ public void validateAttributes(List fqns) { } Map found = resp.getFqnAttributeValuesMap(); List missing = fqns.stream() - .filter(fqn -> !found.containsKey(fqn)) + .filter(f -> !found.containsKey(f)) .collect(Collectors.toList()); if (!missing.isEmpty()) { throw new AttributeNotFoundException("attribute not found: " + String.join(", ", missing)); From 375bb9815a9814edd772c80e98ba180f6e966ca0 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 17 Feb 2026 14:29:59 -0800 Subject: [PATCH 3/9] fix(sdk): remove unreachable ConnectException catch blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocking service client methods do not declare `throws ConnectException` in their Java signatures, so catching it is a compilation error. Remove the three try-catch wrappers in listAttributes, validateAttributes, and getEntityAttributes, letting exceptions propagate naturally — consistent with the existing pattern in Autoconfigure.java. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Mary Dickson --- .../java/io/opentdf/platform/sdk/SDK.java | 57 +++++++------------ 1 file changed, 21 insertions(+), 36 deletions(-) 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 fda2bdfb..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,6 +1,5 @@ package io.opentdf.platform.sdk; -import com.connectrpc.ConnectException; import com.connectrpc.Interceptor; import com.connectrpc.ResponseMessageKt; import com.connectrpc.impl.ProtocolClient; @@ -220,20 +219,16 @@ public List listAttributes(String... namespace) { } List result = new ArrayList<>(); for (int pages = 0; pages < MAX_LIST_ATTRIBUTES_PAGES; pages++) { - try { - 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()); - } catch (ConnectException e) { - throw new SDKException("listing attributes: " + e.getMessage(), e); + 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 + ")"); } @@ -265,17 +260,12 @@ public void validateAttributes(List fqns) { throw new SDKException("invalid attribute value FQN \"" + fqn + "\": " + e.getMessage(), e); } } - GetAttributeValuesByFqnsResponse resp; - try { - resp = ResponseMessageKt.getOrThrow( - services.attributes() - .getAttributeValuesByFqnsBlocking( - GetAttributeValuesByFqnsRequest.newBuilder().addAllFqns(fqns).build(), - Collections.emptyMap()) - .execute()); - } catch (ConnectException e) { - throw new SDKException("validating attributes: " + 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)) @@ -299,17 +289,12 @@ public List getEntityAttributes(Entity entity) { if (entity == null) { throw new SDKException("entity must not be null"); } - GetEntitlementsResponse resp; - try { - resp = ResponseMessageKt.getOrThrow( - services.authorization() - .getEntitlementsBlocking( - GetEntitlementsRequest.newBuilder().addEntities(entity).build(), - Collections.emptyMap()) - .execute()); - } catch (ConnectException e) { - throw new SDKException("getting entity attributes: " + e.getMessage(), e); - } + 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)) { From 3b204e9805e8a67f8e4b3d0f2daa90c721cf45fb Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 17 Feb 2026 16:37:05 -0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(sdk):=20rename=20validateAttributeValu?= =?UTF-8?q?e=20=E2=86=92=20validateAttributeExists,=20add=20new=20validate?= =?UTF-8?q?AttributeValue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old validateAttributeValue(fqn) only checked if a complete value FQN was pre-registered on the platform. This gave false negatives for dynamic (non-enumerated) attributes where any string value is valid. - Rename validateAttributeValue(String fqn) → validateAttributeExists(String fqn). - Add validateAttributeValue(String attributeFqn, String value) which fetches the attribute definition via getAttributeBlocking: - Enumerated attribute (values non-empty): value must match case-insensitively. - Dynamic attribute (values empty): any string is accepted. - Fix entity ID security bug in getEntityAttributes: remove the entityId.isEmpty() short-circuit that would return the first entitlement for any entity with no ID. - Add imports for GetAttributeRequest, GetAttributeResponse, Value. - Add 6 new tests covering enumerated match, case-insensitivity, not-found, dynamic pass-through, attribute-not-found, and invalid FQN. - Rename existing 3 validateAttributeValue tests to validateAttributeExists. Co-Authored-By: Claude Sonnet 4.6 --- .../java/io/opentdf/platform/sdk/SDK.java | 65 ++++++++++- .../opentdf/platform/sdk/DiscoveryTest.java | 101 ++++++++++++++++-- 2 files changed, 154 insertions(+), 12 deletions(-) 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 99c87634..ccef4f55 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -11,7 +11,9 @@ import io.opentdf.platform.policy.Attribute; import io.opentdf.platform.policy.PageRequest; import io.opentdf.platform.policy.SimpleKasKey; +import io.opentdf.platform.policy.Value; import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface; +import io.opentdf.platform.policy.attributes.GetAttributeRequest; import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest; import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse; import io.opentdf.platform.policy.attributes.ListAttributesRequest; @@ -297,7 +299,7 @@ public List getEntityAttributes(Entity entity) { .execute()); String entityId = entity.getId(); for (EntityEntitlements e : resp.getEntitlementsList()) { - if (entityId.isEmpty() || e.getEntityId().equals(entityId)) { + if (e.getEntityId().equals(entityId)) { return e.getAttributeValueFqnsList(); } } @@ -313,10 +315,62 @@ public List getEntityAttributes(Entity entity) { * @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) { + public void validateAttributeExists(String fqn) { validateAttributes(Collections.singletonList(fqn)); } + /** + * Checks that {@code value} is a permitted value for the attribute identified by + * {@code attributeFqn}. Handles both enumerated and dynamic attribute types: + *

+ * + * @param attributeFqn the attribute-level FQN, e.g. + * {@code https://example.com/attr/clearance} + * @param value the candidate value string, e.g. {@code secret} + * @throws AttributeNotFoundException if the attribute does not exist on the platform, or if + * the attribute is enumerated and {@code value} is not in + * the allowed set + * @throws SDKException if the FQN format is invalid or a service error occurs + */ + public void validateAttributeValue(String attributeFqn, String value) { + try { + new Autoconfigure.AttributeNameFQN(attributeFqn); + } catch (AutoConfigureException e) { + throw new SDKException("invalid attribute FQN \"" + attributeFqn + "\": " + e.getMessage(), e); + } + + Attribute attribute; + try { + attribute = ResponseMessageKt.getOrThrow( + services.attributes() + .getAttributeBlocking( + GetAttributeRequest.newBuilder().setFqn(attributeFqn).build(), + Collections.emptyMap()) + .execute()) + .getAttribute(); + } catch (Exception e) { + throw new AttributeNotFoundException("attribute not found: " + attributeFqn); + } + + List vals = attribute.getValuesList(); + if (vals.isEmpty()) { + // Dynamic attribute — any value is permitted. + return; + } + + for (Value v : vals) { + if (v.getValue().equalsIgnoreCase(value)) { + return; + } + } + throw new AttributeNotFoundException( + "attribute not found: value \"" + value + "\" not permitted for attribute " + attributeFqn); + } + /** * Indicates that the TDF is malformed in some way */ @@ -424,9 +478,10 @@ public AssertionException(String errorMessage, String id) { } /** - * {@link AttributeNotFoundException} is thrown by {@link #validateAttributes(List)} and - * {@link #validateAttributeValue(String)} when one or more attribute FQNs are not found - * on the platform. + * {@link AttributeNotFoundException} is thrown by {@link #validateAttributes(List)}, + * {@link #validateAttributeExists(String)}, and + * {@link #validateAttributeValue(String, String)} when one or more attributes or values + * are not found on the platform. */ public static class AttributeNotFoundException extends SDKException { public AttributeNotFoundException(String 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 index 805a7bc8..e119e67d 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java @@ -7,6 +7,9 @@ import io.opentdf.platform.policy.Attribute; import io.opentdf.platform.policy.PageResponse; import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface; +import io.opentdf.platform.policy.Value; +import io.opentdf.platform.policy.attributes.GetAttributeRequest; +import io.opentdf.platform.policy.attributes.GetAttributeResponse; import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse; import io.opentdf.platform.policy.attributes.ListAttributesRequest; import io.opentdf.platform.policy.attributes.ListAttributesResponse; @@ -274,10 +277,10 @@ void getEntityAttributes_idMismatch() { .isEmpty(); } - // --- validateAttributeValue --- + // --- validateAttributeExists --- @Test - void validateAttributeValue_validAndExists() { + void validateAttributeExists_validAndExists() { var fqn = "https://example.com/attr/level/value/high"; var attrSvc = mock(AttributesServiceClientInterface.class); when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) @@ -285,26 +288,110 @@ void validateAttributeValue_validAndExists() { var sdk = sdkWith(attrSvc, null); // Should complete without exception. - sdk.validateAttributeValue(fqn); + sdk.validateAttributeExists(fqn); } @Test - void validateAttributeValue_validButMissing() { + void validateAttributeExists_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)) + assertThatThrownBy(() -> sdk.validateAttributeExists(fqn)) .isInstanceOf(SDK.AttributeNotFoundException.class); } @Test - void validateAttributeValue_invalidFormat() { + void validateAttributeExists_invalidFormat() { var sdk = sdkWith(null, null); - assertThatThrownBy(() -> sdk.validateAttributeValue("bad-fqn-format")) + assertThatThrownBy(() -> sdk.validateAttributeExists("bad-fqn-format")) .isInstanceOf(SDKException.class) .hasMessageContaining("invalid attribute value FQN"); } + + // --- validateAttributeValue --- + + // Helper: build a GetAttributeResponse with the given enumerated values. + private GetAttributeResponse attrResponse(String... values) { + var attrBuilder = Attribute.newBuilder(); + for (String v : values) { + attrBuilder.addValues(Value.newBuilder().setValue(v).build()); + } + return GetAttributeResponse.newBuilder().setAttribute(attrBuilder.build()).build(); + } + + @Test + void validateAttributeValue_enumeratedMatch() { + var attrFqn = "https://example.com/attr/clearance"; + var attrSvc = mock(AttributesServiceClientInterface.class); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(attrResponse("low", "secret", "top-secret"))); + + var sdk = sdkWith(attrSvc, null); + // "secret" is in the list — should not throw. + sdk.validateAttributeValue(attrFqn, "secret"); + } + + @Test + void validateAttributeValue_enumeratedCaseInsensitive() { + var attrFqn = "https://example.com/attr/clearance"; + var attrSvc = mock(AttributesServiceClientInterface.class); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(attrResponse("Secret"))); + + var sdk = sdkWith(attrSvc, null); + sdk.validateAttributeValue(attrFqn, "SECRET"); + } + + @Test + void validateAttributeValue_enumeratedNotFound() { + var attrFqn = "https://example.com/attr/clearance"; + var attrSvc = mock(AttributesServiceClientInterface.class); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(attrResponse("low", "secret"))); + + var sdk = sdkWith(attrSvc, null); + assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "top-secret")) + .isInstanceOf(SDK.AttributeNotFoundException.class) + .hasMessageContaining("top-secret"); + } + + @Test + void validateAttributeValue_dynamic() { + // Dynamic attribute: no pre-registered values — any string is valid. + var attrFqn = "https://example.com/attr/tag"; + var attrSvc = mock(AttributesServiceClientInterface.class); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(attrResponse())); // no values + + var sdk = sdkWith(attrSvc, null); + sdk.validateAttributeValue(attrFqn, "anything-goes"); + } + + @Test + void validateAttributeValue_attributeNotFound() { + var attrFqn = "https://example.com/attr/nonexistent"; + var attrSvc = mock(AttributesServiceClientInterface.class); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenThrow(new RuntimeException("not_found: attribute does not exist")); + + var sdk = sdkWith(attrSvc, null); + assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "somevalue")) + .isInstanceOf(SDK.AttributeNotFoundException.class); + } + + @Test + void validateAttributeValue_invalidFqn() { + // Passing a value FQN (contains /value/) should be rejected as an invalid attribute FQN. + var sdk = sdkWith(null, null); + assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/level/value/high", "high")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("invalid attribute FQN"); + + assertThatThrownBy(() -> sdk.validateAttributeValue("not-a-fqn", "somevalue")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("invalid attribute FQN"); + } } From 7d726801638dd3835d822577c994341a4dc22621 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Tue, 17 Feb 2026 16:51:12 -0800 Subject: [PATCH 5/9] feat(sdk): add empty value guard to validateAttributeValue Co-Authored-By: Claude Sonnet 4.6 --- sdk/src/main/java/io/opentdf/platform/sdk/SDK.java | 3 +++ .../java/io/opentdf/platform/sdk/DiscoveryTest.java | 12 ++++++++++++ 2 files changed, 15 insertions(+) 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 ccef4f55..a40d9615 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -337,6 +337,9 @@ public void validateAttributeExists(String fqn) { * @throws SDKException if the FQN format is invalid or a service error occurs */ public void validateAttributeValue(String attributeFqn, String value) { + if (value == null || value.isEmpty()) { + throw new SDKException("attribute value must not be empty"); + } try { new Autoconfigure.AttributeNameFQN(attributeFqn); } catch (AutoConfigureException e) { diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java index e119e67d..f65afdad 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java @@ -382,6 +382,18 @@ void validateAttributeValue_attributeNotFound() { .isInstanceOf(SDK.AttributeNotFoundException.class); } + @Test + void validateAttributeValue_emptyValue() { + var sdk = sdkWith(null, null); + assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/clearance", "")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("must not be empty"); + + assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/clearance", null)) + .isInstanceOf(SDKException.class) + .hasMessageContaining("must not be empty"); + } + @Test void validateAttributeValue_invalidFqn() { // Passing a value FQN (contains /value/) should be rejected as an invalid attribute FQN. From 62fe1b0b33dc67d00697ba77f7d13b99948474f5 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 19 Feb 2026 09:28:16 -0800 Subject: [PATCH 6/9] fix(sdk): remove false-pass behavior in ValidateAttributeValue An attribute with no registered values now throws AttributeNotFoundException instead of silently succeeding. Also aligns error messages with Go and TypeScript SDKs ("not found" instead of "not permitted"). Co-authored-by: Claude Sonnet 4.6 --- .../java/io/opentdf/platform/sdk/SDK.java | 23 +++++++------------ .../opentdf/platform/sdk/DiscoveryTest.java | 7 +++--- 2 files changed, 12 insertions(+), 18 deletions(-) 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 a40d9615..e59e3157 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -320,20 +320,18 @@ public void validateAttributeExists(String fqn) { } /** - * Checks that {@code value} is a permitted value for the attribute identified by - * {@code attributeFqn}. Handles both enumerated and dynamic attribute types: - *
    - *
  • Enumerated attributes: {@code value} must match one of the pre-registered values - * (case-insensitive).
  • - *
  • Dynamic attributes (no pre-registered values): any non-empty value is accepted.
  • - *
+ * Checks that {@code value} is registered on the attribute identified by + * {@code attributeFqn}. The value must match one of the attribute's registered values + * (case-insensitive), or the call fails. + *

+ * The attribute rule type ({@code ANY_OF}, {@code ALL_OF}, {@code HIERARCHY}) is not relevant + * here — it governs access decisions at decryption time, not value registration. * * @param attributeFqn the attribute-level FQN, e.g. * {@code https://example.com/attr/clearance} * @param value the candidate value string, e.g. {@code secret} * @throws AttributeNotFoundException if the attribute does not exist on the platform, or if - * the attribute is enumerated and {@code value} is not in - * the allowed set + * {@code value} is not among its registered values * @throws SDKException if the FQN format is invalid or a service error occurs */ public void validateAttributeValue(String attributeFqn, String value) { @@ -360,18 +358,13 @@ public void validateAttributeValue(String attributeFqn, String value) { } List vals = attribute.getValuesList(); - if (vals.isEmpty()) { - // Dynamic attribute — any value is permitted. - return; - } - for (Value v : vals) { if (v.getValue().equalsIgnoreCase(value)) { return; } } throw new AttributeNotFoundException( - "attribute not found: value \"" + value + "\" not permitted for attribute " + attributeFqn); + "attribute not found: value \"" + value + "\" not found for attribute " + attributeFqn); } /** diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java index f65afdad..fe38ae50 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java @@ -359,15 +359,16 @@ void validateAttributeValue_enumeratedNotFound() { } @Test - void validateAttributeValue_dynamic() { - // Dynamic attribute: no pre-registered values — any string is valid. + void validateAttributeValue_noRegisteredValues() { + // Attribute with no registered values — value cannot be found, so the call must fail. var attrFqn = "https://example.com/attr/tag"; var attrSvc = mock(AttributesServiceClientInterface.class); when(attrSvc.getAttributeBlocking(any(), any())) .thenReturn(TestUtil.successfulUnaryCall(attrResponse())); // no values var sdk = sdkWith(attrSvc, null); - sdk.validateAttributeValue(attrFqn, "anything-goes"); + assertThrows(SDK.AttributeNotFoundException.class, + () -> sdk.validateAttributeValue(attrFqn, "anything-goes")); } @Test From 0b832ad54fe1f2b918061ce24ca6158e2c1d3d66 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 19 Feb 2026 09:40:17 -0800 Subject: [PATCH 7/9] fix(sdk): use assertThatThrownBy instead of assertThrows in DiscoveryTest assertThrows is JUnit 5; this test class uses AssertJ (assertThatThrownBy). Co-authored-by: Claude Sonnet 4.6 --- sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java index fe38ae50..7c21d97b 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java @@ -367,8 +367,8 @@ void validateAttributeValue_noRegisteredValues() { .thenReturn(TestUtil.successfulUnaryCall(attrResponse())); // no values var sdk = sdkWith(attrSvc, null); - assertThrows(SDK.AttributeNotFoundException.class, - () -> sdk.validateAttributeValue(attrFqn, "anything-goes")); + assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "anything-goes")) + .isInstanceOf(SDK.AttributeNotFoundException.class); } @Test From 0d2dad044524ce4ba79dcb1a86c7ddbb45d4e90a Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 19 Feb 2026 11:00:39 -0800 Subject: [PATCH 8/9] refactor(discovery): replace validateAttributeExists/validateAttributeValue with attributeExists/attributeValueExists Both methods now return boolean instead of throwing on not-found, aligning with idiomatic Java conventions for existence checks. attributeExists uses a "not_found" message check to distinguish not-found from service errors; attributeValueExists checks the FQN map from getAttributeValuesByFqns. Co-Authored-By: Claude Sonnet 4.6 --- .../java/io/opentdf/platform/sdk/SDK.java | 80 +++++------ .../opentdf/platform/sdk/DiscoveryTest.java | 136 +++++++----------- 2 files changed, 94 insertions(+), 122 deletions(-) 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 e59e3157..b1e8391a 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -307,64 +307,62 @@ public List getEntityAttributes(Entity entity) { } /** - * Checks that a single attribute value FQN is valid in format and exists on the platform. + * Reports whether the attribute definition identified by {@code attributeFqn} exists on the + * platform. * - *

This is a convenience wrapper around {@link #validateAttributes(List)} for the single-FQN case. + *

{@code attributeFqn} should be an attribute-level FQN (no {@code /value/} segment): + *

{@code https:///attr/}
* - * @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 validateAttributeExists(String fqn) { - validateAttributes(Collections.singletonList(fqn)); - } - - /** - * Checks that {@code value} is registered on the attribute identified by - * {@code attributeFqn}. The value must match one of the attribute's registered values - * (case-insensitive), or the call fails. - *

- * The attribute rule type ({@code ANY_OF}, {@code ALL_OF}, {@code HIERARCHY}) is not relevant - * here — it governs access decisions at decryption time, not value registration. - * - * @param attributeFqn the attribute-level FQN, e.g. - * {@code https://example.com/attr/clearance} - * @param value the candidate value string, e.g. {@code secret} - * @throws AttributeNotFoundException if the attribute does not exist on the platform, or if - * {@code value} is not among its registered values - * @throws SDKException if the FQN format is invalid or a service error occurs + * @param attributeFqn the attribute-level FQN to check + * @return {@code true} if the attribute exists, {@code false} if it does not + * @throws SDKException if the FQN format is invalid or a non-not-found service error occurs */ - public void validateAttributeValue(String attributeFqn, String value) { - if (value == null || value.isEmpty()) { - throw new SDKException("attribute value must not be empty"); - } + public boolean attributeExists(String attributeFqn) { try { new Autoconfigure.AttributeNameFQN(attributeFqn); } catch (AutoConfigureException e) { throw new SDKException("invalid attribute FQN \"" + attributeFqn + "\": " + e.getMessage(), e); } - - Attribute attribute; try { - attribute = ResponseMessageKt.getOrThrow( + ResponseMessageKt.getOrThrow( services.attributes() .getAttributeBlocking( GetAttributeRequest.newBuilder().setFqn(attributeFqn).build(), Collections.emptyMap()) - .execute()) - .getAttribute(); + .execute()); + return true; } catch (Exception e) { - throw new AttributeNotFoundException("attribute not found: " + attributeFqn); + String msg = e.getMessage(); + if (msg != null && msg.contains("not_found")) { + return false; + } + throw new SDKException("checking attribute existence: " + msg, e); } + } - List vals = attribute.getValuesList(); - for (Value v : vals) { - if (v.getValue().equalsIgnoreCase(value)) { - return; - } + /** + * Reports whether the attribute value FQN exists on the platform. + * + *

{@code valueFqn} should be a full attribute value FQN (with {@code /value/} segment): + *

{@code https:///attr//value/}
+ * + * @param valueFqn the attribute value FQN to check + * @return {@code true} if the value exists, {@code false} if it does not + * @throws SDKException if the FQN format is invalid or a service error occurs + */ + public boolean attributeValueExists(String valueFqn) { + try { + new Autoconfigure.AttributeValueFQN(valueFqn); + } catch (AutoConfigureException e) { + throw new SDKException("invalid attribute value FQN \"" + valueFqn + "\": " + e.getMessage(), e); } - throw new AttributeNotFoundException( - "attribute not found: value \"" + value + "\" not found for attribute " + attributeFqn); + GetAttributeValuesByFqnsResponse resp = ResponseMessageKt.getOrThrow( + services.attributes() + .getAttributeValuesByFqnsBlocking( + GetAttributeValuesByFqnsRequest.newBuilder().addFqns(valueFqn).build(), + Collections.emptyMap()) + .execute()); + return resp.getFqnAttributeValuesMap().containsKey(valueFqn); } /** diff --git a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java index 7c21d97b..20dcf7a4 100644 --- a/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java +++ b/sdk/src/test/java/io/opentdf/platform/sdk/DiscoveryTest.java @@ -7,8 +7,6 @@ import io.opentdf.platform.policy.Attribute; import io.opentdf.platform.policy.PageResponse; import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface; -import io.opentdf.platform.policy.Value; -import io.opentdf.platform.policy.attributes.GetAttributeRequest; import io.opentdf.platform.policy.attributes.GetAttributeResponse; import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse; import io.opentdf.platform.policy.attributes.ListAttributesRequest; @@ -277,134 +275,110 @@ void getEntityAttributes_idMismatch() { .isEmpty(); } - // --- validateAttributeExists --- + // --- attributeExists --- @Test - void validateAttributeExists_validAndExists() { - var fqn = "https://example.com/attr/level/value/high"; + void attributeExists_found() { + var attrFqn = "https://example.com/attr/clearance"; var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(fqnResponse(fqn))); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall( + GetAttributeResponse.newBuilder() + .setAttribute(Attribute.newBuilder().setFqn(attrFqn).build()) + .build())); var sdk = sdkWith(attrSvc, null); - // Should complete without exception. - sdk.validateAttributeExists(fqn); + assertThat(sdk.attributeExists(attrFqn)).isTrue(); } @Test - void validateAttributeExists_validButMissing() { - var fqn = "https://example.com/attr/level/value/nonexistent"; + void attributeExists_notFound() { var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(fqnResponse())); + when(attrSvc.getAttributeBlocking(any(), any())) + .thenThrow(new RuntimeException("not_found: attribute does not exist")); var sdk = sdkWith(attrSvc, null); - assertThatThrownBy(() -> sdk.validateAttributeExists(fqn)) - .isInstanceOf(SDK.AttributeNotFoundException.class); + assertThat(sdk.attributeExists("https://example.com/attr/missing")).isFalse(); } @Test - void validateAttributeExists_invalidFormat() { - var sdk = sdkWith(null, null); - assertThatThrownBy(() -> sdk.validateAttributeExists("bad-fqn-format")) - .isInstanceOf(SDKException.class) - .hasMessageContaining("invalid attribute value FQN"); - } - - // --- validateAttributeValue --- - - // Helper: build a GetAttributeResponse with the given enumerated values. - private GetAttributeResponse attrResponse(String... values) { - var attrBuilder = Attribute.newBuilder(); - for (String v : values) { - attrBuilder.addValues(Value.newBuilder().setValue(v).build()); - } - return GetAttributeResponse.newBuilder().setAttribute(attrBuilder.build()).build(); - } - - @Test - void validateAttributeValue_enumeratedMatch() { - var attrFqn = "https://example.com/attr/clearance"; + void attributeExists_serviceError() { var attrSvc = mock(AttributesServiceClientInterface.class); when(attrSvc.getAttributeBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(attrResponse("low", "secret", "top-secret"))); + .thenThrow(new RuntimeException("unavailable: connection refused")); var sdk = sdkWith(attrSvc, null); - // "secret" is in the list — should not throw. - sdk.validateAttributeValue(attrFqn, "secret"); + assertThatThrownBy(() -> sdk.attributeExists("https://example.com/attr/clearance")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("checking attribute existence"); } @Test - void validateAttributeValue_enumeratedCaseInsensitive() { - var attrFqn = "https://example.com/attr/clearance"; - var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(attrResponse("Secret"))); + void attributeExists_invalidFqn() { + var sdk = sdkWith(null, null); + assertThatThrownBy(() -> sdk.attributeExists("not-a-valid-fqn")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("invalid attribute FQN"); + } - var sdk = sdkWith(attrSvc, null); - sdk.validateAttributeValue(attrFqn, "SECRET"); + @Test + void attributeExists_rejectsValueFqn() { + // Passing a value FQN (contains /value/) should be rejected as an invalid attribute FQN. + var sdk = sdkWith(null, null); + assertThatThrownBy(() -> sdk.attributeExists("https://example.com/attr/clearance/value/secret")) + .isInstanceOf(SDKException.class) + .hasMessageContaining("invalid attribute FQN"); } + // --- attributeValueExists --- + @Test - void validateAttributeValue_enumeratedNotFound() { - var attrFqn = "https://example.com/attr/clearance"; + void attributeValueExists_found() { + var valueFqn = "https://example.com/attr/clearance/value/secret"; var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(attrResponse("low", "secret"))); + when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(fqnResponse(valueFqn))); var sdk = sdkWith(attrSvc, null); - assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "top-secret")) - .isInstanceOf(SDK.AttributeNotFoundException.class) - .hasMessageContaining("top-secret"); + assertThat(sdk.attributeValueExists(valueFqn)).isTrue(); } @Test - void validateAttributeValue_noRegisteredValues() { - // Attribute with no registered values — value cannot be found, so the call must fail. - var attrFqn = "https://example.com/attr/tag"; + void attributeValueExists_notFound() { + var valueFqn = "https://example.com/attr/clearance/value/nonexistent"; var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeBlocking(any(), any())) - .thenReturn(TestUtil.successfulUnaryCall(attrResponse())); // no values + when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) + .thenReturn(TestUtil.successfulUnaryCall(fqnResponse())); var sdk = sdkWith(attrSvc, null); - assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "anything-goes")) - .isInstanceOf(SDK.AttributeNotFoundException.class); + assertThat(sdk.attributeValueExists(valueFqn)).isFalse(); } @Test - void validateAttributeValue_attributeNotFound() { - var attrFqn = "https://example.com/attr/nonexistent"; + void attributeValueExists_serviceError() { var attrSvc = mock(AttributesServiceClientInterface.class); - when(attrSvc.getAttributeBlocking(any(), any())) - .thenThrow(new RuntimeException("not_found: attribute does not exist")); + when(attrSvc.getAttributeValuesByFqnsBlocking(any(), any())) + .thenThrow(new RuntimeException("unavailable: connection refused")); var sdk = sdkWith(attrSvc, null); - assertThatThrownBy(() -> sdk.validateAttributeValue(attrFqn, "somevalue")) - .isInstanceOf(SDK.AttributeNotFoundException.class); + assertThatThrownBy(() -> sdk.attributeValueExists("https://example.com/attr/clearance/value/secret")) + .isInstanceOf(SDKException.class); } @Test - void validateAttributeValue_emptyValue() { + void attributeValueExists_invalidFqn() { var sdk = sdkWith(null, null); - assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/clearance", "")) - .isInstanceOf(SDKException.class) - .hasMessageContaining("must not be empty"); - - assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/clearance", null)) + assertThatThrownBy(() -> sdk.attributeValueExists("not-a-valid-fqn")) .isInstanceOf(SDKException.class) - .hasMessageContaining("must not be empty"); + .hasMessageContaining("invalid attribute value FQN"); } @Test - void validateAttributeValue_invalidFqn() { - // Passing a value FQN (contains /value/) should be rejected as an invalid attribute FQN. + void attributeValueExists_rejectsAttributeFqn() { + // Passing an attribute-level FQN (no /value/) should be rejected. var sdk = sdkWith(null, null); - assertThatThrownBy(() -> sdk.validateAttributeValue("https://example.com/attr/level/value/high", "high")) + assertThatThrownBy(() -> sdk.attributeValueExists("https://example.com/attr/clearance")) .isInstanceOf(SDKException.class) - .hasMessageContaining("invalid attribute FQN"); - - assertThatThrownBy(() -> sdk.validateAttributeValue("not-a-fqn", "somevalue")) - .isInstanceOf(SDKException.class) - .hasMessageContaining("invalid attribute FQN"); + .hasMessageContaining("invalid attribute value FQN"); } } From d757fab7085e53746d282d6b22a777c556d86740 Mon Sep 17 00:00:00 2001 From: Mary Dickson Date: Thu, 19 Feb 2026 11:10:55 -0800 Subject: [PATCH 9/9] fix(discovery): wrap attributeValueExists service errors in SDKException The getAttributeValuesByFqnsBlocking call was missing a try-catch, so service errors propagated as raw RuntimeException instead of SDKException. Co-Authored-By: Claude Sonnet 4.6 --- .../main/java/io/opentdf/platform/sdk/SDK.java | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 b1e8391a..16c7a35a 100644 --- a/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java +++ b/sdk/src/main/java/io/opentdf/platform/sdk/SDK.java @@ -356,12 +356,17 @@ public boolean attributeValueExists(String valueFqn) { } catch (AutoConfigureException e) { throw new SDKException("invalid attribute value FQN \"" + valueFqn + "\": " + e.getMessage(), e); } - GetAttributeValuesByFqnsResponse resp = ResponseMessageKt.getOrThrow( - services.attributes() - .getAttributeValuesByFqnsBlocking( - GetAttributeValuesByFqnsRequest.newBuilder().addFqns(valueFqn).build(), - Collections.emptyMap()) - .execute()); + GetAttributeValuesByFqnsResponse resp; + try { + resp = ResponseMessageKt.getOrThrow( + services.attributes() + .getAttributeValuesByFqnsBlocking( + GetAttributeValuesByFqnsRequest.newBuilder().addFqns(valueFqn).build(), + Collections.emptyMap()) + .execute()); + } catch (Exception e) { + throw new SDKException("checking attribute value existence: " + e.getMessage(), e); + } return resp.getFqnAttributeValuesMap().containsKey(valueFqn); }