Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,7 @@ public enum Privilege {
CREATE_SHARE,
CREATE_RECIPIENT,
/** Lists and reads every share, not only the caller's own. */
USE_SHARE
USE_SHARE,
/** Lists and reads every recipient, not only the caller's own. */
USE_RECIPIENT
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

import io.opensharing.ObjectNames;
import io.opensharing.auth.AuthContext;
import io.opensharing.auth.Ownership;
import io.opensharing.auth.Privilege;
import io.opensharing.auth.TokenHashes;
import io.opensharing.auth.UserContext;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.exception.CatalogAuthorizationException;
import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
import io.opensharing.http.Pagination;
Expand All @@ -19,9 +21,10 @@

/**
* Provider recipient CRUD and token issuance for a caller the host has authenticated. Creating a
* recipient needs CREATE_RECIPIENT from the catalog, and only the owner may change it. Only the
* owner and callers with USE_RECIPIENT, who may read anyone's recipients, are meant to list and get
* recipients. {@link #activate} is public: the activation code itself is the credential.
* recipient needs CREATE_RECIPIENT from the catalog, and only the owner may change it. The owner
* and callers with USE_RECIPIENT from the catalog may read a recipient; listing without
* USE_RECIPIENT returns only the caller's own recipients. {@link #activate} is public: the
* activation code itself is the credential.
*
* <p>{@code activationBaseUrl} is the absolute URL recipient activation links are built under, such
* as {@code https://host/api/1.0/opensharing/activations}; the host knows its own address.
Expand Down Expand Up @@ -72,18 +75,26 @@ public RecipientResponse create(
return toResponse(recipient, activationBaseUrl);
}

/** Lists one page of recipients by name. */
/**
* Lists one page of recipients by name: every recipient with USE_RECIPIENT, otherwise the
* caller's own.
*/
public ListResponse<RecipientResponse> list(
Integer maxResults, String pageToken, String activationBaseUrl) {
// TODO: introduce USE_RECIPIENT and list only the caller's recipients unless they hold it.
return Pagination.page(
maxResults, pageToken, recipients::list, r -> toResponse(r, activationBaseUrl));
UserContext user, Integer maxResults, String pageToken, String activationBaseUrl) {
Pagination.Slice<RecipientEntity> slice =
holds(user, Privilege.USE_RECIPIENT)
? recipients::list
: (offset, limit) -> recipients.listOwnedBy(user.userId(), offset, limit);
return Pagination.page(maxResults, pageToken, slice, r -> toResponse(r, activationBaseUrl));
}

/** Gets a recipient by name in any case. */
public RecipientResponse get(String recipient, String activationBaseUrl) {
// TODO: introduce USE_RECIPIENT and get only the caller's recipient unless they hold it.
return toResponse(recipients.getOrThrow(recipient), activationBaseUrl);
/** Gets a recipient by name in any case. Owner or USE_RECIPIENT. */
public RecipientResponse get(UserContext user, String recipient, String activationBaseUrl) {
RecipientEntity entity = recipients.getOrThrow(recipient);
if (!Ownership.owns(user, entity.getOwnerId())) {
catalog.authorize(AuthContext.of(user), Privilege.USE_RECIPIENT);
}
return toResponse(entity, activationBaseUrl);
}

/** Updates the fields set in {@code request}. Owner only. */
Expand Down Expand Up @@ -141,6 +152,15 @@ private static Instant expiresAt(Long days, Instant now) {
return now.plus(Duration.ofDays(days));
}

private boolean holds(UserContext user, Privilege privilege) {
try {
catalog.authorize(AuthContext.of(user), privilege);
return true;
} catch (CatalogAuthorizationException e) {
return false;
}
}

private RecipientResponse toResponse(RecipientEntity recipient, String activationBaseUrl) {
String code = recipients.findActivationCode(recipient);
return RecipientResponse.from(recipient, code == null ? null : activationBaseUrl + "/" + code);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,20 @@ public List<RecipientEntity> list(int offset, int limit) {
.getResultList());
}

/** Recipients owned by {@code ownerId}, ordered by name. */
public List<RecipientEntity> listOwnedBy(String ownerId, int offset, int limit) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select r from RecipientEntity r where r.ownerId = :ownerId order by r.name",
RecipientEntity.class)
.setParameter("ownerId", ownerId)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList());
}

