From 039d292f678445b29912e59bf83d0a0718db1964 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Fri, 7 Jul 2023 14:30:41 -0500 Subject: [PATCH 1/4] JCL-394: AccessRequest builder --- .../client/accessgrant/AccessGrantClient.java | 44 +++++-- .../client/accessgrant/AccessRequest.java | 122 ++++++++++++++++++ 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java index 664a63b8ad2..1a178422bc5 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrantClient.java @@ -107,6 +107,7 @@ public class AccessGrantClient { private static final String PROVIDED_CONSENT = "providedConsent"; private static final String FOR_PURPOSE = "forPurpose"; private static final String EXPIRATION_DATE = "expirationDate"; + private static final String ISSUANCE_DATE = "issuanceDate"; private static final String CREDENTIAL = "credential"; private static final String SOLID_ACCESS_GRANT = "SolidAccessGrant"; private static final String SOLID_ACCESS_REQUEST = "SolidAccessRequest"; @@ -180,6 +181,17 @@ public AccessGrantClient session(final Session session) { return new AccessGrantClient(client.session(session), metadataCache, config); } + /** + * Issue an access request. + * + * @param request the parameters for the access request + * @return the next stage of completion containing the resulting access request + */ + public CompletionStage requestAccess(final AccessRequest.RequestParameters request) { + return requestAccess(request.getRecipient(), request.getResources(), + request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt()); + } + /** * Issue an access request. * @@ -192,10 +204,16 @@ public AccessGrantClient session(final Session session) { */ public CompletionStage requestAccess(final URI recipient, final Set resources, final Set modes, final Set purposes, final Instant expiration) { + return requestAccess(recipient, resources, modes, purposes, expiration, null); + } + + private CompletionStage requestAccess(final URI recipient, final Set resources, + final Set modes, final Set purposes, final Instant expiration, final Instant issuance) { Objects.requireNonNull(resources, "Resources may not be null!"); Objects.requireNonNull(modes, "Access modes may not be null!"); return v1Metadata().thenCompose(metadata -> { - final Map data = buildAccessRequestv1(recipient, resources, modes, expiration, purposes); + final Map data = buildAccessRequestv1(recipient, resources, modes, purposes, expiration, + issuance); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) @@ -228,7 +246,7 @@ public CompletionStage grantAccess(final AccessRequest request) { Objects.requireNonNull(request, "Request may not be null!"); return v1Metadata().thenCompose(metadata -> { final Map data = buildAccessGrantv1(request.getCreator(), request.getResources(), - request.getModes(), request.getExpiration(), request.getPurposes()); + request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt()); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) .POST(Request.BodyPublishers.ofByteArray(serialize(data))).build(); @@ -260,7 +278,7 @@ public CompletionStage denyAccess(final AccessRequest request) { Objects.requireNonNull(request, "Request may not be null!"); return v1Metadata().thenCompose(metadata -> { final Map data = buildAccessDenialv1(request.getCreator(), request.getResources(), - request.getModes(), request.getExpiration(), request.getPurposes()); + request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt()); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) .POST(Request.BodyPublishers.ofByteArray(serialize(data))).build(); @@ -311,9 +329,9 @@ public CompletionStage issue(final URI type, final URI recipient, f return v1Metadata().thenCompose(metadata -> { final Map data; if (FQ_ACCESS_GRANT.equals(type)) { - data = buildAccessGrantv1(recipient, resources, modes, expiration, uriPurposes); + data = buildAccessGrantv1(recipient, resources, modes, uriPurposes, expiration, null); } else if (FQ_ACCESS_REQUEST.equals(type)) { - data = buildAccessRequestv1(recipient, resources, modes, expiration, uriPurposes); + data = buildAccessRequestv1(recipient, resources, modes, uriPurposes, expiration, null); } else { throw new AccessGrantException("Unsupported grant type: " + type); } @@ -809,7 +827,7 @@ static URI asUri(final Object value) { } static Map buildAccessDenialv1(final URI agent, final Set resources, final Set modes, - final Instant expiration, final Set purposes) { + final Set purposes, final Instant expiration, final Instant issuance) { Objects.requireNonNull(agent, "Access denial agent may not be null!"); final Map consent = new HashMap<>(); consent.put(MODE, modes); @@ -828,6 +846,9 @@ static Map buildAccessDenialv1(final URI agent, final Set r if (expiration != null) { credential.put(EXPIRATION_DATE, expiration.truncatedTo(ChronoUnit.SECONDS).toString()); } + if (issuance != null) { + credential.put(ISSUANCE_DATE, issuance.truncatedTo(ChronoUnit.SECONDS).toString()); + } credential.put(CREDENTIAL_SUBJECT, subject); final Map data = new HashMap<>(); @@ -836,7 +857,7 @@ static Map buildAccessDenialv1(final URI agent, final Set r } static Map buildAccessGrantv1(final URI agent, final Set resources, final Set modes, - final Instant expiration, final Set purposes) { + final Set purposes, final Instant expiration, final Instant issuance) { Objects.requireNonNull(agent, "Access grant agent may not be null!"); final Map consent = new HashMap<>(); consent.put(MODE, modes); @@ -855,6 +876,9 @@ static Map buildAccessGrantv1(final URI agent, final Set re if (expiration != null) { credential.put(EXPIRATION_DATE, expiration.truncatedTo(ChronoUnit.SECONDS).toString()); } + if (issuance != null) { + credential.put(ISSUANCE_DATE, issuance.truncatedTo(ChronoUnit.SECONDS).toString()); + } credential.put(CREDENTIAL_SUBJECT, subject); final Map data = new HashMap<>(); @@ -863,7 +887,7 @@ static Map buildAccessGrantv1(final URI agent, final Set re } static Map buildAccessRequestv1(final URI agent, final Set resources, final Set modes, - final Instant expiration, final Set purposes) { + final Set purposes, final Instant expiration, final Instant issuance) { final Map consent = new HashMap<>(); consent.put(HAS_STATUS, "https://w3id.org/GConsent#ConsentStatusRequested"); consent.put(MODE, modes); @@ -883,6 +907,10 @@ static Map buildAccessRequestv1(final URI agent, final Set if (expiration != null) { credential.put(EXPIRATION_DATE, expiration.truncatedTo(ChronoUnit.SECONDS).toString()); } + if (issuance != null) { + credential.put(ISSUANCE_DATE, issuance.truncatedTo(ChronoUnit.SECONDS).toString()); + } + credential.put(CREDENTIAL_SUBJECT, subject); final Map data = new HashMap<>(); diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java index 45f426d89bd..d64ba25505c 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java @@ -30,6 +30,8 @@ import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.time.Instant; +import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -133,4 +135,124 @@ static AccessRequest parse(final String serialization) throws IOException { } } } + + public static class RequestParameters { + + private final URI recipient; + private final Set resources; + private final Set modes; + private final Set purposes; + private final Instant expiration; + private final Instant issuedAt; + + RequestParameters(final URI recipient, final Set resources, + final Set modes, final Set purposes, final Instant expiration, final Instant issuedAt) { + this.recipient = recipient; + this.resources = resources; + this.modes = modes; + this.purposes = purposes; + this.expiration = expiration; + this.issuedAt = issuedAt; + } + + public URI getRecipient() { + return recipient; + } + + public Set getResources() { + return resources; + } + + public Set getModes() { + return modes; + } + + public Set getPurposes() { + return purposes; + } + + public Instant getExpiration() { + return expiration; + } + + public Instant getIssuedAt() { + return issuedAt; + } + + public static class Builder { + + private final Set builderResources = new HashSet<>(); + private final Set builderModes = new HashSet<>(); + private final Set builderPurposes = new HashSet<>(); + private URI builderRecipient; + private Instant builderExpiration; + private Instant builderIssuedAt; + + Builder() { + // Prevent external instantiation + } + + public Builder recipient(final URI recipient) { + builderRecipient = recipient; + return this; + } + + public Builder resource(final URI resource) { + builderResources.add(resource); + return this; + } + + public Builder resources(final Collection resources) { + if (resources != null) { + builderResources.addAll(resources); + } else { + builderResources.clear(); + } + return this; + } + + public Builder mode(final String mode) { + builderModes.add(mode); + return this; + } + + public Builder modes(final Collection modes) { + if (modes != null) { + builderModes.addAll(modes); + } else { + builderModes.clear(); + } + return this; + } + + public Builder purpose(final URI purpose) { + builderPurposes.add(purpose); + return this; + } + + public Builder purposes(final Collection purposes) { + if (purposes != null) { + builderPurposes.addAll(purposes); + } else { + builderPurposes.clear(); + } + return this; + } + + public Builder expiration(final Instant expiration) { + builderExpiration = expiration; + return this; + } + + public Builder issuedAt(final Instant issuedAt) { + builderIssuedAt = issuedAt; + return this; + } + + public RequestParameters build() { + return new RequestParameters(builderRecipient, builderResources, builderModes, builderPurposes, + builderExpiration, builderIssuedAt); + } + } + } } From cb49a37f2474293572163e26fb3844b597d1b778 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Fri, 7 Jul 2023 14:52:55 -0500 Subject: [PATCH 2/4] javadocs --- .../client/accessgrant/AccessRequest.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java index d64ba25505c..dc95fb67aa3 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java @@ -136,6 +136,11 @@ static AccessRequest parse(final String serialization) throws IOException { } } + /** + * A collection of parameters used for creating access requests. + * + *

See, in particular, the {@link AccessGrantClient.requestAccess(RequestParameters)} method. + */ public static class RequestParameters { private final URI recipient; @@ -145,6 +150,7 @@ public static class RequestParameters { private final Instant expiration; private final Instant issuedAt; + /* package private */ RequestParameters(final URI recipient, final Set resources, final Set modes, final Set purposes, final Instant expiration, final Instant issuedAt) { this.recipient = recipient; @@ -155,26 +161,62 @@ public static class RequestParameters { this.issuedAt = issuedAt; } + /** + * Get the recipient used with an access request operation. + * + *

Note: the recipient will typically be the resource owner + * + * @return the recipient's identifier + */ public URI getRecipient() { return recipient; } + /** + * Get the resources used with an access request operation. + * + * @return the resource idnetifiers + */ public Set getResources() { return resources; } + /** + * Get the access modes used with an access request operation. + * + * @return the access modes + */ public Set getModes() { return modes; } + /** + * Get the purpose identifiers used with an access request operation. + * + * @return the purpose identifiers + */ public Set getPurposes() { return purposes; } + /** + * Get the requested expiration date used with an access request operation. + * + *

Note: an access grant server may select a different expiration date + * + * @return the requested expiration date + */ public Instant getExpiration() { return expiration; } + /** + * Get the requested issuance date used with an access request operation. + * + *

Note: an access grant server may select a different issuance date + * + * @return the requested issuance date + */ public Instant getIssuedAt() { return issuedAt; } From e4dc9a70c7698ad7253570e3e130f17fb56453fc Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Fri, 7 Jul 2023 19:27:52 -0500 Subject: [PATCH 3/4] Javadocs --- .../client/accessgrant/AccessRequest.java | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java index dc95fb67aa3..0c04c689faa 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java @@ -139,7 +139,7 @@ static AccessRequest parse(final String serialization) throws IOException { /** * A collection of parameters used for creating access requests. * - *

See, in particular, the {@link AccessGrantClient.requestAccess(RequestParameters)} method. + *

See, in particular, the {@link AccessGrantClient#requestAccess(RequestParameters)} method. */ public static class RequestParameters { @@ -221,6 +221,9 @@ public Instant getIssuedAt() { return issuedAt; } + /** + * A class for building access request parameters. + */ public static class Builder { private final Set builderResources = new HashSet<>(); @@ -230,20 +233,43 @@ public static class Builder { private Instant builderExpiration; private Instant builderIssuedAt; + /* package-private */ Builder() { // Prevent external instantiation } + /** + * Set a recipient for the access request operation. + * + *

Note: this will typically be the identifier of resource owner + * + * @param recipient the recipient identifier, may be {@code null} + * @return this builder + */ public Builder recipient(final URI recipient) { builderRecipient = recipient; return this; } + /** + * Set a single resource for the access request operation. + * + * @param resource the resource identifier, not {@code null} + * @return this builder + */ public Builder resource(final URI resource) { builderResources.add(resource); return this; } + /** + * Set multiple resources for the access request operation. + * + *

Note: A null value will clear all existing resource values + * + * @param resources the resource identifiers, may be {@code null} + * @return this builder + */ public Builder resources(final Collection resources) { if (resources != null) { builderResources.addAll(resources); @@ -253,11 +279,25 @@ public Builder resources(final Collection resources) { return this; } + /** + * Set a single access mode for the access request operation. + * + * @param mode the access mode, not {@code null} + * @return this builder + */ public Builder mode(final String mode) { builderModes.add(mode); return this; } + /** + * Set multiple access modes for the access request operation. + * + *

Note: A null value will clear all existing mode values + * + * @param modes the access modes, may be {@code null} + * @return this builder + */ public Builder modes(final Collection modes) { if (modes != null) { builderModes.addAll(modes); @@ -267,11 +307,25 @@ public Builder modes(final Collection modes) { return this; } + /** + * Set a single purpose for the access request operation. + * + * @param purpose the purpose identifier, not {@code null} + * @return this builder + */ public Builder purpose(final URI purpose) { builderPurposes.add(purpose); return this; } + /** + * Set multiple purposes for the access request operation. + * + *

Note: A null value will clear all existing purpose values + * + * @param purposes the purpose identifiers, may be {@code null} + * @return this builder + */ public Builder purposes(final Collection purposes) { if (purposes != null) { builderPurposes.addAll(purposes); @@ -281,16 +335,37 @@ public Builder purposes(final Collection purposes) { return this; } + /** + * Set a preferred expiration time for the access request operation. + * + *

Note: an access grant server may select a different expiration value + * + * @param expiration the expiration time, may be {@code null}. + * @return this builder + */ public Builder expiration(final Instant expiration) { builderExpiration = expiration; return this; } + /** + * Set a preferred issuance time for the access request operation, likely at a time in the future. + * + *

Note: an access grant server may select a different issuance value + * + * @param issuedAt the issuance time, may be {@code null}. + * @return this builder + */ public Builder issuedAt(final Instant issuedAt) { builderIssuedAt = issuedAt; return this; } + /** + * Build the {@link RequestParameters} object. + * + * @return the access request parameters + */ public RequestParameters build() { return new RequestParameters(builderRecipient, builderResources, builderModes, builderPurposes, builderExpiration, builderIssuedAt); From c396c0e91e6ab4435df5399cc89a7c1699853e33 Mon Sep 17 00:00:00 2001 From: Aaron Coburn Date: Mon, 10 Jul 2023 17:04:00 -0500 Subject: [PATCH 4/4] Add tests --- .../client/accessgrant/AccessRequest.java | 9 +++ .../accessgrant/AccessGrantClientTest.java | 34 +++++++++++ .../client/accessgrant/AccessRequestTest.java | 59 +++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java index 0c04c689faa..75c4f090b69 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessRequest.java @@ -221,6 +221,15 @@ public Instant getIssuedAt() { return issuedAt; } + /** + * Create a new {@link RequestParameters} builder. + * + * @return the new builder + */ + public static Builder newBuilder() { + return new Builder(); + } + /** * A class for building access request parameters. */ diff --git a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java index 47d13010f82..30a6b18a44b 100644 --- a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java +++ b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessGrantClientTest.java @@ -340,6 +340,40 @@ void testIssueRequest() { assertEquals(resources, request.getResources()); } + @Test + void testIssueRequestBuilder() { + final Map claims = new HashMap<>(); + claims.put("webid", WEBID); + claims.put("sub", SUB); + claims.put("iss", ISS); + claims.put("azp", AZP); + final String token = generateIdToken(claims); + final AccessGrantClient client = agClient.session(OpenIdSession.ofIdToken(token)); + + final URI recipient = URI.create("https://id.test/agent"); + final Instant expiration = Instant.parse("2022-08-27T12:00:00Z"); + final Set modes = new HashSet<>(Arrays.asList("Read", "Append")); + final Set purposes = Collections.singleton(URI.create("https://purpose.test/Purpose1")); + + final Set resources = Collections.singleton(URI.create("https://storage.test/data/")); + final AccessRequest.RequestParameters params = AccessRequest.RequestParameters.newBuilder() + .recipient(recipient) + .resources(resources) + .modes(modes) + .purposes(purposes) + .expiration(expiration) + .issuedAt(Instant.now()).build(); + final AccessRequest request = client.requestAccess(params).toCompletableFuture().join(); + + assertTrue(request.getTypes().contains("SolidAccessRequest")); + assertEquals(Optional.of(recipient), request.getRecipient()); + assertEquals(modes, request.getModes()); + assertEquals(expiration, request.getExpiration()); + assertEquals(baseUri, request.getIssuer()); + assertEquals(purposes, request.getPurposes()); + assertEquals(resources, request.getResources()); + } + @Test void testRequestAccessNoAuth() { final URI recipient = URI.create("https://id.test/agent"); diff --git a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessRequestTest.java b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessRequestTest.java index edaec6706a4..fe3059af83c 100644 --- a/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessRequestTest.java +++ b/access-grant/src/test/java/com/inrupt/client/accessgrant/AccessRequestTest.java @@ -42,6 +42,65 @@ class AccessRequestTest { private static final JsonService jsonService = ServiceProvider.getJsonService(); + @Test + void testBuilderWithNulls() { + final URI uri1 = URI.create("https://example.com/resource1"); + final URI uri2 = URI.create("https://example.com/resource2"); + final URI uri3 = URI.create("https://example.com/resource3"); + final URI uri4 = URI.create("https://example.com/resource4"); + final AccessRequest.RequestParameters params = AccessRequest.RequestParameters.newBuilder() + .resource(uri1).resource(uri2) + .mode("Read").mode("Append") + .purpose(uri3).purpose(uri4) + .recipient(null) + .modes(null) + .resources(null) + .purposes(null).build(); + + assertTrue(params.getPurposes().isEmpty()); + assertTrue(params.getResources().isEmpty()); + assertTrue(params.getModes().isEmpty()); + assertNull(params.getRecipient()); + assertNull(params.getExpiration()); + assertNull(params.getIssuedAt()); + } + + @Test + void testBuilderWithCollections() { + final URI uri1 = URI.create("https://example.com/resource1"); + final URI uri2 = URI.create("https://example.com/resource2"); + final URI uri3 = URI.create("https://example.com/resource3"); + final URI uri4 = URI.create("https://example.com/resource4"); + final AccessRequest.RequestParameters params = AccessRequest.RequestParameters.newBuilder() + .resource(uri1) + .mode("Read") + .purpose(uri3) + .recipient(uri2) + .modes(Collections.singleton("Append")) + .resources(Collections.singleton(uri2)) + .purposes(Collections.singleton(uri4)).build(); + + final Set expectedPurposes = new HashSet<>(); + expectedPurposes.add(uri3); + expectedPurposes.add(uri4); + assertEquals(expectedPurposes, params.getPurposes()); + + final Set expectedResources = new HashSet<>(); + expectedResources.add(uri1); + expectedResources.add(uri2); + assertEquals(expectedResources, params.getResources()); + + final Set expectedModes = new HashSet<>(); + expectedModes.add("Read"); + expectedModes.add("Append"); + assertEquals(expectedModes, params.getModes()); + + assertEquals(uri2, params.getRecipient()); + assertNull(params.getExpiration()); + assertNull(params.getIssuedAt()); + } + + @Test void testReadAccessRequest() throws IOException { try (final InputStream resource = AccessRequestTest.class.getResourceAsStream("/access_request1.json")) {