diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessCredential.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessCredential.java index a520fa6be2..32f05594db 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessCredential.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessCredential.java @@ -37,6 +37,7 @@ /** A base class for access credentials. **/ public class AccessCredential { + static final int SINGLETON = 1; private static final Logger LOGGER = LoggerFactory.getLogger(AccessCredential.class); protected static final String TYPE = "type"; @@ -49,6 +50,7 @@ public class AccessCredential { private final Set modes; private final Set purposes; private final Set resources; + private final Set templates; private final URI recipient; private final URI creator; private final Instant expiration; @@ -71,6 +73,7 @@ protected AccessCredential(final URI identifier, final String credential, this.purposes = data.getPurposes(); this.resources = data.getResources(); + this.templates = data.getTemplates(); this.modes = data.getModes(); this.recipient = data.getRecipient(); @@ -165,6 +168,15 @@ public Set getResources() { return resources; } + /** + * Get the templates associated with the access credential. + * + * @return the associated templates + */ + public Set getTemplates() { + return templates; + } + /** * Get the creator of this access credential. * @@ -281,6 +293,7 @@ public static class CredentialData { private final Set modes; private final Set purposes; private final Set resources; + private final Set templates; private final URI recipient; private final URI accessRequest; @@ -294,7 +307,7 @@ public static class CredentialData { */ public CredentialData(final Set resources, final Set modes, final Set purposes, final URI recipient) { - this(resources, modes, purposes, recipient, null); + this(resources, Collections.emptySet(), modes, purposes, recipient, null); } /** @@ -308,6 +321,22 @@ public CredentialData(final Set resources, final Set modes, */ public CredentialData(final Set resources, final Set modes, final Set purposes, final URI recipient, final URI accessRequest) { + this(resources, Collections.emptySet(), modes, purposes, recipient, accessRequest); + } + + /** + * Create a collection of user-managed credential data. + * + * @param resources the resources referenced by the credential + * @param templates the resource templates referenced by the credential + * @param modes the access modes defined by this credential + * @param purposes the purposes associated with this credential + * @param recipient the recipient for this credential, may be {@code null} + * @param accessRequest the access request identifier, may be {@code null} + */ + public CredentialData(final Set resources, final Set templates, final Set modes, + final Set purposes, final URI recipient, final URI accessRequest) { + this.templates = Objects.requireNonNull(templates, "templates may not be null!"); this.modes = Objects.requireNonNull(modes, "modes may not be null!"); this.purposes = Objects.requireNonNull(purposes, "purposes may not be null!"); this.resources = Objects.requireNonNull(resources, "resources may not be null!"); @@ -342,6 +371,15 @@ public Set getResources() { return resources; } + /** + * Get the URL templates associated with this credential. + * + * @return the URL templates + */ + public Set getTemplates() { + return templates; + } + /** * Get the recipient associated with this credential. * @@ -393,7 +431,7 @@ static Stream filterUris(final String uri) { try { return Stream.of(URI.create(uri)); } catch (final IllegalArgumentException ex) { - LOGGER.debug("Ignoring non-URI purpose: {}", ex.getMessage()); + LOGGER.atDebug().setMessage("Ignoring non-URI purpose: {}").addArgument(ex::getMessage).log(); } return Stream.empty(); } diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessDenial.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessDenial.java index 7259a9c792..6ad694f9bb 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessDenial.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessDenial.java @@ -111,13 +111,13 @@ static Set getSupportedTypes() { } static AccessDenial parse(final String serialization) throws IOException { - try (final InputStream in = new ByteArrayInputStream(serialization.getBytes())) { + try (final InputStream in = new ByteArrayInputStream(serialization.getBytes(UTF_8))) { // TODO process as JSON-LD final Map data = jsonService.fromJson(in, new HashMap(){}.getClass().getGenericSuperclass()); final List> vcs = getCredentialsFromPresentation(data, supportedTypes); - if (vcs.size() != 1) { + if (vcs.size() != SINGLETON) { throw new IllegalArgumentException( "Invalid Access Denial: ambiguous number of verifiable credentials"); } diff --git a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java index 8dfef4d2b4..c9fec2814b 100644 --- a/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java +++ b/access-grant/src/main/java/com/inrupt/client/accessgrant/AccessGrant.java @@ -111,13 +111,13 @@ static Set getSupportedTypes() { } static AccessGrant parse(final String serialization) throws IOException { - try (final InputStream in = new ByteArrayInputStream(serialization.getBytes())) { + try (final InputStream in = new ByteArrayInputStream(serialization.getBytes(UTF_8))) { // TODO process as JSON-LD final Map data = jsonService.fromJson(in, new HashMap(){}.getClass().getGenericSuperclass()); final List> vcs = getCredentialsFromPresentation(data, supportedTypes); - if (vcs.size() != 1) { + if (vcs.size() != SINGLETON) { throw new IllegalArgumentException( "Invalid Access Grant: ambiguous number of verifiable credentials"); } 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 89716d47a5..998dc84209 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 @@ -55,6 +55,7 @@ import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.function.Function; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -105,6 +106,7 @@ public class AccessGrantClient { private static final String IS_PROVIDED_TO = "isProvidedTo"; private static final String IS_CONSENT_FOR_DATA_SUBJECT = "isConsentForDataSubject"; private static final String FOR_PERSONAL_DATA = "forPersonalData"; + private static final String TEMPLATE = "template"; private static final String HAS_STATUS = "hasStatus"; private static final String REQUEST = "request"; private static final String VERIFIED_REQUEST = "verifiedRequest"; @@ -183,7 +185,10 @@ private AccessGrantClient(final Client client, final ClientCache this.config = Objects.requireNonNull(config, "config may not be null!"); this.metadataCache = Objects.requireNonNull(metadataCache, "metadataCache may not be null!"); this.jsonService = ServiceProvider.getJsonService(); - LOGGER.debug("Initializing Access Grant client with issuer: {}", config.getIssuer()); + LOGGER.atDebug() + .setMessage("Initializing Access Grant client with issuer: {}") + .addArgument(config::getIssuer) + .log(); } /** @@ -204,7 +209,7 @@ public AccessGrantClient session(final Session session) { * @return the next stage of completion containing the resulting access request */ public CompletionStage requestAccess(final AccessRequest.RequestParameters request) { - return requestAccess(request.getRecipient(), request.getResources(), + return requestAccess(request.getRecipient(), request.getResources(), request.getTemplates(), request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt()); } @@ -220,16 +225,23 @@ public CompletionStage requestAccess(final AccessRequest.RequestP */ 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); + return requestAccess(recipient, resources, Collections.emptySet(), 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) { + final Set templates, final Set modes, final Set purposes, final Instant expiration, + final Instant issuance) { Objects.requireNonNull(resources, "Resources may not be null!"); + Objects.requireNonNull(templates, "Templates may not be null!"); Objects.requireNonNull(modes, "Access modes may not be null!"); + if (templates.isEmpty() && resources.isEmpty()) { + LOGGER.warn("Both resources and templates are empty in access request"); + } else if (!templates.isEmpty() && !resources.isEmpty()) { + LOGGER.warn("Both resources and templates are non-empty in access request"); + } return v1Metadata().thenCompose(metadata -> { - final Map data = buildAccessRequestv1(recipient, resources, modes, purposes, expiration, - issuance); + final Map data = buildAccessRequestv1(recipient, resources, templates, modes, purposes, + expiration, issuance); final Request req = Request.newBuilder(metadata.issueEndpoint) .header(CONTENT_TYPE, APPLICATION_JSON) @@ -253,26 +265,79 @@ private CompletionStage requestAccess(final URI recipient, final } /** - * Issue an access grant based on an access request. The access request is not verified. + * Issue an access grant based on an access request. + * + *

+ * The access request is not verified. + * Any templated URLs are ignored. * * @param request the access request * @return the next stage of completion containing the issued access grant */ public CompletionStage grantAccess(final AccessRequest request) { - return grantAccess(request, false); + return grantAccess(request, templates -> Collections.emptySet(), false); + } + + /** + * Issue an access grant based on an access request. + * + *

+ * The access request is verified. + * Any templated URLs are processed according to the provided mapping function. + * + * @param request the access request + * @param mapping a mapping function for template URLs + * @return the next stage of completion containing the issued access grant + */ + public CompletionStage grantAccess(final AccessRequest request, + final Function, Set> mapping) { + return grantAccess(request, mapping, true); } /** * Issue an access grant based on an access request. * + *

+ * Any templated URLs are ignored. + * * @param request the access request * @param verifyRequest whether the request should be verified before issuing the access grant * @return the next stage of completion containing the issued access grant */ public CompletionStage grantAccess(final AccessRequest request, final boolean verifyRequest) { + return grantAccess(request, templates -> Collections.emptySet(), verifyRequest); + } + + /** + * Issue an access grant based on an access request. + * + * @param request the access request + * @param mapping a mapping function for template URLs + * @param verifyRequest whether the request should be verified before issuing the access grant + * @return the next stage of completion containing the issued access grant + */ + public CompletionStage grantAccess(final AccessRequest request, + final Function, Set> mapping, final boolean verifyRequest) { Objects.requireNonNull(request, "Request may not be null!"); + final var templated = mapping.apply(request.getTemplates()); + if (templated.size() != request.getTemplates().size()) { + LOGGER.atDebug() + .setMessage("Unexpected number of mapped template values, found ({}) expected ({})") + .addArgument(templated::size) + .addArgument(() -> request.getTemplates().size()) + .log(); + } + final var resources = new HashSet(request.getResources()); + resources.addAll(templated); + if (resources.isEmpty()) { + LOGGER.atWarn() + .setMessage("No data URLs supplied: {} resource URLs and {} mapped templates") + .addArgument(() -> request.getResources().size()) + .addArgument(templated::size) + .log(); + } return v1Metadata().thenCompose(metadata -> { - final Map data = buildAccessGrantv1(request.getCreator(), request.getResources(), + final Map data = buildAccessGrantv1(request.getCreator(), resources, request.getModes(), request.getPurposes(), request.getExpiration(), request.getIssuedAt(), request.getIdentifier(), verifyRequest); final Request req = Request.newBuilder(metadata.issueEndpoint) @@ -815,12 +880,17 @@ static Map buildAccessGrantv1( return data; } - static Map buildAccessRequestv1(final URI agent, final Set resources, final Set modes, - final Set purposes, final Instant expiration, final Instant issuance) { + static Map buildAccessRequestv1(final URI agent, final Set resources, + final Set templates, final Set modes, 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); - consent.put(FOR_PERSONAL_DATA, resources); + if (!resources.isEmpty()) { + consent.put(FOR_PERSONAL_DATA, resources); + } else { + consent.put(TEMPLATE, templates); + } if (agent != null) { consent.put(IS_CONSENT_FOR_DATA_SUBJECT, agent); } 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 5c3b33b661..bb3db7dcd7 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 @@ -37,6 +37,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -100,13 +101,13 @@ static Set getSupportedTypes() { } static AccessRequest parse(final String serialization) throws IOException { - try (final InputStream in = new ByteArrayInputStream(serialization.getBytes())) { + try (final InputStream in = new ByteArrayInputStream(serialization.getBytes(UTF_8))) { // TODO process as JSON-LD final Map data = jsonService.fromJson(in, new HashMap(){}.getClass().getGenericSuperclass()); final List> vcs = getCredentialsFromPresentation(data, supportedTypes); - if (vcs.size() != 1) { + if (vcs.size() != SINGLETON) { throw new IllegalArgumentException( "Invalid Access Request: ambiguous number of verifiable credentials"); } @@ -128,7 +129,15 @@ static AccessRequest parse(final String serialization) throws IOException { .stream().map(URI::create).collect(Collectors.toSet()); final Set purposes = asSet(consent.get("forPurpose")).orElseGet(Collections::emptySet) .stream().flatMap(AccessCredential::filterUris).collect(Collectors.toSet()); - final CredentialData credentialData = new CredentialData(resources, modes, purposes, recipient); + final Set templates = asSet(consent.get("template")).orElseGet(Collections::emptySet); + + final CredentialData credentialData; + + if (!templates.isEmpty()) { + credentialData = new CredentialData(resources, templates, modes, purposes, recipient, null); + } else { + credentialData = new CredentialData(resources, modes, purposes, recipient); + } return new AccessRequest(identifier, serialization, credentialData, credentialMetadata); } else { @@ -137,6 +146,33 @@ static AccessRequest parse(final String serialization) throws IOException { } } + /** + * Produce a URI template that can be used consistently with the JCL. + * + * @param dataPath the data path relative to a storage root + * @return a URI template + */ + public static String template(final String dataPath) { + Objects.requireNonNull(dataPath, "dataPath may not be null!"); + if (!dataPath.startsWith("/") || dataPath.isBlank()) { + return template("/" + dataPath); + } + return "https://{domain}/{+path}" + dataPath; + } + + /** + * Produce a Map of template values that can be used with a URI template resolver. + * + * @param domain the domain of the user's storage + * @param path the base path of the user's storage + * @return a map of template values + */ + public static Map templateValues(final String domain, final String path) { + Objects.requireNonNull(domain, "domain may not be null!"); + Objects.requireNonNull(path, "path may not be null!"); + return Map.of("domain", domain, "path", path); + } + /** * A collection of parameters used for creating access requests. * @@ -145,6 +181,7 @@ static AccessRequest parse(final String serialization) throws IOException { public static class RequestParameters { private final URI recipient; + private final Set templates; private final Set resources; private final Set modes; private final Set purposes; @@ -152,10 +189,11 @@ public static class RequestParameters { private final Instant issuedAt; /* package private */ - RequestParameters(final URI recipient, final Set resources, + RequestParameters(final URI recipient, final Set resources, final Set templates, final Set modes, final Set purposes, final Instant expiration, final Instant issuedAt) { this.recipient = recipient; this.resources = resources; + this.templates = templates; this.modes = modes; this.purposes = purposes; this.expiration = expiration; @@ -176,12 +214,21 @@ public URI getRecipient() { /** * Get the resources used with an access request operation. * - * @return the resource idnetifiers + * @return the resource identifiers */ public Set getResources() { return resources; } + /** + * Get the resource templates used with an access request operation. + * + * @return the URI templates for an access request + */ + public Set getTemplates() { + return templates; + } + /** * Get the access modes used with an access request operation. * @@ -239,6 +286,7 @@ public static class Builder { private final Set builderResources = new HashSet<>(); private final Set builderModes = new HashSet<>(); private final Set builderPurposes = new HashSet<>(); + private final Set builderTemplates = new HashSet<>(); private URI builderRecipient; private Instant builderExpiration; private Instant builderIssuedAt; @@ -289,6 +337,34 @@ public Builder resources(final Collection resources) { return this; } + /** + * Set a single resource template for the access request operation. + * + * @param template a URL template for the requested resource, not {@code null} + * @return this builder + */ + public Builder template(final String template) { + builderTemplates.add(template); + return this; + } + + /** + * Set multiple resource templates for the access request operation. + * + *

Note: A null value will clear all existing template values + * + * @param templates URL templates for the requested resources, may be {@code null} + * @return this builder + */ + public Builder templates(final Collection templates) { + if (templates != null) { + builderTemplates.addAll(templates); + } else { + builderTemplates.clear(); + } + return this; + } + /** * Set a single access mode for the access request operation. * @@ -377,8 +453,8 @@ public Builder issuedAt(final Instant issuedAt) { * @return the access request parameters */ public RequestParameters build() { - return new RequestParameters(builderRecipient, builderResources, builderModes, builderPurposes, - builderExpiration, builderIssuedAt); + return new RequestParameters(builderRecipient, builderResources, builderTemplates, 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 750847aaf1..4192c3e6a0 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 @@ -42,6 +42,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.stream.Collectors; import org.apache.commons.io.IOUtils; import org.jose4j.jwk.PublicJsonWebKey; @@ -174,7 +175,6 @@ void testFetch5() { assertInstanceOf(AccessGrantException.class, err1.getCause()); } - @Test void testFetch6() { final Map claims = new HashMap<>(); @@ -215,6 +215,27 @@ void testNotAccessGrant() { client.fetch(uri, AccessGrant.class).toCompletableFuture()::join); } + @Test + void testFetchTemplate() { + 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 URI uri = URIBuilder.newBuilder(baseUri).path("access-request-6").build(); + final AccessGrantClient client = agClient.session(OpenIdSession.ofIdToken(token)); + final AccessRequest request = client.fetch(uri, AccessRequest.class).toCompletableFuture().join(); + + assertEquals(uri, request.getIdentifier()); + assertEquals(baseUri, request.getIssuer()); + + // Revoke + final CompletableFuture future = client.revoke(request).toCompletableFuture(); + final CompletionException err1 = assertThrows(CompletionException.class, future::join); + assertInstanceOf(AccessGrantException.class, err1.getCause()); + } + @Test void testFetchInvalid() { final URI uri = URIBuilder.newBuilder(baseUri).path(".well-known/vc-configuration").build(); @@ -286,6 +307,34 @@ void testIssueRequestBuilder() { assertEquals(resources, request.getResources()); } + @Test + void testGrantFromTemplate() { + 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 URI uri = URIBuilder.newBuilder(baseUri).path("access-request-6").build(); + final AccessGrantClient client = agClient.session(OpenIdSession.ofIdToken(token)); + final AccessRequest request = client.fetch(uri, AccessRequest.class).toCompletableFuture().join(); + + final AccessGrant grant = client.grantAccess(request, + templates -> Set.of(URI.create("https://storage.test/data/"))).toCompletableFuture().join(); + + final Instant expiration = Instant.parse("2022-08-27T12:00:00Z"); + final Set modes = Set.of("Read", "Append"); + final Set purposes = Collections.singleton(URI.create("https://purpose.test/Purpose1")); + + assertTrue(grant.getTypes().contains("SolidAccessGrant")); + assertEquals(Optional.of(URI.create("https://id.test/agent")), grant.getRecipient()); + assertEquals(modes, grant.getModes()); + assertEquals(expiration, grant.getExpiration()); + assertEquals(baseUri, grant.getIssuer()); + assertEquals(purposes, grant.getPurposes()); + assertNotNull(grant.getAccessRequest()); + } + @Test void testRequestAccessNoAuth() { final URI recipient = URI.create("https://id.test/agent"); @@ -335,6 +384,53 @@ void testGrantAccess(final boolean verifyRequest) { assertNotNull(grant.getAccessRequest()); } + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void testGrantTemplatedAccess(final boolean verifyRequest) { + 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 = Set.of("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 var req = AccessRequest.RequestParameters.newBuilder() + .recipient(recipient) + .templates(Collections.singleton("http://{storage}/path")) + .templates(null) + .template(AccessRequest.template("data")) + .modes(modes) + .purposes(purposes) + .expiration(expiration) + .build(); + + final AccessRequest request = client.requestAccess(req) + .toCompletableFuture().join(); + + final AccessGrant grant = client.grantAccess(request, templates -> + templates.stream() + .map(t -> t.replace("{domain}", "storage.test").replace("{+path}/", "")) + .map(URI::create) + .collect(Collectors.toSet()), verifyRequest) + .toCompletableFuture().join(); + + assertTrue(grant.getTypes().contains("SolidAccessGrant")); + assertEquals(Optional.of(recipient), grant.getRecipient()); + assertEquals(modes, grant.getModes()); + assertEquals(expiration, grant.getExpiration()); + assertEquals(baseUri, grant.getIssuer()); + assertEquals(purposes, grant.getPurposes()); + assertEquals(resources, grant.getResources()); + assertNotNull(grant.getAccessRequest()); + } + @ParameterizedTest @ValueSource(booleans = {true, false}) void testDenyAccess(final boolean verifyRequest) { @@ -371,6 +467,58 @@ void testDenyAccess(final boolean verifyRequest) { assertInstanceOf(AccessGrantException.class, err.getCause()); } + @Test + void testNoTemplatesResources() { + 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 = Set.of("Read", "Append"); + final Set purposes = Collections.singleton(URI.create("https://purpose.test/Purpose1")); + + final var req = AccessRequest.RequestParameters.newBuilder() + .recipient(recipient) + .modes(modes) + .purposes(purposes) + .expiration(expiration) + .build(); + + assertDoesNotThrow(client.requestAccess(req).toCompletableFuture()::join); + } + + @Test + void testBothTemplatesResources() { + 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 = Set.of("Read", "Append"); + final Set purposes = Collections.singleton(URI.create("https://purpose.test/Purpose1")); + + final var req = AccessRequest.RequestParameters.newBuilder() + .recipient(recipient) + .modes(modes) + .purposes(purposes) + .expiration(expiration) + .template(AccessRequest.template("data")) + .resource(URI.create("https://storage.test/path/data/")) + .build(); + + assertDoesNotThrow(client.requestAccess(req).toCompletableFuture()::join); + } + @Test void testGrantAccessNoAuth() { final Map claims = new HashMap<>(); 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 3baf4aea9a..a6eea106eb 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 @@ -272,4 +272,19 @@ void testInvalidStream() throws IOException { void testInvalidString() throws IOException { assertThrows(IllegalArgumentException.class, () -> AccessRequest.of("not json")); } + + @Test + void testTemplate() { + assertEquals("https://{domain}/{+path}/custom-path", AccessRequest.template("custom-path")); + assertEquals("https://{domain}/{+path}/custom-path", AccessRequest.template("/custom-path")); + assertEquals("https://{domain}/{+path}/./custom-path", AccessRequest.template("./custom-path")); + assertEquals("https://{domain}/{+path}/", AccessRequest.template("")); + } + + @Test + void testTemplateValues() { + final var values = AccessRequest.templateValues("mydomain.com", "/storage/path"); + assertEquals("mydomain.com", values.get("domain")); + assertEquals("/storage/path", values.get("path")); + } } diff --git a/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java b/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java index 9a3eb57dec..cfd5e49d43 100644 --- a/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java +++ b/access-grant/src/test/java/com/inrupt/client/accessgrant/MockAccessGrantServer.java @@ -139,6 +139,32 @@ private void setupMocks() { .withStatus(401) .withHeader("WWW-Authenticate", "Bearer,DPoP algs=\"ES256\""))); + wireMockServer.stubFor(get(urlEqualTo("/access-request-6")) + .atPriority(1) + .withHeader("Authorization", containing("Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.")) + .willReturn(aResponse() + .withStatus(200) + .withHeader(CONTENT_TYPE, APPLICATION_JSON) + .withBody(getResource("/template-6.json", wireMockServer.baseUrl())))); + + wireMockServer.stubFor(get(urlEqualTo("/access-request-6")) + .atPriority(2) + .willReturn(aResponse() + .withStatus(401) + .withHeader("WWW-Authenticate", "Bearer,DPoP algs=\"ES256\""))); + + wireMockServer.stubFor(delete(urlEqualTo("/access-request-6")) + .atPriority(1) + .withHeader("Authorization", containing("Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.")) + .willReturn(aResponse() + .withStatus(204))); + + wireMockServer.stubFor(delete(urlEqualTo("/access-request-6")) + .atPriority(2) + .willReturn(aResponse() + .withStatus(401) + .withHeader("WWW-Authenticate", "Bearer,DPoP algs=\"ES256\""))); + wireMockServer.stubFor(get(urlEqualTo("/access-grant-6")) .atPriority(1) .withHeader("Authorization", containing("Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.")) diff --git a/access-grant/src/test/resources/template-6.json b/access-grant/src/test/resources/template-6.json new file mode 100644 index 0000000000..ed803da730 --- /dev/null +++ b/access-grant/src/test/resources/template-6.json @@ -0,0 +1,31 @@ +{ + "@context":[ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/suites/ed25519-2020/v1", + "https://w3id.org/vc-revocation-list-2020/v1", + "https://schema.inrupt.com/credentials/v2.jsonld"], + "id":"{{baseUrl}}/access-request-6", + "type":["VerifiableCredential","SolidAccessRequest"], + "issuer":"{{baseUrl}}", + "expirationDate":"2022-08-27T12:00:00Z", + "issuanceDate":"2022-08-25T20:34:05.153Z", + "credentialStatus":{ + "id":"https://accessgrant.example/status/CVAM#2832", + "revocationListCredential":"https://accessgrant.example/status/CVAM", + "revocationListIndex":"2832", + "type":"RevocationList2020Status"}, + "credentialSubject":{ + "id":"https://id.test/username", + "hasConsent":{ + "mode":["Read","Append"], + "hasStatus":"https://w3id.org/GConsent#ConsentStatusRequested", + "forPurpose":["https://purpose.test/Purpose1"], + "template":["https://{domain}/{+path}/data/"]}}, + "proof":{ + "created":"2022-08-25T20:34:05.236Z", + "proofPurpose":"assertionMethod", + "proofValue":"nIeQF44XVik7onnAbdkbp8xxJ2C8JoTw6-VtCkAzxuWYRFsSfYpft5MuAJaivyeKDmaK82Lj_YsME2xgL2WIBQ", + "type":"Ed25519Signature2020", + "verificationMethod":"https://accessgrant.example/key/1e332728-4af5-46e4-a5db-4f7b89e3f378"} +} + diff --git a/integration/base/src/main/java/com/inrupt/client/integration/base/AccessGrantScenarios.java b/integration/base/src/main/java/com/inrupt/client/integration/base/AccessGrantScenarios.java index cf164129fb..cadf46edd0 100644 --- a/integration/base/src/main/java/com/inrupt/client/integration/base/AccessGrantScenarios.java +++ b/integration/base/src/main/java/com/inrupt/client/integration/base/AccessGrantScenarios.java @@ -45,6 +45,7 @@ import java.util.HashSet; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import java.util.stream.Stream; import org.apache.commons.rdf.api.IRI; @@ -770,6 +771,70 @@ void accessGrantCreateNonRdfTest(final Session resourceOwnerSession, final Sessi assertDoesNotThrow(resourceOwnerAccessGrantClient.revoke(grant).toCompletableFuture()::join); } + @ParameterizedTest + @MethodSource("provideSessions") + @DisplayName("Creating non-RDF using a templated Access Grant") + void accessGrantCreateTemplateTest(final Session resourceOwnerSession, final Session requesterSession) + throws IOException { + + LOGGER.info("Integration Test - Creating non-RDF using a templated Access Grant"); + + final URI newTestFileURI = URIBuilder.newBuilder(privateContainerURI) + .path("newFile-accessGrantCreateNonRdfTest2.txt") + .build(); + + final String template = "http://{domain}/{path}/newFile-accessGrantCreateNonRdfTest2.txt"; + + final SolidSyncClient resourceOwnerClient = SolidSyncClient.getClientBuilder() + .build().session(resourceOwnerSession); + + final AccessGrantClient requesterAccessGrantClient = new AccessGrantClient( + URI.create(ACCESS_GRANT_PROVIDER) + ).session(requesterSession); + + final Set modes = Set.of(GRANT_MODE_READ, GRANT_MODE_WRITE, GRANT_MODE_APPEND); + final Instant expiration = Instant.parse(GRANT_EXPIRATION); + final var request = AccessRequest.RequestParameters.newBuilder() + .template(template) + .modes(modes) + .purposes(PURPOSES) + .expiration(expiration) + .build(); + + final AccessRequest accessRequest = requesterAccessGrantClient.requestAccess(request) + .exceptionally(ex -> null) + .toCompletableFuture().join(); + + if (accessRequest == null) { + LOGGER.info("Templated Access Requests not supported"); + return; + } + + final AccessGrantClient resourceOwnerAccessGrantClient = new AccessGrantClient( + URI.create(ACCESS_GRANT_PROVIDER) + ).session(resourceOwnerSession); + final AccessGrant grant = resourceOwnerAccessGrantClient.grantAccess(accessRequest, templates -> + templates.stream().map(t -> t + .replace("{domain}", privateContainerURI.getHost()) + .replace("{path}", privateContainerURI.getPath().replaceAll("^/", "").replaceAll("/$", ""))) + .map(URI::create) + .collect(Collectors.toSet())) + .toCompletableFuture().join(); + + final Session newSession = AccessGrantSession.ofAccessGrant(requesterSession, grant); + final SolidSyncClient requesterAuthClient = SolidSyncClient.getClient().session(newSession); + + try (final InputStream is = new ByteArrayInputStream( + StandardCharsets.UTF_8.encode("Test test test text").array())) { + final SolidNonRDFSource testResource = + new SolidNonRDFSource(newTestFileURI, Utils.PLAIN_TEXT, is); + assertDoesNotThrow(() -> requesterAuthClient.create(testResource)); + } + + resourceOwnerClient.delete(newTestFileURI); + assertDoesNotThrow(resourceOwnerAccessGrantClient.revoke(grant).toCompletableFuture()::join); + } + private static void prepareAcpOfResource(final SolidSyncClient authClient, final URI resourceURI, final Class clazz) {