Skip to content
Open
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
@@ -0,0 +1,12 @@
package io.opensharing.asset.share;

import io.opensharing.share.ShareResponse;

/**
* Protocol {@code GET /shares/{share}} wraps the share in a single field:
*
* <pre>{@code
* {"share": {"id": "<share id>", "name": "<share name>", ...}}
* }</pre>
*/
public record GetShareResponse(ShareResponse share) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.opensharing.asset.share;

import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
import io.opensharing.http.Pagination;
import io.opensharing.recipient.RecipientPrincipal;
import io.opensharing.share.SharePermissionStore;
import io.opensharing.share.ShareResponse;
import io.opensharing.share.ShareStore;

/**
* The shares a recipient sees through the sharing protocol: those it holds SELECT on. Any other
* share is reported as missing, so recipients cannot learn which shares exist.
*/
public class ProtocolShares {

private final ShareStore shares;
private final SharePermissionStore permissions;

public ProtocolShares(ShareStore shares, SharePermissionStore permissions) {
this.shares = shares;
this.permissions = permissions;
}

/** Lists one page of the recipient's shares by name. */
public ListResponse<ShareResponse> list(
RecipientPrincipal recipient, Integer maxResults, String pageToken) {
return Pagination.protocolPage(
maxResults,
pageToken,
(offset, limit) -> permissions.listSharesFor(recipient.recipientId(), offset, limit),
ShareResponse::from);
}

/** Gets one of the recipient's shares by name in any case. */
public GetShareResponse get(RecipientPrincipal recipient, String share) {
return shares
.find(share)
.filter(candidate -> permissions.hasSelect(candidate, recipient.recipientId()))
.map(granted -> new GetShareResponse(ShareResponse.from(granted)))
.orElseThrow(() -> ApiException.notFound("share '" + share + "' does not exist"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ public static <E, T> ListResponse<T> page(
return new ListResponse<>(items, rows.size() > size ? encode(offset + size) : null);
}

/**
* Like {@link #page}, but a {@code maxResults} of 0 returns no items, with a next page token when
* any remain, as the sharing protocol specifies.
*/
public static <E, T> ListResponse<T> protocolPage(
Integer maxResults, String pageToken, Slice<E> slice, Function<E, T> mapper) {
if (maxResults == null || maxResults != 0) {
return page(maxResults, pageToken, slice, mapper);
}
int offset = offsetOf(pageToken);
return new ListResponse<>(List.of(), slice.fetch(offset, 1).isEmpty() ? null : encode(offset));
}

private static int pageSize(Integer maxResults) {
if (maxResults == null || maxResults == 0) {
return MAX_RESULTS;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import io.opensharing.recipient.RecipientService;

/**
* Public one-time redemption of a recipient activation URL. Hosts mount it without their own
* authentication: the activation code itself is the credential.
* Public one-time redemption of a recipient activation URL. {@link OpenSharingRecipientApi} mounts
* it without authentication: the activation code itself is the credential.
*/
@Blocking
public final class ActivationService {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.opensharing.protocol;

import com.linecorp.armeria.common.annotation.Nullable;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.annotation.Blocking;
import com.linecorp.armeria.server.annotation.Get;
import com.linecorp.armeria.server.annotation.Param;
import com.linecorp.armeria.server.annotation.ProducesJson;
import io.opensharing.asset.share.GetShareResponse;
import io.opensharing.asset.share.ProtocolShares;
import io.opensharing.http.ListResponse;
import io.opensharing.share.ShareResponse;

/**
* The Delta Sharing protocol API: every endpoint recipients call with their bearer token, served
* behind {@link RecipientTokenDecorator}.
*/
@Blocking
@ProducesJson
public final class OpenSharingProtocolService {

private final ProtocolShares shares;

public OpenSharingProtocolService(ProtocolShares shares) {
this.shares = shares;
}

/** {@code GET /shares}: lists one page of the shares the recipient holds SELECT on. */
@Get("/shares")
public ListResponse<ShareResponse> listShares(
ServiceRequestContext ctx,
@Param @Nullable Integer maxResults,
@Param @Nullable String pageToken) {
return shares.list(RecipientTokenDecorator.recipient(ctx), maxResults, pageToken);
}

/** {@code GET /shares/{share}}: gets a share the recipient holds SELECT on. */
@Get("/shares/{share}")
public GetShareResponse getShare(ServiceRequestContext ctx, @Param String share) {
return shares.get(RecipientTokenDecorator.recipient(ctx), share);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package io.opensharing.protocol;

import com.linecorp.armeria.server.ServerBuilder;
import com.linecorp.armeria.server.annotation.JacksonRequestConverterFunction;
import com.linecorp.armeria.server.annotation.JacksonResponseConverterFunction;
import io.opensharing.http.ApiExceptionHandler;
import io.opensharing.http.ApiJson;
import io.opensharing.runtime.OpenSharing;

/**
* The recipient-facing endpoints, which OpenSharing authenticates itself. A host mounts them on
* its Armeria server next to its own services.
*/
public final class OpenSharingRecipientApi {

private OpenSharingRecipientApi() {}

/**
* Serves the sharing protocol under {@code protocolBasePath}, behind recipient bearer tokens, and
* recipient activation under {@code activationBasePath}, behind none. Both bring their own JSON
* converters and error handling, which take precedence over the host's server-wide ones.
*
* @param publicUrl the address recipients reach the host at, such as {@code
* https://sharing.example.com}, which profile files point recipients to
*/
public static void mount(
ServerBuilder builder,
String publicUrl,
String protocolBasePath,
String activationBasePath,
OpenSharing openSharing) {
ApiExceptionHandler errors = new ApiExceptionHandler();
// The token check must sit inside the error decorator, so that its 401s get the protocol body.
builder
.annotatedService()
.pathPrefix(protocolBasePath)
.requestConverters(new JacksonRequestConverterFunction(ApiJson.MAPPER))
.responseConverters(new JacksonResponseConverterFunction(ApiJson.MAPPER))
.exceptionHandlers(errors)
.decorator(new RecipientTokenDecorator(openSharing.recipients()))
.decorator(errors)
.build(new OpenSharingProtocolService(openSharing.protocolShares()));
builder
.annotatedService()
.pathPrefix(activationBasePath)
.exceptionHandlers(errors)
.decorator(errors)
.build(new ActivationService(openSharing.recipients(), publicUrl + protocolBasePath));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package io.opensharing.protocol;

import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.auth.OAuth2Token;
import com.linecorp.armeria.server.DecoratingHttpServiceFunction;
import com.linecorp.armeria.server.HttpService;
import com.linecorp.armeria.server.ServiceRequestContext;
import com.linecorp.armeria.server.auth.AuthTokenExtractors;
import io.netty.util.AttributeKey;
import io.opensharing.http.ApiException;
import io.opensharing.recipient.RecipientPrincipal;
import io.opensharing.recipient.RecipientService;

/**
* Authenticates a sharing protocol request by its recipient bearer token; services read the
* recipient with {@link #recipient}. Missing, unknown, unactivated and expired tokens get a 401.
*/
public final class RecipientTokenDecorator implements DecoratingHttpServiceFunction {

private static final AttributeKey<RecipientPrincipal> RECIPIENT =
AttributeKey.valueOf(RecipientTokenDecorator.class, "recipient");

private final RecipientService recipients;

public RecipientTokenDecorator(RecipientService recipients) {
this.recipients = recipients;
}

/** Serves the request as the recipient its bearer token belongs to. */
@Override
public HttpResponse serve(HttpService delegate, ServiceRequestContext ctx, HttpRequest req) {
OAuth2Token token = AuthTokenExtractors.oAuth2().apply(req.headers());
if (token == null || token.accessToken().isBlank()) {
throw ApiException.unauthenticated("bearer token is required");
}
// The token lookup queries the database, which must not block the event loop.
return HttpResponse.of(
() -> {
try {
ctx.setAttr(RECIPIENT, recipients.authenticate(token.accessToken()));
return delegate.serve(ctx, req);
} catch (Exception e) {
return HttpResponse.ofFailure(e);
}
},
ctx.blockingTaskExecutor());
}

/**
* Returns the recipient this decorator authenticated for the request. Only services mounted
* behind this decorator may call it; anywhere else there is no recipient, so it fails with a 401
* rather than letting the service run anonymously.
*/
public static RecipientPrincipal recipient(ServiceRequestContext ctx) {
RecipientPrincipal recipient = ctx.attr(RECIPIENT);
if (recipient == null) {
throw ApiException.unauthenticated("this endpoint requires an authenticated recipient");
}
return recipient;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.opensharing.recipient;

/** The recipient a sharing protocol request's bearer token belongs to. */
public record RecipientPrincipal(String recipientId) {}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
* 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.
* activation code itself is the credential. {@link #authenticate} checks the bearer tokens
* recipients then present.
*
* <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 @@ -142,6 +143,17 @@ public ProfileFile activate(String code, String endpoint) {
SHARE_CREDENTIALS_VERSION, bearer, endpoint, EXPIRATION_TIME.format(token.getExpiresAt()));
}

/**
* Resolves a sharing protocol bearer token to its recipient. Fails with unauthenticated unless
* the token is activated and unexpired.
*/
public RecipientPrincipal authenticate(String bearer) {
return recipients
.findUsableToken(TokenHashes.sha256(bearer), Instant.now())
.map(token -> new RecipientPrincipal(token.getRecipient().getId()))
.orElseThrow(() -> ApiException.unauthenticated("bearer token is invalid or expired"));
}

private static Instant expiresAt(Long days, Instant now) {
if (days == null) {
throw ApiException.invalidParameter("tokenExpirationDays is required");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,20 @@ public RecipientTokenEntity activate(String activationCode, String tokenHash, In
});
}

/** The token with {@code tokenHash}, when it is activated and unexpired at {@code now}. */
public Optional<RecipientTokenEntity> findUsableToken(String tokenHash, Instant now) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select t from RecipientTokenEntity t where t.tokenHash = :hash",
RecipientTokenEntity.class)
.setParameter("hash", tokenHash)
.getResultStream()
.findFirst()
.filter(token -> token.isActivated() && token.getExpiresAt().isAfter(now)));
}

/**
* Supersedes live credentials of the recipient and persists a pending replacement. An
* unactivated credential or a zero grace window expires immediately because no recipient should
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import io.opensharing.Transactions;
import io.opensharing.asset.SharedDataObjectEntity;
import io.opensharing.asset.SharedDataObjectStore;
import io.opensharing.asset.share.ProtocolShares;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.recipient.RecipientEntity;
import io.opensharing.recipient.RecipientService;
Expand All @@ -25,19 +26,23 @@ public final class OpenSharing {
private final CatalogConnector catalog;
private final ShareService shares;
private final RecipientService recipients;
private final ProtocolShares protocolShares;

private OpenSharing(CatalogConnector catalog, Transactions tx) {
this.catalog = catalog;
ShareStore shareStore = new ShareStore(tx);
SharePermissionStore permissionStore = new SharePermissionStore(tx);
RecipientStore recipientStore = new RecipientStore(tx);
this.shares =
new ShareService(
catalog,
tx,
new ShareStore(tx),
shareStore,
new SharedDataObjectStore(tx),
new SharePermissionStore(tx),
permissionStore,
recipientStore);
this.recipients = new RecipientService(catalog, recipientStore);
this.protocolShares = new ProtocolShares(shareStore, permissionStore);
}

public static Builder builder() {
Expand Down Expand Up @@ -66,6 +71,11 @@ public RecipientService recipients() {
return recipients;
}

/** The shares recipients see through the sharing protocol. */
public ProtocolShares protocolShares() {
return protocolShares;
}

public static final class Builder {

private CatalogConnector catalog;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,43 @@ public List<SharePermissionEntity> list(ShareEntity share, int offset, int limit
.getResultList());
}

/**
* Shares the recipient holds SELECT on, ordered by name. Share names are unique, so the order
* is total.
*/
public List<ShareEntity> listSharesFor(String recipientId, int offset, int limit) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select s from SharePermissionEntity p join p.share s"
+ " where p.recipient.id = :recipientId and p.privilege = :privilege"
+ " order by s.name",
ShareEntity.class)
.setParameter("recipientId", recipientId)
.setParameter("privilege", SharePrivilege.SELECT)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList());
}

/** Whether the recipient holds SELECT on {@code share}. */
public boolean hasSelect(ShareEntity share, String recipientId) {
return tx.inTransaction(
true,
em ->
!em.createQuery(
"select p.id from SharePermissionEntity p where p.share.id = :shareId"
+ " and p.recipient.id = :recipientId and p.privilege = :privilege",
String.class)
.setParameter("shareId", share.getId())
.setParameter("recipientId", recipientId)
.setParameter("privilege", SharePrivilege.SELECT)
.setMaxResults(1)
.getResultList()
.isEmpty());
}

private static Optional<SharePermissionEntity> find(
EntityManager em, ShareEntity share, RecipientEntity recipient, SharePrivilege privilege) {
return em.createQuery(
Expand Down
Loading
Loading