/** Deletes a recipient, its tokens and its permissions. Only the owner may delete it. */
public void delete(String name, UserContext user) {
tx.inTransaction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@

/**
* Provider HTTP API for recipient CRUD, behind {@link ProviderTokenDecorator}. Only the owner may
* change a recipient. Only the owner and callers with USE_RECIPIENT, who may read anyone's
* recipients, are meant to list and get recipients.
* change a recipient. The owner and callers with USE_RECIPIENT may read it; listing without
* USE_RECIPIENT returns only the caller's own recipients.
*/
@Blocking
@ProducesJson
Expand Down Expand Up @@ -46,17 +46,26 @@ public RecipientResponse create(ServiceRequestContext ctx, CreateRecipientReques
return recipients.create(ProviderTokenDecorator.user(ctx), request, activationBaseUrl);
}

/** {@code GET /recipients}: lists one page of recipients by name. */
/**
* {@code GET /recipients}: lists one page of recipients by name: every recipient with
* USE_RECIPIENT, otherwise the caller's own.
*/
@Get("/recipients")
public ListResponse<RecipientResponse> list(
@Param @Nullable Integer maxResults, @Param @Nullable String pageToken) {
return recipients.list(maxResults, pageToken, activationBaseUrl);
ServiceRequestContext ctx,
@Param @Nullable Integer maxResults,
@Param @Nullable String pageToken) {
return recipients.list(
ProviderTokenDecorator.user(ctx), maxResults, pageToken, activationBaseUrl);
}

/** {@code GET /recipients/{recipient}}: gets a recipient by name in any case. */
/**
* {@code GET /recipients/{recipient}}: gets a recipient by name in any case. Owner or
* USE_RECIPIENT.
*/
@Get("/recipients/{recipient}")
public RecipientResponse get(@Param String recipient) {
return recipients.get(recipient, activationBaseUrl);
public RecipientResponse get(ServiceRequestContext ctx, @Param String recipient) {
return recipients.get(ProviderTokenDecorator.user(ctx), recipient, activationBaseUrl);
}

/** {@code PATCH /recipients/{recipient}}: updates the fields set in the body. Owner only. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ principals:
- bearerToken: alice-token
userId: catalog-alice-id
userName: alice
privileges: [CREATE_SHARE, CREATE_RECIPIENT, USE_SHARE]
privileges: [CREATE_SHARE, CREATE_RECIPIENT, USE_SHARE, USE_RECIPIENT]
- bearerToken: bob-token
userId: catalog-bob-id
userName: bob
privileges: [CREATE_SHARE, CREATE_RECIPIENT, USE_SHARE]
privileges: [CREATE_SHARE, CREATE_RECIPIENT, USE_SHARE, USE_RECIPIENT]
- bearerToken: carol-token
userId: catalog-carol-id
userName: carol
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package io.opensharing.recipient;

import static org.junit.jupiter.api.Assertions.assertEquals;

import io.opensharing.TestServer;
import io.opensharing.TestServer.Response;
import io.opensharing.http.ErrorCodes;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

/** In the local catalog, alice and bob hold USE_RECIPIENT and carol does not. */
class RecipientReadPrivilegeTest {

private static final String RECIPIENTS = "/api/1.0/opensharing/provider/recipients";

private static TestServer server;

@BeforeAll
static void startServer() {
server = TestServer.start();
create("alice-token", "alice_recipient");
create("carol-token", "carol_recipient");
}

@AfterAll
static void stopServer() {
server.close();
}

@Test
void listsOnlyOwnRecipientsWithoutUseRecipient() {
assertEquals(List.of("carol_recipient"), names(server.get(RECIPIENTS, "carol-token")));
assertEquals(
List.of("alice_recipient", "carol_recipient"), names(server.get(RECIPIENTS, "bob-token")));
}

@Test
void readsOthersRecipientsOnlyWithUseRecipient() {
assertEquals(200, server.get(RECIPIENTS + "/carol_recipient", "carol-token").status());
assertEquals(200, server.get(RECIPIENTS + "/carol_recipient", "bob-token").status());

Response denied = server.get(RECIPIENTS + "/alice_recipient", "carol-token");
assertEquals(403, denied.status());
assertEquals(ErrorCodes.PERMISSION_DENIED, denied.errorCode());
}

private static void create(String token, String name) {
Response created =
server.post(
RECIPIENTS,
token,
"{\"name\":\""
+ name
+ "\",\"authenticationType\":\"TOKEN\",\"tokenExpirationDays\":30}");
assertEquals(201, created.status());
}

private static List<String> names(Response response) {
assertEquals(200, response.status());
List<String> names = new ArrayList<>();
response.body().path("items").forEach(item -> names.add(item.path("name").asText()));
return names;
}
}
Loading