Skip to content

Commit 8de6068

Browse files
marythoughtclaude
andauthored
feat(sdk): DSPX-2418 add discovery convenience methods (#339)
## Summary - Add five discovery methods directly on the `SDK` class: `listAttributes()`, `validateAttributes()`, `attributeExists()`, `attributeValueExists()`, `getEntityAttributes()` - Add `SDK.AttributeNotFoundException extends SDKException`, thrown when FQNs or attribute values are not found on the platform - Add 26 unit tests in `DiscoveryTest.java` covering all methods and edge cases - docs update: opentdf/docs#186 ## Rationale Addresses GitHub discussion developer feedback. Developers currently work with verbose Connect RPC request/response types through `sdk.getServices().attributes()`. These methods provide ergonomic wrappers that: - Auto-paginate `listAttributes()` with a 1,000-page safety cap to prevent unbounded memory growth from a misbehaving server - Validate FQN format and existence before `createTDF()` so failures happen at encryption time with a clear message rather than at decryption time with a cryptic "resource not found" - Match entity ID in the `GetEntitlements` response to prevent silent wrong-entity attribute returns - Cap `validateAttributes()` at 250 FQNs client-side to match the server limit Matches the API shape and security properties of the Go SDK implementation: opentdf/platform#3082. ## Test plan - [ ] `mvn -pl sdk test -Dtest=DiscoveryTest` — all 26 tests pass - [ ] `mvn -pl sdk test` — no regressions in existing test suite --------- Signed-off-by: Mary Dickson <mary.dickson@virtru.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0f224a6 commit 8de6068

2 files changed

Lines changed: 588 additions & 1 deletion

File tree

sdk/src/main/java/io/opentdf/platform/sdk/SDK.java

Lines changed: 204 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
package io.opentdf.platform.sdk;
22

33
import com.connectrpc.Interceptor;
4-
4+
import com.connectrpc.ResponseMessageKt;
55
import com.connectrpc.impl.ProtocolClient;
66
import io.opentdf.platform.authorization.AuthorizationServiceClientInterface;
7+
import io.opentdf.platform.authorization.Entity;
8+
import io.opentdf.platform.authorization.EntityEntitlements;
9+
import io.opentdf.platform.authorization.GetEntitlementsRequest;
10+
import io.opentdf.platform.authorization.GetEntitlementsResponse;
11+
import io.opentdf.platform.policy.Attribute;
12+
import io.opentdf.platform.policy.PageRequest;
713
import io.opentdf.platform.policy.SimpleKasKey;
14+
import io.opentdf.platform.policy.Value;
815
import io.opentdf.platform.policy.attributes.AttributesServiceClientInterface;
16+
import io.opentdf.platform.policy.attributes.GetAttributeRequest;
17+
import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsRequest;
18+
import io.opentdf.platform.policy.attributes.GetAttributeValuesByFqnsResponse;
19+
import io.opentdf.platform.policy.attributes.ListAttributesRequest;
920
import io.opentdf.platform.policy.kasregistry.KeyAccessServerRegistryServiceClientInterface;
1021
import io.opentdf.platform.policy.namespaces.NamespaceServiceClientInterface;
1122
import io.opentdf.platform.policy.resourcemapping.ResourceMappingServiceClientInterface;
@@ -17,7 +28,12 @@
1728
import java.io.InputStream;
1829
import java.io.OutputStream;
1930
import java.nio.channels.SeekableByteChannel;
31+
import java.util.ArrayList;
32+
import java.util.Collections;
33+
import java.util.List;
34+
import java.util.Map;
2035
import java.util.Optional;
36+
import java.util.stream.Collectors;
2137

2238
/**
2339
* The SDK class represents a software development kit for interacting with the
@@ -26,6 +42,15 @@
2642
* platform.
2743
*/
2844
public class SDK implements AutoCloseable {
45+
46+
// Caps the pagination loop in listAttributes to prevent unbounded memory growth
47+
// if a server repeatedly returns a non-zero next_offset.
48+
private static final int MAX_LIST_ATTRIBUTES_PAGES = 1000;
49+
50+
// Matches the server-side limit on GetAttributeValuesByFqns so callers get a
51+
// clear local error instead of a cryptic server rejection.
52+
private static final int MAX_VALIDATE_FQNS = 250;
53+
2954
private final Services services;
3055
private final TrustManager trustManager;
3156
private final Interceptor authInterceptor;
@@ -179,6 +204,172 @@ public String getPlatformUrl() {
179204
return platformUrl;
180205
}
181206

207+
/**
208+
* Lists all active attributes available on the platform, auto-paginating through all results.
209+
* An optional namespace name or ID may be provided to filter results.
210+
*
211+
* <p>Use this before calling {@code createTDF()} to see what attributes are available for data tagging.
212+
*
213+
* @param namespace optional namespace name or ID to filter results
214+
* @return list of all active {@link Attribute} objects
215+
* @throws SDKException if a service error occurs or pagination exceeds the maximum page limit
216+
*/
217+
public List<Attribute> listAttributes(String... namespace) {
218+
ListAttributesRequest.Builder reqBuilder = ListAttributesRequest.newBuilder();
219+
if (namespace.length > 0 && namespace[0] != null) {
220+
reqBuilder.setNamespace(namespace[0]);
221+
}
222+
List<Attribute> result = new ArrayList<>();
223+
for (int pages = 0; pages < MAX_LIST_ATTRIBUTES_PAGES; pages++) {
224+
var resp = ResponseMessageKt.getOrThrow(
225+
services.attributes()
226+
.listAttributesBlocking(reqBuilder.build(), Collections.emptyMap())
227+
.execute());
228+
result.addAll(resp.getAttributesList());
229+
int nextOffset = resp.getPagination().getNextOffset();
230+
if (nextOffset == 0) {
231+
return result;
232+
}
233+
reqBuilder.setPagination(PageRequest.newBuilder().setOffset(nextOffset).build());
234+
}
235+
throw new SDKException("listing attributes: exceeded maximum page limit (" + MAX_LIST_ATTRIBUTES_PAGES + ")");
236+
}
237+
238+
/**
239+
* Checks that all provided attribute value FQNs exist on the platform.
240+
* Validates FQN format first, then verifies existence via the platform API.
241+
*
242+
* <p>Use this before {@code createTDF()} to catch missing or misspelled attributes early
243+
* instead of discovering the problem at decryption time.
244+
*
245+
* @param fqns list of attribute value FQNs in the form
246+
* {@code https://<namespace>/attr/<name>/value/<value>}
247+
* @throws AttributeNotFoundException if any FQNs are not found on the platform
248+
* @throws SDKException if input validation fails or a service error occurs
249+
*/
250+
public void validateAttributes(List<String> fqns) {
251+
if (fqns == null || fqns.isEmpty()) {
252+
return;
253+
}
254+
if (fqns.size() > MAX_VALIDATE_FQNS) {
255+
throw new SDKException("too many attribute FQNs: " + fqns.size()
256+
+ " exceeds maximum of " + MAX_VALIDATE_FQNS);
257+
}
258+
for (String fqn : fqns) {
259+
try {
260+
new Autoconfigure.AttributeValueFQN(fqn);
261+
} catch (AutoConfigureException e) {
262+
throw new SDKException("invalid attribute value FQN \"" + fqn + "\": " + e.getMessage(), e);
263+
}
264+
}
265+
GetAttributeValuesByFqnsResponse resp = ResponseMessageKt.getOrThrow(
266+
services.attributes()
267+
.getAttributeValuesByFqnsBlocking(
268+
GetAttributeValuesByFqnsRequest.newBuilder().addAllFqns(fqns).build(),
269+
Collections.emptyMap())
270+
.execute());
271+
Map<String, GetAttributeValuesByFqnsResponse.AttributeAndValue> found = resp.getFqnAttributeValuesMap();
272+
List<String> missing = fqns.stream()
273+
.filter(f -> !found.containsKey(f))
274+
.collect(Collectors.toList());
275+
if (!missing.isEmpty()) {
276+
throw new AttributeNotFoundException("attribute not found: " + String.join(", ", missing));
277+
}
278+
}
279+
280+
/**
281+
* Returns the attribute value FQNs assigned to an entity (person or non-person entity).
282+
*
283+
* <p>Use this to inspect what attributes a user, service account, or other entity has been
284+
* granted before making authorization decisions or constructing access policies.
285+
*
286+
* @param entity the entity to look up; must not be null
287+
* @return list of attribute value FQNs assigned to the entity, or an empty list if none
288+
* @throws SDKException if entity is null or a service error occurs
289+
*/
290+
public List<String> getEntityAttributes(Entity entity) {
291+
if (entity == null) {
292+
throw new SDKException("entity must not be null");
293+
}
294+
GetEntitlementsResponse resp = ResponseMessageKt.getOrThrow(
295+
services.authorization()
296+
.getEntitlementsBlocking(
297+
GetEntitlementsRequest.newBuilder().addEntities(entity).build(),
298+
Collections.emptyMap())
299+
.execute());
300+
String entityId = entity.getId();
301+
for (EntityEntitlements e : resp.getEntitlementsList()) {
302+
if (e.getEntityId().equals(entityId)) {
303+
return e.getAttributeValueFqnsList();
304+
}
305+
}
306+
return Collections.emptyList();
307+
}
308+
309+
/**
310+
* Reports whether the attribute definition identified by {@code attributeFqn} exists on the
311+
* platform.
312+
*
313+
* <p>{@code attributeFqn} should be an attribute-level FQN (no {@code /value/} segment):
314+
* <pre>{@code https://<namespace>/attr/<attribute_name>}</pre>
315+
*
316+
* @param attributeFqn the attribute-level FQN to check
317+
* @return {@code true} if the attribute exists, {@code false} if it does not
318+
* @throws SDKException if the FQN format is invalid or a non-not-found service error occurs
319+
*/
320+
public boolean attributeExists(String attributeFqn) {
321+
try {
322+
new Autoconfigure.AttributeNameFQN(attributeFqn);
323+
} catch (AutoConfigureException e) {
324+
throw new SDKException("invalid attribute FQN \"" + attributeFqn + "\": " + e.getMessage(), e);
325+
}
326+
try {
327+
ResponseMessageKt.getOrThrow(
328+
services.attributes()
329+
.getAttributeBlocking(
330+
GetAttributeRequest.newBuilder().setFqn(attributeFqn).build(),
331+
Collections.emptyMap())
332+
.execute());
333+
return true;
334+
} catch (Exception e) {
335+
String msg = e.getMessage();
336+
if (msg != null && msg.contains("not_found")) {
337+
return false;
338+
}
339+
throw new SDKException("checking attribute existence: " + msg, e);
340+
}
341+
}
342+
343+
/**
344+
* Reports whether the attribute value FQN exists on the platform.
345+
*
346+
* <p>{@code valueFqn} should be a full attribute value FQN (with {@code /value/} segment):
347+
* <pre>{@code https://<namespace>/attr/<attribute_name>/value/<value>}</pre>
348+
*
349+
* @param valueFqn the attribute value FQN to check
350+
* @return {@code true} if the value exists, {@code false} if it does not
351+
* @throws SDKException if the FQN format is invalid or a service error occurs
352+
*/
353+
public boolean attributeValueExists(String valueFqn) {
354+
try {
355+
new Autoconfigure.AttributeValueFQN(valueFqn);
356+
} catch (AutoConfigureException e) {
357+
throw new SDKException("invalid attribute value FQN \"" + valueFqn + "\": " + e.getMessage(), e);
358+
}
359+
GetAttributeValuesByFqnsResponse resp;
360+
try {
361+
resp = ResponseMessageKt.getOrThrow(
362+
services.attributes()
363+
.getAttributeValuesByFqnsBlocking(
364+
GetAttributeValuesByFqnsRequest.newBuilder().addFqns(valueFqn).build(),
365+
Collections.emptyMap())
366+
.execute());
367+
} catch (Exception e) {
368+
throw new SDKException("checking attribute value existence: " + e.getMessage(), e);
369+
}
370+
return resp.getFqnAttributeValuesMap().containsKey(valueFqn);
371+
}
372+
182373
/**
183374
* Indicates that the TDF is malformed in some way
184375
*/
@@ -284,4 +475,16 @@ public AssertionException(String errorMessage, String id) {
284475
super("assertion id: "+ id + "; " + errorMessage);
285476
}
286477
}
478+
479+
/**
480+
* {@link AttributeNotFoundException} is thrown by {@link #validateAttributes(List)},
481+
* {@link #validateAttributeExists(String)}, and
482+
* {@link #validateAttributeValue(String, String)} when one or more attributes or values
483+
* are not found on the platform.
484+
*/
485+
public static class AttributeNotFoundException extends SDKException {
486+
public AttributeNotFoundException(String errorMessage) {
487+
super(errorMessage);
488+
}
489+
}
287490
}

0 commit comments

Comments
 (0)