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
@@ -0,0 +1,6 @@
package io.opensharing.recipient;

/** How a recipient authenticates to this server. */
public enum AuthenticationType {
TOKEN
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package io.opensharing.recipient;

/**
* POST body to create a recipient. The authenticated caller becomes the owner. Only {@code TOKEN}
* authentication is accepted for now.
*/
public record CreateRecipientRequest(
String name, String comment, AuthenticationType authenticationType) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.opensharing.recipient;

import io.opensharing.BaseEntity;
import io.opensharing.ObjectNames;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

/**
* Persisted recipient row: someone a provider shares data with. {@code name} is always stored
* lowercase. Credentials live in {@link RecipientTokenEntity}.
*/
@Entity
@Table(
name = "os_recipients",
uniqueConstraints = @UniqueConstraint(name = "uk_recipients_name", columnNames = "name"))
public class RecipientEntity extends BaseEntity {

@Column(nullable = false, length = 255)
private String name;

@Column(length = 8192)
private String comment;

/** userId of whoever created this recipient. Not a foreign key: there is no principal table. */
@Column(name = "owner_id", nullable = false, length = 255)
private String ownerId;

/** How the recipient authenticates to the sharing protocol. */
@Enumerated(EnumType.STRING)
@Column(name = "authentication_type", nullable = false, length = 32)
private AuthenticationType authenticationType;

public String getName() {
return name;
}

public void setName(String name) {
this.name = ObjectNames.normalize(name);
}

public String getComment() {
return comment;
}

public void setComment(String comment) {
this.comment = comment;
}

public String getOwnerId() {
return ownerId;
}

public void setOwnerId(String ownerId) {
this.ownerId = ownerId;
}

public AuthenticationType getAuthenticationType() {
return authenticationType;
}

public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.opensharing.recipient;

import com.fasterxml.jackson.annotation.JsonInclude;

/**
* A recipient as the provider API reports it. {@code activationUrl} is the link the provider sends
* to the recipient; omitted when the recipient has no activation code.
*
* <pre>{@code
* {
* "id": "<recipient id>",
* "name": "<recipient name>",
* "comment": "<comment>",
* "authenticationType": "TOKEN",
* "activationUrl": "<activation URL>"
* }
* }</pre>
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record RecipientResponse(
String id,
String name,
String comment,
AuthenticationType authenticationType,
String activationUrl) {

public static RecipientResponse from(RecipientEntity recipient, String activationUrl) {
return new RecipientResponse(
recipient.getId(),
recipient.getName(),
recipient.getComment(),
recipient.getAuthenticationType(),
activationUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package io.opensharing.recipient;

import io.opensharing.ObjectNames;
import io.opensharing.auth.AuthContext;
import io.opensharing.auth.Privilege;
import io.opensharing.auth.UserContext;
import io.opensharing.catalog.CatalogConnector;
import io.opensharing.http.ApiException;
import io.opensharing.http.ListResponse;
import java.util.UUID;

/**
* Provider recipient CRUD 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.
*
* <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.
*/
public class RecipientService {

private final CatalogConnector catalog;
private final RecipientStore recipients;

public RecipientService(CatalogConnector catalog, RecipientStore recipients) {
this.catalog = catalog;
this.recipients = recipients;
}

/**
* Creates a recipient owned by the caller, who needs CREATE_RECIPIENT from the catalog, with a
* new activation code.
*/
public RecipientResponse create(
UserContext user, CreateRecipientRequest request, String activationBaseUrl) {
// The host has already authenticated the caller. A host that also checks CREATE_RECIPIENT at
// its endpoint can make this a no-op.
catalog.authorize(AuthContext.of(user), Privilege.CREATE_RECIPIENT);
if (request.authenticationType() == null) {
throw ApiException.invalidParameter("authenticationType is required");
}
RecipientEntity recipient =
recipients.create(
user,
ObjectNames.validateRecipientName(request.name()),
request.comment(),
request.authenticationType(),
UUID.randomUUID().toString());
return toResponse(recipient, activationBaseUrl);
}

/** Lists every recipient by name, unpaged. */
public ListResponse<RecipientResponse> list(String activationBaseUrl) {
// TODO: page with maxResults and pageToken.
// TODO: introduce USE_RECIPIENT and list only the caller's recipients unless they hold it.
return ListResponse.of(
recipients.list().stream().map(r -> toResponse(r, activationBaseUrl)).toList());
}

/** 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);
}

/** Updates the fields set in {@code request}. Owner only. */
public RecipientResponse update(
UserContext user,
String recipient,
UpdateRecipientRequest request,
String activationBaseUrl) {
return toResponse(recipients.update(user, recipient, request.comment()), activationBaseUrl);
}

/** Deletes the recipient and its tokens. Owner only. */
public void delete(UserContext user, String recipient) {
recipients.delete(recipient, user);
}

private RecipientResponse toResponse(RecipientEntity recipient, String activationBaseUrl) {
String code = recipients.findActivationCode(recipient);
return RecipientResponse.from(recipient, code == null ? null : activationBaseUrl + "/" + code);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package io.opensharing.recipient;

import io.opensharing.ObjectNames;
import io.opensharing.Transactions;
import io.opensharing.auth.Ownership;
import io.opensharing.auth.UserContext;
import io.opensharing.http.ApiException;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import java.util.List;
import java.util.Optional;

/**
* Storage for recipients and their tokens. Names are stored lowercase and looked up
* case-insensitively. Each method runs in its own transaction, so returned entities are detached.
*/
public class RecipientStore {

private final Transactions tx;

public RecipientStore(Transactions tx) {
this.tx = tx;
}

/**
* Creates a recipient owned by {@code author} and its first token holding {@code
* activationCode}. {@code name} must already be validated and lowercase. Fails with
* already-exists when the name is taken.
*/
public RecipientEntity create(
UserContext author,
String name,
String comment,
AuthenticationType authenticationType,
String activationCode) {
return tx.inTransaction(
false,
em -> {
if (find(em, name, LockModeType.NONE).isPresent()) {
throw ApiException.alreadyExists("recipient '" + name + "' already exists");
}
RecipientEntity recipient = new RecipientEntity();
recipient.setName(name);
recipient.setComment(comment);
recipient.setOwnerId(author.userId());
recipient.setAuthenticationType(authenticationType);
em.persist(recipient);
RecipientTokenEntity token = new RecipientTokenEntity();
token.setRecipient(recipient);
token.setActivationCode(activationCode);
em.persist(token);
return recipient;
});
}

/** Only non-null fields are applied. Only the owner may update the recipient. */
public RecipientEntity update(UserContext user, String name, String comment) {
return tx.inTransaction(
false,
em -> {
RecipientEntity recipient = getOwnedOrThrow(em, name, user);
if (comment != null) {
recipient.setComment(comment);
}
return recipient;
});
}

/** Looks up a recipient by name in any case. Fails with not-found when it does not exist. */
public RecipientEntity getOrThrow(String name) {
return tx.inTransaction(true, em -> getOrThrow(em, name, LockModeType.NONE));
}

/** The newest token's activation code, or null when there is none. */
public String findActivationCode(RecipientEntity recipient) {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select t.activationCode from RecipientTokenEntity t"
+ " where t.recipient.id = :recipientId order by t.createdAt desc",
String.class)
.setParameter("recipientId", recipient.getId())
.setMaxResults(1)
.getResultStream()
.findFirst()
.orElse(null));
}

/** Every recipient, regardless of owner, ordered by name. */
public List<RecipientEntity> list() {
return tx.inTransaction(
true,
em ->
em.createQuery(
"select r from RecipientEntity r order by r.name", RecipientEntity.class)
.getResultList());
}

/** Deletes a recipient and its tokens. Only the owner may delete it. */
public void delete(String name, UserContext user) {
tx.inTransaction(
false,
em -> {
RecipientEntity recipient = getOwnedOrThrow(em, name, user);
em.createQuery("delete from RecipientTokenEntity t where t.recipient.id = :recipientId")
.setParameter("recipientId", recipient.getId())
.executeUpdate();
em.remove(recipient);
return null;
});
}

private static Optional<RecipientEntity> find(EntityManager em, String name, LockModeType lock) {
return em.createQuery(
"select r from RecipientEntity r where r.name = :name", RecipientEntity.class)
.setParameter("name", ObjectNames.normalize(name))
.setLockMode(lock)
.getResultStream()
.findFirst();
}

private static RecipientEntity getOrThrow(EntityManager em, String name, LockModeType lock) {
return find(em, name, lock)
.orElseThrow(() -> ApiException.notFound("recipient '" + name + "' does not exist"));
}

/**
* Locks the row of a recipient the caller owns, so changes to one recipient run one at a time and
* each sees what the one before it wrote.
*/
private static RecipientEntity getOwnedOrThrow(EntityManager em, String name, UserContext user) {
RecipientEntity recipient = getOrThrow(em, name, LockModeType.PESSIMISTIC_WRITE);
Ownership.check(user, recipient.getOwnerId(), "recipient '" + recipient.getName() + "'");
return recipient;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package io.opensharing.recipient;

import io.opensharing.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;

/** A token issued to a recipient. {@code activationCode} is stored plaintext until redeemed. */
@Entity
@Table(
name = "os_recipient_tokens",
uniqueConstraints =
@UniqueConstraint(
name = "uk_recipient_tokens_activation_code",
columnNames = "activation_code"))
public class RecipientTokenEntity extends BaseEntity {

/** The recipient the token belongs to. {@code RecipientStore} deletes tokens with it. */
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "recipient_id", nullable = false)
private RecipientEntity recipient;

/** One-time code embedded in the activation URL. */
@Column(name = "activation_code", unique = true, length = 36)
private String activationCode;

public RecipientEntity getRecipient() {
return recipient;
}

public void setRecipient(RecipientEntity recipient) {
this.recipient = recipient;
}

public String getActivationCode() {
return activationCode;
}

public void setActivationCode(String activationCode) {
this.activationCode = activationCode;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package io.opensharing.recipient;

/** PATCH body for recipient metadata. Null fields are left unchanged. */
public record UpdateRecipientRequest(String comment) {}
Loading
Loading