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"); + } +}