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..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 @@ -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,250 @@ 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; + private final Set resources; + private final Set modes; + private final Set purposes; + 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; + this.resources = resources; + this.modes = modes; + this.purposes = purposes; + this.expiration = expiration; + 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; + } + + /** + * Create a new {@link RequestParameters} builder. + * + * @return the new builder + */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * A class for building access request parameters. + */ + 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; + + /* 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); + } else { + builderResources.clear(); + } + 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); + } else { + builderModes.clear(); + } + 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); + } else { + builderPurposes.clear(); + } + 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); + } + } + } } 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")) {