diff --git a/datamanager-app/frontend/themes/datamanager/components/all.css b/datamanager-app/frontend/themes/datamanager/components/all.css index 57293f3ff..9cc6ae0ac 100644 --- a/datamanager-app/frontend/themes/datamanager/components/all.css +++ b/datamanager-app/frontend/themes/datamanager/components/all.css @@ -1540,3 +1540,113 @@ states, and modal-like overlays that sit inside a flex container. justify-content: center; } /*endregion*/ + +/* ── External Providers card styling ────── */ + +/* Give the page breathing room — the .main grid has no + inherent padding, so we add it here. */ +.external-providers { + padding: var(--lumo-space-l); +} + +.external-providers .external-providers__content { + max-width: 800px; +} + +.external-providers .instance-card { + display: flex; + flex-direction: column; + border-radius: var(--lumo-border-radius-m); + background-color: var(--lumo-base-color); + border: 1px solid var(--lumo-contrast-20pct); + border-left-width: 8px; + overflow: hidden; +} + +.external-providers .instance-card + .instance-card { + margin-top: var(--lumo-space-m); +} + +.external-providers .instance-card--connected { + border-left-color: var(--lumo-success-color); +} + +.external-providers .instance-card--not-connected { + border-left-color: var(--lumo-contrast-30pct); +} + +.external-providers .instance-card--invalidated { + border-left-color: var(--lumo-error-color); +} + +.external-providers .instance-card__header { + display: flex; + flex-direction: row; + align-items: flex-start; + justify-content: space-between; + gap: var(--lumo-space-m); + padding: var(--lumo-space-l) var(--lumo-space-l) 0 var(--lumo-space-l); +} + +.external-providers .instance-card__identity { + display: flex; + flex-direction: column; + gap: 2px; + flex-grow: 1; + min-width: 0; +} + +.external-providers .instance-card__action { + flex-shrink: 0; +} + +.external-providers .instance-card__url { + color: var(--lumo-primary-text-color); + text-decoration: none; +} + +.external-providers .instance-card__url:hover { + text-decoration: underline; +} + +.external-providers .instance-card__body { + display: flex; + flex-direction: column; + gap: var(--lumo-space-s); + padding: var(--lumo-space-m) var(--lumo-space-l) var(--lumo-space-l) var(--lumo-space-l); +} + +.external-providers .instance-card__status-line { + display: flex; + align-items: center; + gap: var(--lumo-space-s); +} + +.external-providers .instance-card__description { + color: var(--lumo-secondary-text-color); + font-size: var(--lumo-font-size-s); + line-height: 1.5; + margin: 0; +} + +.external-providers .security-note { + color: var(--lumo-tertiary-text-color); + font-size: var(--lumo-font-size-xs); + font-style: italic; +} + +/* Toolbar — right-aligned with heading */ + +.external-providers .external-providers__toolbar { + display: flex; + justify-content: flex-end; + padding: var(--lumo-space-m) 0 var(--lumo-space-l) 0; +} + +/* Button group for side-by-side Reconnect + Disconnect */ + +.external-providers .instance-card__action-group { + display: flex; + gap: var(--lumo-space-xs); + flex-shrink: 0; +} diff --git a/datamanager-app/frontend/themes/datamanager/components/custom.css b/datamanager-app/frontend/themes/datamanager/components/custom.css index 8c5e069f0..28e1e689c 100644 --- a/datamanager-app/frontend/themes/datamanager/components/custom.css +++ b/datamanager-app/frontend/themes/datamanager/components/custom.css @@ -22,6 +22,7 @@ @import "virtuallist.css"; @import "toast.css"; @import "grid-templates.css"; +@import "verification-sidebar.css"; vaadin-avatar-group vaadin-avatar, .user-avatar { diff --git a/datamanager-app/frontend/themes/datamanager/components/dialog.css b/datamanager-app/frontend/themes/datamanager/components/dialog.css index 408fa8c84..0a6b51aa9 100644 --- a/datamanager-app/frontend/themes/datamanager/components/dialog.css +++ b/datamanager-app/frontend/themes/datamanager/components/dialog.css @@ -867,3 +867,86 @@ vaadin-upload-file::part(meta){ border-radius: var(--lumo-border-radius-l); padding: var(--lumo-space-m); } + +/* ─ External Providers: Add Token dialog — loading overlay ─── + * + * The `position: absolute; inset: 0` positions the overlay to exactly + * fill the nearest positioned ancestor. We declare `.dialog-app { position: + * relative }` in all.css so the AppDialog body slot becomes that ancestor, + * meaning `inset: 0` covers the body area without any min-height hacks. + */ + +.token-loading-overlay { + position: absolute; + inset: 0; + background-color: var(--lumo-base-color); + z-index: 10; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--lumo-space-m); +} + +/* + * Pure-CSS four-dots rotating spinner (Spinner II, Temani Afif). + * A single grid div with two pseudo-elements; each carries four dots + * at the cardinal points. The outer ring (::after, larger dots, slower + * animation) and the inner ring (::before, smaller dots, margin-inset, + * linear timing) rotate at slightly different rates to produce the + * characteristic "four-dots orbit" effect. + * + * CSS variables allow theming from outside: + * --spinner-outer (default: Lumo primary color) + * --spinner-inner (default: secondary teal accent) + * + * Credit: https://codepen.io/t_afif/pen/yLMXBRL (Spinner #2) + */ +.token-loading-overlay .spinner { + width: 48px; + aspect-ratio: 1; + display: grid; + --spinner-outer: var(--lumo-primary-color); + --spinner-inner: #00acac; +} + +.token-loading-overlay .spinner::after { + content: ""; + grid-area: 1 / 1; + --c: radial-gradient(farthest-side, var(--spinner-outer) 92%, #0000); + background: + var(--c) 50% 0, + var(--c) 50% 100%, + var(--c) 100% 50%, + var(--c) 0 50%; + background-size: 12px 12px; + background-repeat: no-repeat; + animation: token-spin-half 1s infinite; +} + +.token-loading-overlay .spinner::before { + content: ""; + grid-area: 1 / 1; + margin: 4px; + --c: radial-gradient(farthest-side, var(--spinner-inner) 92%, #0000); + background: + var(--c) 50% 0, + var(--c) 50% 100%, + var(--c) 100% 50%, + var(--c) 0 50%; + background-size: 8px 8px; + background-repeat: no-repeat; + animation: token-spin-half 1s infinite linear; +} + +@keyframes token-spin-half { + 100% { transform: rotate(0.5turn); } +} + +.token-loading-overlay .token-loading-label { + font-weight: var(--font-weight-medium); +} + +.token-loading-overlay .token-loading-hint { + color: var(--lumo-tertiary-text-color); +} diff --git a/datamanager-app/frontend/themes/datamanager/components/verification-sidebar.css b/datamanager-app/frontend/themes/datamanager/components/verification-sidebar.css new file mode 100644 index 000000000..51628f089 --- /dev/null +++ b/datamanager-app/frontend/themes/datamanager/components/verification-sidebar.css @@ -0,0 +1,187 @@ +/* ── Verification Sidebar ────── */ + +.verification-sidebar { + position: fixed; + inset: 0; + z-index: 100; + pointer-events: none; +} + +/* Backdrop */ + +.verification-sidebar .vs-backdrop { + position: absolute; + inset: 0; + background-color: var(--lumo-shade-20pct); + opacity: 0; + pointer-events: none; +} + +.verification-sidebar.vs-open .vs-backdrop { + pointer-events: auto; + opacity: 1; +} + +/* Slide-in panel */ + +.verification-sidebar .vs-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 480px; + max-width: 90vw; + background-color: var(--lumo-base-color); + box-shadow: var(--lumo-box-shadow-l); + z-index: 2; + display: flex; + flex-direction: column; + pointer-events: auto; +} + +/* Layout: Header / Content / Footer */ + +.verification-sidebar .vs-body { + box-sizing: border-box; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.verification-sidebar .vs-header { + display: flex; + align-items: center; + gap: var(--lumo-space-s); + flex-shrink: 0; + padding: var(--lumo-space-m) var(--lumo-space-l); + border-bottom: 1px solid var(--lumo-contrast-10pct); +} + +.verification-sidebar .vs-title { + flex-grow: 1; +} + +.verification-sidebar .vs-content { + flex-grow: 1; + flex-direction: column; + display: flex; + overflow-y: auto; + padding: var(--lumo-space-l); + min-height: 0; + gap: var(--lumo-space-s); +} + +.verification-sidebar .vs-footer { + flex-shrink: 0; + border-top: 1px solid var(--lumo-contrast-10pct); + padding: var(--lumo-space-m) var(--lumo-space-l); +} + +/* Provider rows */ + +.verification-sidebar .vs-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--lumo-space-m); + padding: var(--lumo-space-s) var(--lumo-space-m); + border: 1px solid var(--lumo-contrast-10pct); + border-radius: var(--lumo-border-radius-m); + background-color: var(--lumo-base-color); +} + +.verification-sidebar .vs-row--valid { + border-left: 4px solid var(--lumo-success-color); +} + +.verification-sidebar .vs-row--invalid { + border-left: 4px solid var(--lumo-error-color); +} + +.verification-sidebar .vs-row--pending { + border-left: 4px solid var(--lumo-contrast-20pct); +} + +/* Skipped row — provider has no token; no validation is attempted. + Dashed left accent visually separates it from rows that actually + went through the verification pipeline. */ + +.verification-sidebar .vs-row--skipped { + border-left: 4px dashed var(--lumo-contrast-10pct); + background-color: var(--lumo-contrast-5pct); +} + +.verification-sidebar .vs-status--skipped { + color: var(--lumo-tertiary-text-color); + opacity: 0.7; +} + +/* Status-label variants — keep the message visually tied to its icon. */ + +.verification-sidebar .vs-status-label--pending { + color: var(--lumo-tertiary-text-color); +} + +.verification-sidebar .vs-status-label--valid { + color: var(--lumo-success-text-color); + font-weight: 500; +} + +.verification-sidebar .vs-status-label--invalid { + color: var(--lumo-error-text-color); + font-weight: 500; +} + +/* Reason shown below the provider name for invalid rows. */ + +.verification-sidebar .vs-invalid-reason { + display: block; + color: var(--lumo-tertiary-text-color); + font-size: var(--lumo-font-size-xs); + margin-top: 2px; +} + +.verification-sidebar .vs-row__identity { + display: flex; + flex-direction: column; + gap: 2px; + flex-grow: 1; + min-width: 0; +} + +.verification-sidebar .vs-row__name { + font-weight: 600; +} + +.verification-sidebar .vs-row__status { + display: flex; + align-items: center; + gap: var(--lumo-space-xs); +} + +.verification-sidebar .vs-spinner { + width: 1.2em; + height: 1.2em; + border: 2px solid var(--lumo-contrast-20pct); + border-top-color: var(--lumo-primary-color); + border-radius: 50%; + animation: vs-spin 0.8s linear infinite; +} + +@keyframes vs-spin { + to { transform: rotate(360deg); } +} + +.verification-sidebar .vs-row__actions { + display: flex; + gap: var(--lumo-space-xs); + flex-shrink: 0; +} + +/* Inline Reconnect link — tertiary, so it doesn't compete with + the Reconnect button on the main-view card. */ + +.verification-sidebar .vs-reconnect-link { + font-size: var(--lumo-font-size-s); +} diff --git a/datamanager-app/src/main/bundles/dev.bundle b/datamanager-app/src/main/bundles/dev.bundle index a814dc4c8..6f943b325 100644 Binary files a/datamanager-app/src/main/bundles/dev.bundle and b/datamanager-app/src/main/bundles/dev.bundle differ diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java index 36344e330..ddcea820d 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java @@ -1,18 +1,35 @@ package life.qbic.datamanager.configuration; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import life.qbic.projectmanagement.application.associated_dataset.CredentialEncryptor; import life.qbic.projectmanagement.application.associated_dataset.DatasetSource; +import life.qbic.projectmanagement.application.associated_dataset.DefaultExternalCredentialService; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialValidator; import life.qbic.projectmanagement.application.associated_dataset.SourceInstanceRegistry; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; +import life.qbic.projectmanagement.domain.model.associated_dataset.repository.UserExternalCredentialRepository; +import life.qbic.projectmanagement.infrastructure.DataManagerVault; +import life.qbic.projectmanagement.infrastructure.external.AesGcmCredentialEncryptor; +import life.qbic.projectmanagement.infrastructure.external.CredentialValidatorAdapter; +import life.qbic.projectmanagement.infrastructure.external.SourceTypeDispatchingCredentialValidator; import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmClient; import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmClient.InvenioRdmHttpClient; +import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmCredentialValidatorAdapter; import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmDatasetSource; import life.qbic.projectmanagement.infrastructure.external.invenio.InvenioRdmProperties; import life.qbic.projectmanagement.infrastructure.external.invenio.PropertiesBackedSourceInstanceRegistry; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; /** - * Wires up the InvenioRDM integration beans. + * Wires up the InvenioRDM integration and credential management beans. * *
Registers: *
Allows a user to view and manage their personal access tokens + * for external data source instances (e.g. InvenioRDM instances such + * as Zenodo and FDAT), so they can connect access-restricted datasets + * to their projects.
+ * + *Route: {@code /external-providers}, laid out with + * {@link UserMainLayout} alongside the user's profile and personal + * access token pages.
+ * + * @since 1.12.0 + */ +@Route(value = "external-providers", layout = UserMainLayout.class) +@SpringComponent +@UIScope +@PermitAll +public class ExternalProvidersMain extends Main + implements BeforeEnterObserver { + + @Serial + private static final long serialVersionUID = 6739821508412736524L; + private static final Logger log = LoggerFactory.logger( + ExternalProvidersMain.class); + + /** Date format for "since 15 Nov 2024" style. */ + private static final DateTimeFormatter DATE_FMT = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(Locale.ENGLISH); + + private final String SECONDARY_COLOR = + "var(--lumo-secondary-text-color)"; + + private final transient ExternalCredentialService credentialService; + private final transient AuthenticationToUserIdTranslationService userIdTranslator; + + private final UiHandle uiHandle = new UiHandle(); + + private final Div content = new Div(); + private final VerificationSidebar verificationSidebar; + + public ExternalProvidersMain( + ExternalCredentialService credentialService, + AuthenticationToUserIdTranslationService userIdTranslator) { + this.credentialService = requireNonNull(credentialService, + "credentialService must not be null"); + this.userIdTranslator = requireNonNull(userIdTranslator, + "userIdTranslator must not be null"); + + verificationSidebar = new VerificationSidebar( + credentialService, userIdTranslator); + verificationSidebar.addReconnectRequestListener(e -> + openAddTokenDialog(e.getInstanceId())); + // When the sidebar finishes a verification run, refresh the main + // page so any tokens that proved to be invalid now render as red + // INVALIDATED cards (with a Reconnect + Disconnect action). + verificationSidebar.addValidationsCompletedListener( + e -> renderContent()); + // When the sidebar closes (button, backdrop, programmatic) do a + // fresh status fetch. This catches the tail-end case where a + // validation ran while the drawer was open, the main view refreshed + // mid-flight, and the user then closed the drawer. + verificationSidebar.addSidebarClosedListener( + e -> renderContent()); + + addClassName("external-providers"); + content.addClassNames("external-providers__content"); + add(content, verificationSidebar); + } + + @Override + public void beforeEnter(BeforeEnterEvent event) { + renderContent(); + } + + // ─ Rendering ────────────────────────────────────────────────── + + private void renderContent() { + content.removeAll(); + + // ── Heading & benefit text (AC-6) ── + var heading = new H2("External Providers"); + heading.addClassNames("font-semibold", "text-size-l", "m-0"); + + var benefit = new Paragraph( + "Connect your personal access tokens to enable access to " + + "access-restricted datasets on external instances. Once " + + "connected, you can link restricted datasets from these " + + "instances to your Data Manager projects."); + benefit.addClassNames("text-contrast-70pct", "text-size-s", + "mt-xs", "mb-s"); + + // ─ Security reassurance ─ + var securityNote = new Span( + "Tokens are encrypted at rest and never shared with third " + + "parties. You can disconnect at any time."); + securityNote.addClassNames("security-note", "mb-m"); + + content.add(heading, benefit, securityNote); + + // ── Toolbar (Verify connections) ── + var toolbar = new Div(); + toolbar.addClassNames("external-providers__toolbar"); + var verifyButton = new Button("Verify connections", VaadinIcon.REFRESH.create()); + verifyButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + verifyButton.addClickListener(e -> { + // If sidebar is open, refresh; otherwise open it + if (verificationSidebar.getClassNames().contains("vs-open")) { + verificationSidebar.refresh(); + } else { + verificationSidebar.open(); + } + }); + toolbar.add(verifyButton); + content.add(toolbar); + + // ── Instance list (AC-1, AC-2) ── + ListWraps a {@link PasswordField} together with instructional text + * (instance name, encryption notice, link to the provider's token + * settings page). Implements {@link UserInput} so the surrounding + * {@link life.qbic.datamanager.views.general.dialog.AppDialog} drives + * validation automatically via {@link #validate()}.
+ * + *The token value is returned as a {@code char[]} via + * {@link #getToken()}. The service is responsible for zeroing the + * array after use (ADR-0002 D1).
+ * + *When the remote instance rejects the token, {@link #setError} + * displays the rejection message on the password field without + * clearing the user's input, so they can correct and retry.
+ * + * @since 1.12.0 + */ +class TokenInput extends Div implements UserInput { + + private final PasswordField passwordField; + + /** + * @param instanceDisplayName human-readable instance name (e.g. "Zenodo") + * @param tokenCreationUrl URL to the instance's token-settings page, + * or {@code null} when no URL can be derived + */ + TokenInput(String instanceDisplayName, String tokenCreationUrl) { + addClassName("token-input"); + + var description = new Paragraph( + "Paste your personal access token from your " + + instanceDisplayName + " account."); + description.addClassNames("text-contrast-70pct", "text-size-s"); + + passwordField = new PasswordField("Personal Access Token"); + passwordField.setPlaceholder("Paste token here"); + passwordField.setRequired(true); + passwordField.setWidthFull(); + + var helpLine = new Div(); + helpLine.addClassNames("text-size-xs", "text-contrast-60pct"); + helpLine.add(new Span( + "Your token is stored encrypted and used only to access " + + "your own restricted datasets. You can create one at: ")); + if (tokenCreationUrl != null && !"#".equals(tokenCreationUrl)) { + helpLine.add(new Anchor(tokenCreationUrl, + instanceDisplayName + " token settings", AnchorTarget.BLANK)); + } else { + helpLine.add(new Span( + "your " + instanceDisplayName + " account settings.")); + } + + add(description, passwordField, helpLine); + } + + /** + * Validates that the field is non-blank. On success, any previous + * error state is cleared so the field is ready for a retry. The + * field value is not cleared here — the confirm + * action extracts it via {@link #getToken()} before clearing. + */ + @Override + public InputValidation validate() { + String value = passwordField.getValue(); + if (value == null || value.isBlank()) { + passwordField.setErrorMessage("Token must not be empty."); + passwordField.setInvalid(true); + return InputValidation.failed(); + } + passwordField.setInvalid(false); + passwordField.setErrorMessage(""); + return InputValidation.passed(); + } + + @Override + public boolean hasChanges() { + String value = passwordField.getValue(); + return value != null && !value.isBlank(); + } + + /** + * Returns the current token value as a {@code char[]}. + * Returns an empty array when the field is blank. + */ + char[] getToken() { + String value = passwordField.getValue(); + if (value == null || value.isBlank()) { + return new char[0]; + } + return value.toCharArray(); + } + + /** + * Displays a service-side rejection message on the password field. + * The user's current input is preserved so they can correct and + * retry without re-pasting. + */ + void setError(String errorMessage) { + passwordField.setErrorMessage(errorMessage); + passwordField.setInvalid(true); + } + + /** Resets the field to its initial empty state. */ + void clearField() { + passwordField.clear(); + passwordField.setInvalid(false); + passwordField.setErrorMessage(""); + } +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/VerificationSidebar.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/VerificationSidebar.java new file mode 100644 index 000000000..210981f8a --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/VerificationSidebar.java @@ -0,0 +1,498 @@ +package life.qbic.datamanager.views.account; + +import com.vaadin.flow.component.ComponentEvent; +import com.vaadin.flow.component.ComponentEventListener; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.button.Button; +import com.vaadin.flow.component.button.ButtonVariant; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.VaadinIcon; +import com.vaadin.flow.shared.Registration; +import java.io.Serial; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import life.qbic.datamanager.views.UiHandle; +import life.qbic.projectmanagement.application.AuthenticationToUserIdTranslationService; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService.AddCredentialResult; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService.CredentialStatusView; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService.InvalidToken; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService.Success; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * Slide-in drawer that verifies all configured external provider connections. + * + *On open, it lists all available providers. Configured ones show a loading + * spinner ("Verifying…"); unconfigured ones immediately show a greyed-out + * "Not connected" row — no validation is attempted for them.
+ * + *Configured instances are then validated in parallel against their remote + * providers. Rows update as results arrive:
+ *Clicking "Reconnect" inside an invalid row fires a + * {@link ReconnectRequestEvent} handled by the parent view to open the + * token-input dialog. The sidebar stays open throughout.
+ * + *Matches the pattern used by + * {@link life.qbic.datamanager.views.projects.project.datasets.ConnectDatasetSidebar}.
+ * + * @since 1.13.0 + */ +public class VerificationSidebar extends Div { + + @Serial private static final long serialVersionUID = 1L; + + private final ExternalCredentialService credentialService; + private final AuthenticationToUserIdTranslationService userIdTranslator; + private final UiHandle uiHandle = new UiHandle(); + + private final Div overlay; + private final Div panel; + private final Div contentContainer; // Holds the rows + + /** Maps instanceId → row Div so results can update rows in place. */ + private final MapProvider-agnostic: the same encryptor handles tokens for InvenioRDM, + * LIMS, or any future external source. All share the same master key + * from the PKCS12 vault (ADR-0002 S2).
+ * + *Each encryption generates a fresh random 12-byte nonce. The nonce is + * prepended to the ciphertext so decryption can extract it.
+ * + *Output format: {@code nonce (12 bytes) || ciphertext (n bytes) || tag (16 bytes)}
+ * + * @since 1.12.0 + */ +public interface ExternalCredentialEncryptor { + + /** + * Encrypts a plaintext token. + * + * @param plaintext the plaintext token as char[] + * @return the encrypted blob (nonce + ciphertext + tag) + */ + byte[] encrypt(char[] plaintext); + + /** + * Decrypts an encrypted token. + * + * @param encrypted the encrypted blob (nonce + ciphertext + tag) + * @return the plaintext token as char[] — caller MUST zero after use + * (Arrays.fill(result, '\0') in a finally block) + */ + char[] decrypt(byte[] encrypted); +} +``` + +**Implementation class:** `AesGcmCredentialEncryptor` + +**Key constants:** +- Algorithm: `AES/GCM/NoPadding` +- Key size: 256 bits +- Nonce size: 12 bytes (96 bits — GCM standard, RFC 5116) +- Tag size: 128 bits (16 bytes — GCM default) +- Vault entry alias: `external-credential-master-key` (configurable) + +**Security review checklist for this class:** +- [ ] Fresh nonce per encryption (never reuse) +- [ ] Nonce included in output (prepended) +- [ ] Master key loaded once at bean creation, held as `SecretKey`, never logged +- [ ] `decrypt()` returns `char[]` (not `String`) to enable zeroing +- [ ] No token value in `toString()`, `hashCode()`, logs, or exceptions + +--- + +### Task 3: Domain entity + Repository interface + +**Files:** +- `project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/UserExternalCredential.java` +- `project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/CredentialStatus.java` +- `project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/repository/UserExternalCredentialRepository.java` + +**Domain entity:** +```java +package life.qbic.projectmanagement.domain.model.associated_dataset; + +import java.time.Instant; + +/** + * Per-user, per-instance credential for an external data provider. + * + *This entity owns the encrypted token blob and the credential + * status. The plaintext token never exists in this layer — it exists + * only transiently in the infrastructure adapter during HTTP calls + * (ADR-0002 D1 decryption boundary).
+ * + *The entity is source-agnostic at the domain boundary: it carries + * a {@link SourceType} and an {@code instanceId}, consistent with the + * {@link AssociatedDataset} aggregate.
+ * + * @since 1.12.0 + */ +public class UserExternalCredential { + + private final String id; + private final String userId; + private final SourceType sourceType; + private final String instanceId; + private final byte[] encryptedToken; // AES-GCM blob, opaque at this layer + private CredentialStatus status; + private final Instant createdAt; + private Instant updatedAt; + + // constructor, getters, status transition method +} +``` + +**Enum:** +```java +public enum CredentialStatus { + VALID, + INVALIDATED +} +``` + +**Repository interface (domain-layer port):** +```java +package life.qbic.projectmanagement.domain.model.associated_dataset.repository; + +import java.util.List; +import java.util.Optional; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; +import life.qbic.projectmanagement.domain.model.associated_dataset.UserExternalCredential; + +/** + * Persistence port for per-user external provider credentials. + * + *Implemented by the infrastructure layer (JPA). The application layer + * depends only on this interface.
+ * + *Queries are scoped by source type so that the application service + * can look up credentials without knowing the storage details.
+ * + * @since 1.12.0 + */ +public interface UserExternalCredentialRepository { + + OptionalSeparate from {@link DatasetSource} (ADR-0002 P2): token validation + * is a credential management concern, not a dataset search/resolve + * concern.
+ * + *Source-type dispatching: the implementation routes to the correct + * provider-specific validator based on the {@link SourceType}. The + * application service does not need to know which validator handles a + * given provider — it passes the source type and the dispatcher handles + * the rest.
+ * + * @since 1.12.0 + */ +public interface ExternalCredentialValidator { + + /** + * Validates whether the given plaintext token is accepted by the + * instance's authenticated-user endpoint. + * + * @param sourceType the external source type — routes to the + * appropriate provider-specific validator + * @param config the target instance configuration + * @param token the plaintext token as char[] — implementation + * MUST zero after use + * @return true if the token is valid (server returned 200), false if + * the server rejected the token (401/403) + * @throws CredentialValidationException if the source type has no + * registered validator, or on transient failures (network + * error, server error after retries) + */ + boolean validateToken(SourceType sourceType, InstanceConfig config, + char[] token); +} +``` + +**Why a separate port?** ADR-0002 P2 defines `DatasetSource` as the port for dataset search and resolve. Token validation is a credential lifecycle operation (add/verify), not a dataset operation. Keeping them separate: +- Maintains cohesion in `DatasetSource` (search + resolve of datasets) +- Prevents the `actingUserId` parameter contract from being overloaded with a "validate my own credential" use case +- Allows independent evolution of credential management vs. dataset discovery + +#### 5b. Per-provider adapter interface — `CredentialValidatorAdapter` + +**File:** `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/CredentialValidatorAdapter.java` + +```java +package life.qbic.projectmanagement.infrastructure.external; + +import life.qbic.projectmanagement.application.associated_dataset.InstanceConfig; + +/** + * Provider-specific credential validation adapter. + * + *Each external source type implements this interface with its own + * authentication scheme. The composite dispatcher routes to the correct + * adapter based on source type.
+ * + * @since 1.12.0 + */ +public interface CredentialValidatorAdapter { + + /** + * Validates the given plaintext token against the instance. + * + * @param config the target instance configuration + * @param token the plaintext token as char[] — implementation MUST + * zero after use + * @return true if valid, false if rejected + */ + boolean validate(InstanceConfig config, char[] token); +} +``` + +#### 5c. InvenioRDM adapter — `InvenioRdmCredentialValidatorAdapter` + +**File:** `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmCredentialValidatorAdapter.java` + +```java +package life.qbic.projectmanagement.infrastructure.external.invenio; + +import life.qbic.projectmanagement.application.associated_dataset.InstanceConfig; +import life.qbic.projectmanagement.infrastructure.external.CredentialValidatorAdapter; + +/** + * Validates InvenioRDM personal access tokens via the spec-defined + * authenticated-user endpoint. + * + *Endpoint: {@code GET /api/users} (operationId: {@code getAUserById})
+ * with {@code Authorization: Bearer
New providers are added by implementing {@link CredentialValidatorAdapter} + * and registering the new adapter in the constructor (or via Spring wiring + * in {@code InvenioRdmConfiguration}). No changes to the application + * service, port, UI, or database are required.
+ * + * @since 1.12.0 + */ +public class SourceTypeDispatchingCredentialValidator + implements ExternalCredentialValidator { + + private final MapThis is the token validation endpoint defined in the InvenioRDM + * OpenAPI spec (operationId: {@code getAUserById}).
+ * + *The response body is typed {@code type: object} in the official spec + * (no named fields guaranteed). The DTO below captures best-effort + * display values for informational/log purposes only — validation + * succeeds based purely on the 200 status.
+ * + * @param instanceUrl the base URL of the InvenioRDM instance + * @param authHeader the full Authorization header value + * (e.g. {@code BearerOrchestrates token validation, encryption (delegated to + * infrastructure), and persistence. The service is source-agnostic — + * it operates on any configured instance regardless of source type.
+ * + * @since 1.12.0 + */ +public interface ExternalCredentialService { + + /** + * Adds (or replaces) a token for the given user and instance. + * + *Flow: + *
Returns one entry per configured instance (from + * {@link SourceInstanceRegistry}). Instances where the user has no + * token configured are included with a status indicating "not + * configured".
+ * + * @param userId the DM user whose credentials to list + * @return credential status per instance; never null + */ + ListWraps {@link UserExternalCredentialJpaRepository} and delegates + * persistence, exposing the domain-level semantics defined by + * {@link UserExternalCredentialRepository}.
+ * + * @since 1.12.0 + */ +@Repository +public class UserExternalCredentialRepositoryImpl + implements UserExternalCredentialRepository { + + private final UserExternalCredentialJpaRepository jpaRepository; + + public UserExternalCredentialRepositoryImpl( + UserExternalCredentialJpaRepository jpaRepository) { + this.jpaRepository = Objects.requireNonNull(jpaRepository, + "jpaRepository must not be null"); + } + + @Override + public OptionalUses a master {@link SecretKey} loaded from the PKCS12 vault at + * application startup (ADR-0002 S2). The key is resolved by the Spring + * configuration ({@code InvenioRdmConfiguration}) which reads the vault + * entry and passes the raw key material here.
+ * + *+ * nonce (12 bytes) ‖ ciphertext (n bytes) ‖ tag (16 bytes) + *+ * where the nonce is a fresh random 96-bit value per invocation (RFC + * 5116 §3.1). The GCM authentication tag is always 128 bits (Java's + * {@link Cipher} appends it transparently to ciphertext output). + * + *
Never includes plaintext token content in the message.
+ * + * @since 1.12.0 + */ + public static class ExternalCredentialEncryptorException + extends RuntimeException { + + public ExternalCredentialEncryptorException(String message) { + super(message); + } + + public ExternalCredentialEncryptorException(String message, + Throwable cause) { + super(message, cause); + } + } + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/CredentialValidatorAdapter.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/CredentialValidatorAdapter.java new file mode 100644 index 000000000..af0aa7094 --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/CredentialValidatorAdapter.java @@ -0,0 +1,38 @@ +package life.qbic.projectmanagement.infrastructure.external; + +import life.qbic.projectmanagement.application.associated_dataset.InstanceConfig; + +/** + * Provider-specific credential validation adapter. + * + *Each external source type implements this interface with its own + * authentication scheme and validation logic. The composite dispatcher + * ({@code SourceTypeDispatchingCredentialValidator}) routes to the + * correct adapter based on source type.
+ * + *This interface is the infrastructure-side counterpart of the + * application-layer {@link life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialValidator} + * port. It allows adding new provider types without changing the + * dispatcher contract or the application service.
+ * + * @since 1.12.0 + */ +public interface CredentialValidatorAdapter { + + /** + * Validates the given plaintext token against the instance. + * + * @param config the target instance configuration (base URL, display + * name) + * @param token the plaintext token as {@code char[]} — + * implementation MUST zero after use + * ({@code Arrays.fill(token, '\0')} in a finally block) + * @return {@code true} if the token is valid (the remote API + * returned 200), {@code false} if the token was rejected + * (401/403) + * @throws CredentialValidationException on transient failures + * (network error, 5xx server error after retries) + */ + boolean validate(InstanceConfig config, char[] token); + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/SourceTypeDispatchingCredentialValidator.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/SourceTypeDispatchingCredentialValidator.java new file mode 100644 index 000000000..cd8a23705 --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/SourceTypeDispatchingCredentialValidator.java @@ -0,0 +1,54 @@ +package life.qbic.projectmanagement.infrastructure.external; + +import java.util.Map; +import java.util.Objects; +import life.qbic.projectmanagement.application.associated_dataset.CredentialValidationException; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialValidator; +import life.qbic.projectmanagement.application.associated_dataset.InstanceConfig; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; + +/** + * Composite dispatcher that routes credential validation to the correct + * provider-specific adapter based on source type. + * + *New providers are added by implementing {@link CredentialValidatorAdapter} + * and registering the new adapter in the constructor (or via Spring wiring + * in the configuration module). No changes to the application service, + * port, UI, or database are required.
+ * + *Thread-safe: the adapter map is immutable.
+ * + * @since 1.12.0 + */ +public class SourceTypeDispatchingCredentialValidator + implements ExternalCredentialValidator { + + private final MapThis is the token validation endpoint defined in the InvenioRDM + * OpenAPI specification (operationId: {@code getAUserById}).
+ * + *The response body is typed {@code type: object} in the official + * spec (no named fields guaranteed). The DTO below captures best-effort + * display values for informational/log purposes only — validation + * succeeds based purely on the 200 status.
+ * + * @param instanceUrl the base URL of the InvenioRDM instance + * @param authHeader the full Authorization header value + * (e.g. {@code "BearerThe response body is typed {@code type: object} in the official + * spec (no named fields guaranteed). This DTO captures best-effort + * display values. Validation succeeds purely on the 200 status; + */ + @JsonIgnoreProperties(ignoreUnknown = true) + record AuthenticatedUserResponse( + @JsonProperty("id") String id, + @JsonProperty("username") String username, + @JsonProperty("email") String email + ) {} + /** * Implementation of the InvenioRDM HTTP client. * @@ -507,11 +583,18 @@ public InvenioRdmHttpClient() { public SearchResultResponse search(String instanceUrl, SearchParams params) throws InvenioRdmPermanentException, InvenioRdmTransientException, InvenioRdmResponseParsingException { + return search(instanceUrl, params, null); + } + + @Override + public SearchResultResponse search(String instanceUrl, SearchParams params, + String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException { Objects.requireNonNull(instanceUrl, "instanceUrl must not be null"); Objects.requireNonNull(params, "params must not be null"); String url = buildSearchUrl(instanceUrl, params); - String body = getWithRetry(url, null, "search records"); + String body = getWithRetry(url, authHeader, "search records"); return parseJson(body, SearchResultResponse.class); } @@ -519,15 +602,35 @@ public SearchResultResponse search(String instanceUrl, SearchParams params) public RecordResponse getRecord(String instanceUrl, String recordId) throws InvenioRdmPermanentException, InvenioRdmTransientException, InvenioRdmResponseParsingException { + return getRecord(instanceUrl, recordId, null); + } + + @Override + public RecordResponse getRecord(String instanceUrl, String recordId, + String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException { Objects.requireNonNull(instanceUrl, "instanceUrl must not be null"); Objects.requireNonNull(recordId, "recordId must not be null"); String url = normalizeBaseUrl(instanceUrl) + "/api/records/" + URLEncoder.encode(recordId, StandardCharsets.UTF_8); - String body = getWithRetry(url, null, "get record " + recordId); + String body = getWithRetry(url, authHeader, "get record " + recordId); return parseJson(body, RecordResponse.class); } + @Override + public AuthenticatedUserResponse getAuthenticatedUser( + String instanceUrl, String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException, + InvenioRdmResponseParsingException { + Objects.requireNonNull(instanceUrl, "instanceUrl must not be null"); + Objects.requireNonNull(authHeader, "authHeader must not be null"); + + String url = normalizeBaseUrl(instanceUrl) + "/api/users"; + String body = getWithRetry(url, authHeader, "get authenticated user"); + return parseJson(body, AuthenticatedUserResponse.class); + } + // ── Internals ─────────────────────────────────────────────────── private String buildSearchUrl(String instanceUrl, SearchParams params) { diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmCredentialValidatorAdapter.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmCredentialValidatorAdapter.java new file mode 100644 index 000000000..fab70a7dd --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmCredentialValidatorAdapter.java @@ -0,0 +1,75 @@ +package life.qbic.projectmanagement.infrastructure.external.invenio; + +import java.util.Arrays; +import java.util.Objects; +import life.qbic.projectmanagement.application.associated_dataset.CredentialValidationException; +import life.qbic.projectmanagement.application.associated_dataset.InstanceConfig; +import life.qbic.projectmanagement.infrastructure.external.CredentialValidatorAdapter; + +/** + * Validates InvenioRDM personal access tokens via the spec-defined + * authenticated-user endpoint. + * + *
Endpoint: {@code GET /api/users} (operationId: {@code getAUserById})
+ * with {@code Authorization: Bearer
The plaintext token is zeroed in a {@code finally} block after + * use (ADR-0002 D1 decryption boundary).
+ * + * @since 1.12.0 + */ +public class InvenioRdmCredentialValidatorAdapter implements CredentialValidatorAdapter { + + private final InvenioRdmClient client; + + public InvenioRdmCredentialValidatorAdapter(InvenioRdmClient client) { + this.client = Objects.requireNonNull(client, "client must not be null"); + } + + @Override + public boolean validate(InstanceConfig config, char[] token) { + Objects.requireNonNull(config, "config must not be null"); + Objects.requireNonNull(token, "token must not be null"); + + // Copy token — the original is owned by the caller and may be + // zeroed before this method returns (it is, in a finally below). + // The auth header string is in local scope only. + char[] tokenCopy = Arrays.copyOf(token, token.length); + try { + String authHeader = "Bearer " + new String(tokenCopy); + client.getAuthenticatedUser(config.baseUrl(), authHeader); + // 200 response → token is valid + return true; + } catch (InvenioRdmClient.InvenioRdmPermanentException e) { + // 401 / 403 → token rejected + if (e.getStatusCode() == 401 || e.getStatusCode() == 403) { + return false; + } + // Other 4xx → unexpected (e.g. 404 means endpoint missing) + throw new CredentialValidationException( + "Unexpected error validating token on " + config.displayName() + + " (status " + e.getStatusCode() + ")", e); + } catch (InvenioRdmClient.InvenioRdmTransientException e) { + // 5xx / network error → retry exhausted, surface to caller + throw new CredentialValidationException( + "Token validation failed on " + config.displayName() + + " due to a transient error", e); + } catch (InvenioRdmClient.InvenioRdmInterruptedException e) { + Thread.currentThread().interrupt(); + throw new CredentialValidationException( + "Token validation on " + config.displayName() + + " was interrupted", e); + } finally { + Arrays.fill(tokenCopy, '\0'); + } + } + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmDatasetSource.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmDatasetSource.java index 9c7d73866..6efc87903 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmDatasetSource.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmDatasetSource.java @@ -5,10 +5,12 @@ import java.time.LocalDate; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.Optional; import life.qbic.logging.api.Logger; +import life.qbic.projectmanagement.application.associated_dataset.CredentialEncryptor; import life.qbic.projectmanagement.application.associated_dataset.DatasetResolveException; import life.qbic.projectmanagement.application.associated_dataset.DatasetSearchException; import life.qbic.projectmanagement.application.associated_dataset.DatasetSource; @@ -20,6 +22,8 @@ import life.qbic.projectmanagement.domain.model.associated_dataset.InvenioRdmAccessStatus; import life.qbic.projectmanagement.domain.model.associated_dataset.InvenioRdmResourceMetadata; import life.qbic.projectmanagement.domain.model.associated_dataset.ResourceMetadata; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; +import life.qbic.projectmanagement.domain.model.associated_dataset.repository.UserExternalCredentialRepository; /** * Infrastructure adapter implementing the {@link DatasetSource} port for @@ -41,9 +45,17 @@ public class InvenioRdmDatasetSource implements DatasetSource { private static final Logger log = logger(InvenioRdmDatasetSource.class); private final InvenioRdmClient client; + private final UserExternalCredentialRepository credentialRepository; + private final CredentialEncryptor encryptor; - public InvenioRdmDatasetSource(InvenioRdmClient client) { + public InvenioRdmDatasetSource(InvenioRdmClient client, + UserExternalCredentialRepository credentialRepository, + CredentialEncryptor encryptor) { this.client = Objects.requireNonNull(client, "client must not be null"); + this.credentialRepository = Objects.requireNonNull(credentialRepository, + "credentialRepository must not be null"); + this.encryptor = Objects.requireNonNull(encryptor, + "encryptor must not be null"); } // ── Port implementation ───────────────────────────────────────────── @@ -59,15 +71,22 @@ public SearchResult search(SearchQuery query, InstanceConfig config, var params = new InvenioRdmClient.SearchParams( query.effectiveQuery(), invenioPage, query.pageSize()); + char[] token = resolveTokenForUser(actingUserId, config.id()); try { - var response = client.search(config.baseUrl(), params); + String authHeader = token != null + ? "Bearer " + new String(token) : null; + var response = client.search(config.baseUrl(), params, authHeader); ListThe returned {@code char[]} MUST be zeroed + * by the caller in a {@code finally} block + * (ADR-0002 D1 decryption boundary).
+ */ + private char[] resolveTokenForUser(String userId, String instanceId) { + if (userId == null) { + return null; } + return credentialRepository + .findByUserIdAndSourceTypeAndInstanceId( + userId, SourceType.INVENIO_RDM, instanceId) + .map(cred -> encryptor.decrypt(cred.getEncryptedToken())) + .orElse(null); } // ── Mapping: v12 response → DTO ───────────────────────────────────── diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/PropertiesBackedSourceInstanceRegistry.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/PropertiesBackedSourceInstanceRegistry.java index 440bdc0e6..7b8a99a51 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/PropertiesBackedSourceInstanceRegistry.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/PropertiesBackedSourceInstanceRegistry.java @@ -34,7 +34,8 @@ public PropertiesBackedSourceInstanceRegistry(InvenioRdmProperties properties) { .map(e -> new SourceInstanceDescriptor( e.id(), e.displayName() != null ? e.displayName() : e.id(), - e.baseUrl())) + e.baseUrl(), + SourceType.INVENIO_RDM)) .toList(); log.info("Loaded %d InvenioRDM instance(s): %s" .formatted(descriptors.size(), diff --git a/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptorSpec.groovy b/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptorSpec.groovy new file mode 100644 index 000000000..a0f7292cf --- /dev/null +++ b/project-management-infrastructure/src/test/groovy/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptorSpec.groovy @@ -0,0 +1,190 @@ +package life.qbic.projectmanagement.infrastructure.external + +import java.nio.charset.StandardCharsets +import java.security.SecureRandom + +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import spock.lang.Specification + +/** + * Unit tests for {@link AesGcmCredentialEncryptor}. + * + * @since 1.12.0 + */ +class AesGcmCredentialEncryptorSpec extends Specification { + + /** 32-byte raw master key — simulates what ops stores in the PKCS12 vault */ + static final SecretKey MASTER_KEY = new SecretKeySpec( + "aaaa1111bbbb2222cccc3333dddd4444".getBytes(StandardCharsets.UTF_8), + "AES") + + def "encrypt then decrypt roundtrip preserves plaintext"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + def plaintext = "my-secret-invenio-token-abc123".toCharArray() + + when: + def encrypted = encryptor.encrypt(plaintext) + def decrypted = encryptor.decrypt(encrypted) + + then: + decrypted == plaintext + } + + def "different encryptions of the same plaintext produce different ciphertexts"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + def plaintext = "same-token-every-time".toCharArray() + + when: + def encrypted1 = encryptor.encrypt(plaintext) + def encrypted2 = encryptor.encrypt(plaintext) + + then: "nonce is random each time, so ciphertexts differ" + encrypted1 != encrypted2 + + and: "but both decrypt to the same plaintext" + encryptor.decrypt(encrypted1) == plaintext + encryptor.decrypt(encrypted2) == plaintext + } + + def "encrypt then decrypt works with empty token"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + def plaintext = "".toCharArray() + + when: + def encrypted = encryptor.encrypt(plaintext) + def decrypted = encryptor.decrypt(encrypted) + + then: + decrypted == plaintext + } + + def "encrypt then decrypt works with unicode characters"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + def plaintext = "tökén-ünïcödë-日本語".toCharArray() + + when: + def encrypted = encryptor.encrypt(plaintext) + def decrypted = encryptor.decrypt(encrypted) + + then: + decrypted == plaintext + } + + def "decryption with wrong key fails"() { + given: + def encryptor1 = buildEncryptor(MASTER_KEY) + def wrongKey = new SecretKeySpec( + "zzzz9999yyyy8888xxxx7777wwww6666".getBytes(StandardCharsets.UTF_8), + "AES") + def encryptor2 = buildEncryptor(wrongKey) + def plaintext = "test-token".toCharArray() + + when: + def encrypted = encryptor1.encrypt(plaintext) + encryptor2.decrypt(encrypted) + + then: + thrown(AesGcmCredentialEncryptor.ExternalCredentialEncryptorException) + } + + def "decryption of corrupted data throws meaningful exception"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + + when: + def encrypted = encryptor.encrypt("token".toCharArray()) + encrypted[15] = (byte) (encrypted[15] ^ 0xFF) + encryptor.decrypt(encrypted) + + then: + def e = thrown(AesGcmCredentialEncryptor.ExternalCredentialEncryptorException) + e.message.contains("Decryption failed") + } + + def "encrypted data shorter than nonce plus tag fails fast"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + + when: + encryptor.decrypt(new byte[10]) + + then: + def e = thrown(AesGcmCredentialEncryptor.ExternalCredentialEncryptorException) + e.message.contains("too short") + } + + def "encrypt output does not contain plaintext as readable text"() { + given: + def encryptor = buildEncryptor(MASTER_KEY) + def plaintext = "secret-token-value".toCharArray() + + when: + def encrypted = encryptor.encrypt(plaintext) + def asUtf8 = new String(encrypted, StandardCharsets.UTF_8) + + then: + !asUtf8.contains("secret-token-value") + } + + def "null master key fails at construction"() { + when: + new AesGcmCredentialEncryptor(null) + + then: + thrown(NullPointerException) + } + + def "non-AES algorithm key fails at construction"() { + given: + // DES has 8-byte keys — valid for DES but wrong algorithm for our encryptor + def desKey = new SecretKeySpec( + "12345678".getBytes(StandardCharsets.UTF_8), "DES") + + when: + new AesGcmCredentialEncryptor(desKey) + + then: + thrown(IllegalArgumentException) + } + + def "AES key shorter than 32 bytes fails at construction"() { + given: "a 16-byte AES key (AES-128, not AES-256)" + def shortKey = new SecretKeySpec( + "1234567890123456".getBytes(StandardCharsets.UTF_8), "AES") + + when: + new AesGcmCredentialEncryptor(shortKey) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains("256 bits") + e.message.contains("32 bytes") + } + + def "AES key longer than 32 bytes fails at construction"() { + given: "a 48-byte AES key (not a valid AES key size)" + def longKey = new SecretKeySpec( + "123456789012345678901234567890123456789012345678".getBytes(StandardCharsets.UTF_8), + "AES") + + when: + new AesGcmCredentialEncryptor(longKey) + + then: + def e = thrown(IllegalArgumentException) + e.message.contains("256 bits") + e.message.contains("32 bytes") + } + + // ── Helpers ──────────────────────────────────────────────────── + + private static AesGcmCredentialEncryptor buildEncryptor( + SecretKey key) { + new AesGcmCredentialEncryptor(key, new SecureRandom()) + } +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialEncryptor.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialEncryptor.java new file mode 100644 index 000000000..7c911b191 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialEncryptor.java @@ -0,0 +1,40 @@ +package life.qbic.projectmanagement.application.associated_dataset; + +/** + * Encrypts and decrypts external provider user tokens. + * + *Application-layer port (SPI): implemented by the infrastructure + * layer. The interface is provider-agnostic — the same encryptor + * handles tokens for InvenioRDM, LIMS, or any future external source, + * all sharing the same master key.
+ * + *The infrastructure implementation (AES-256-GCM) uses a dedicated + * master key from the PKCS12 vault (ADR-0002 S2).
+ * + *Output format: opaque encrypted blob (nonce + ciphertext + auth tag). + * The application layer treats this as an opaque byte array — it never + * inspects or interprets the contents.
+ * + * @since 1.12.0 + */ +public interface CredentialEncryptor { + + /** + * Encrypts a plaintext token. + * + * @param plaintext the plaintext token as {@code char[]} + * @return the encrypted blob (nonce + ciphertext + GCM tag) + */ + byte[] encrypt(char[] plaintext); + + /** + * Decrypts an encrypted token. + * + * @param encrypted the encrypted blob + * @return the plaintext token as {@code char[]} — caller MUST + * zero after use ({@code Arrays.fill(result, '\0')} in a + * finally block) + */ + char[] decrypt(byte[] encrypted); + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialValidationException.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialValidationException.java new file mode 100644 index 000000000..c72994551 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/CredentialValidationException.java @@ -0,0 +1,25 @@ +package life.qbic.projectmanagement.application.associated_dataset; + +/** + * Thrown when credential validation cannot determine a definitive + * result because of a transient infrastructure failure (network + * error, remote server error after retries). + * + *This is not thrown when a token is simply invalid + * (401/403) — that case returns {@code false} from + * {@link ExternalCredentialValidator#validateToken}. This exception + * is reserved for situations where "try again later" might succeed.
+ * + * @since 1.12.0 + */ +public class CredentialValidationException extends RuntimeException { + + public CredentialValidationException(String message) { + super(message); + } + + public CredentialValidationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/DefaultExternalCredentialService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/DefaultExternalCredentialService.java new file mode 100644 index 000000000..f4b28217c --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/DefaultExternalCredentialService.java @@ -0,0 +1,216 @@ +package life.qbic.projectmanagement.application.associated_dataset; + +import static java.util.Objects.requireNonNull; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import life.qbic.projectmanagement.domain.model.associated_dataset.CredentialStatus; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; +import life.qbic.projectmanagement.domain.model.associated_dataset.UserExternalCredential; +import life.qbic.projectmanagement.domain.model.associated_dataset.repository.UserExternalCredentialRepository; + +/** + * Default implementation of {@link ExternalCredentialService}. + * + *Orchestrates token validation, encryption, and persistence. + * The plaintext token is accepted as a {@code char[]} from the view + * layer and is zeroed in a {@code finally} block before the method + * returns — regardless of success or failure.
+ * + * @since 1.12.0 + */ +public class DefaultExternalCredentialService implements ExternalCredentialService { + + private final ExternalCredentialValidator validator; + private final UserExternalCredentialRepository credentialRepository; + private final CredentialEncryptor encryptor; + private final SourceInstanceRegistry instanceRegistry; + + public DefaultExternalCredentialService( + ExternalCredentialValidator validator, + UserExternalCredentialRepository credentialRepository, + CredentialEncryptor encryptor, + SourceInstanceRegistry instanceRegistry) { + this.validator = requireNonNull(validator, "validator must not be null"); + this.credentialRepository = requireNonNull(credentialRepository, + "credentialRepository must not be null"); + this.encryptor = requireNonNull(encryptor, "encryptor must not be null"); + this.instanceRegistry = requireNonNull(instanceRegistry, + "instanceRegistry must not be null"); + } + + @Override + public AddCredentialResult addCredential(String userId, String instanceId, + char[] token) { + requireNonNull(userId, "userId must not be null"); + requireNonNull(instanceId, "instanceId must not be null"); + requireNonNull(token, "token must not be null"); + + try { + // 1. Resolve the instance + source type from the registry + OptionalOrchestrates token validation (via the composite + * {@link ExternalCredentialValidator}), encryption and persistence + * (delegated to infrastructure), and credential listing. The service is + * source-agnostic — it operates on any configured instance regardless + * of source type.
+ * + *The application service accepts a plaintext {@code char[]} token + * from the view layer and is responsible for zeroing it before + * returning (in a {@code finally} block). It never exposes the token + * via return values or stored state.
+ * + * @since 1.12.0 + */ +public interface ExternalCredentialService { + + /** + * Validates and persists a token for the given user and instance. + * + *The plaintext token is zeroed before this method returns. + * If a credential for this user/instance already exists, it is + * replaced with the new validated token.
+ * + * @param userId the DM user adding the token + * @param instanceId the target instance (e.g. {@code "zenodo"}) + * @param token the plaintext token — MUST be + * zeroed by this method before returning + * @return result indicating success or failure + */ + AddCredentialResult addCredential(String userId, String instanceId, + char[] token); + + /** + * Removes the token for the given user and instance. + * + * @param userId the DM user removing the token + * @param instanceId the target instance + * @return {@code true} if a credential was removed, {@code false} + * if none existed for that user/instance pair + */ + boolean removeCredential(String userId, String instanceId); + + /** + * Lists the user's credential status for all configured instances. + * + *Returns one entry per configured instance (from + * {@link SourceInstanceRegistry}). Instances where the user has no + * token configured are included with status + * {@code "NOT_CONFIGURED"}.
+ * + * @param userId the DM user whose credentials to list + * @return credential status per instance; never null + */ + ListThis method decrypts the stored encrypted token, sends it to the + * provider's validation endpoint, and transitions the credential to + * {@link life.qbic.projectmanagement.domain.model.associated_dataset.CredentialStatus#VALID} + * or + * {@link life.qbic.projectmanagement.domain.model.associated_dataset.CredentialStatus#INVALIDATED} + * based on the response. Per ADR-0002, status transitions happen only + * on explicit user-initiated validation.
+ * + * @param userId the DM user whose credential to validate + * @param instanceId the target instance (e.g. {@code "zenodo"}) + * @return result indicating success or failure + */ + AddCredentialResult validateCredential(String userId, String instanceId); + + // ── Result types ──────────────────────────────────────────────── + + /** + * Outcome of an {@link #addCredential} operation. + * + *A sealed type hierarchy: only the three named record forms + * are permitted. The plaintext token value is never present in + * any result form.
+ */ + sealed interface AddCredentialResult + permits Success, InvalidToken, ServiceError, UnknownInstance { + } + + /** The token was validated and persisted successfully. */ + record Success() implements AddCredentialResult { + } + + /** The token was rejected by the external instance. */ + record InvalidToken(String reason) implements AddCredentialResult { + } + + /** A transient infrastructure failure prevented validation. */ + record ServiceError(String reason) implements AddCredentialResult { + } + + /** No instance with the given ID is configured. */ + record UnknownInstance(String instanceId) implements AddCredentialResult { + } + + /** + * Credential status for a single instance (no plaintext token). + */ + record CredentialStatusView( + SourceType sourceType, + String instanceId, + String instanceDisplayName, + String instanceBaseUrl, + boolean configured, + /** {@code "VALID"}, {@code "INVALIDATED"}, or {@code "NOT_CONFIGURED"} */ + String status, + Instant configuredAt + ) { + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialValidator.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialValidator.java new file mode 100644 index 000000000..b4bd324ca --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialValidator.java @@ -0,0 +1,46 @@ +package life.qbic.projectmanagement.application.associated_dataset; + +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; + +/** + * Validates a user's personal access token against an external data + * source instance. + * + *Separate from {@link DatasetSource} (ADR-0002 P2): token + * validation is a credential-management concern, not a dataset + * search/resolve concern.
+ * + *Implementations use source-type dispatching — the composite + * implementation routes to the correct provider-specific adapter + * based on the {@link SourceType}. The application service does not + * need to know which validator handles a given provider; it passes + * the source type and the dispatcher handles the rest.
+ * + *The application layer never sees, holds, or returns the plaintext + * token. The only observable outcomes are a boolean (valid/invalid) + * or a {@link CredentialValidationException} (transient failure).
+ * + * @since 1.12.0 + */ +public interface ExternalCredentialValidator { + + /** + * Validates whether the given plaintext token is accepted by the + * instance's authenticated-user endpoint. + * + * @param sourceType the external source type — routes to the + * appropriate provider-specific validator + * @param config the target instance configuration + * @param token the plaintext token as {@code char[]} — + * implementation MUST zero after use + * @return {@code true} if the token is valid (the remote API + * returned 200), {@code false} if the server rejected the + * token (401/403) + * @throws CredentialValidationException if the source type has no + * registered validator, or on transient failures (network + * error, server error after retries) + */ + boolean validateToken(SourceType sourceType, InstanceConfig config, + char[] token); + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/SourceInstanceDescriptor.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/SourceInstanceDescriptor.java index 29e2af239..f9995a068 100644 --- a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/SourceInstanceDescriptor.java +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/SourceInstanceDescriptor.java @@ -1,6 +1,7 @@ package life.qbic.projectmanagement.application.associated_dataset; import java.util.Objects; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; /** * Configuration descriptor for one available external data source instance, @@ -13,6 +14,10 @@ * infrastructure adapter resolves per-user credentials from secure storage * when making external API calls. * + *The {@code sourceType} field identifies which provider handles this + * instance, enabling source-type dispatching in the composite credential + * validator and the dataset source port.
+ * *Admin-controlled: adding or changing instances is a config change + * deploy (ADR-0002 I2).
* @@ -21,13 +26,15 @@ public record SourceInstanceDescriptor( String id, String displayName, - String baseUrl + String baseUrl, + SourceType sourceType ) { public SourceInstanceDescriptor { Objects.requireNonNull(id, "id must not be null"); Objects.requireNonNull(displayName, "displayName must not be null"); Objects.requireNonNull(baseUrl, "baseUrl must not be null"); + Objects.requireNonNull(sourceType, "sourceType must not be null"); } /** diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/CredentialStatus.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/CredentialStatus.java new file mode 100644 index 000000000..6d588fd6f --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/CredentialStatus.java @@ -0,0 +1,38 @@ +package life.qbic.projectmanagement.domain.model.associated_dataset; + +/** + * The status of a stored external provider credential. + * + *Per ADR-0002 §9, the status is updated only on explicit + * user action (e.g. the user adds or re-validates a token in the UI). + * A failed sync operation does not silently update the + * credential status to {@code INVALIDATED} — that would be misleading, + * since the failure may be transient or caused by insufficient access + * rather than an expired token.
+ * + * @since 1.12.0 + */ +public enum CredentialStatus { + + /** + * The token was validated and stored successfully. The credential + * is currently usable for authenticated requests. + */ + VALID, + + /** + * The token was explicitly marked as invalidated (e.g. the user + * removed it or re-validation failed). The stored encrypted blob + * is retained for audit but must not be used for authentication. + */ + INVALIDATED; + + public static CredentialStatus parse(String value) { + try { + return valueOf(value); + } catch (IllegalArgumentException | NullPointerException e) { + throw new IllegalArgumentException("Unknown credential status: " + value, e); + } + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/UserExternalCredential.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/UserExternalCredential.java new file mode 100644 index 000000000..4a91ce863 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/UserExternalCredential.java @@ -0,0 +1,169 @@ +package life.qbic.projectmanagement.domain.model.associated_dataset; + +import static java.util.Objects.requireNonNull; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import java.util.Arrays; +import java.util.UUID; + +/** + * Per-user, per-instance credential for an external data provider. + * + *This entity owns the encrypted token blob and the credential + * status. The plaintext token never exists at this layer — it exists + * only transiently in the infrastructure adapter during HTTP calls + * (ADR-0002 D1 decryption boundary).
+ * + *The entity is source-agnostic at the domain boundary: it carries + * a {@link SourceType} and an {@code instanceId}, consistent with the + * {@link AssociatedDataset} aggregate design.
+ * + *Persistence convention: in this codebase, aggregate roots are + * annotated directly with JPA annotations (see {@link AssociatedDataset} + * for the reference pattern).
+ * + * @since 1.12.0 + */ +@Entity +@Table(name = "user_external_credential", + uniqueConstraints = @UniqueConstraint( + name = "uk_user_src_instance", + columnNames = {"user_id", "source_type", "instance_id"})) +public class UserExternalCredential { + + @Id + @Column(name = "id", nullable = false, length = 36) + private String id; + + @Column(name = "user_id", nullable = false, length = 255) + private String userId; + + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false, length = 32) + private SourceType sourceType; + + @Column(name = "instance_id", nullable = false, length = 64) + private String instanceId; + + @Column(name = "encrypted_token", nullable = false, + columnDefinition = "varbinary(512)") + private byte[] encryptedToken; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private CredentialStatus status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + /** + * No-arg constructor required by JPA. Not for application use. + */ + protected UserExternalCredential() { + } + + /** + * Creates a new credential. + * + * @param userId the DM user ID + * @param sourceType the external source type + * @param instanceId the instance identifier (matches + * {@code InstanceConfig.id}) + * @param encryptedToken the AES-GCM-encrypted token blob + * (nonce ‖ ciphertext ‖ tag) + * @param status the credential status + */ + public UserExternalCredential( + String userId, + SourceType sourceType, + String instanceId, + byte[] encryptedToken, + CredentialStatus status) { + this.id = UUID.randomUUID().toString(); + this.userId = requireNonNull(userId, "userId must not be null"); + this.sourceType = requireNonNull(sourceType, + "sourceType must not be null"); + this.instanceId = requireNonNull(instanceId, + "instanceId must not be null"); + this.encryptedToken = requireNonNull(encryptedToken, + "encryptedToken must not be null"); + this.status = requireNonNull(status, "status must not be null"); + Instant now = Instant.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PrePersist + void onPrePersist() { + if (createdAt == null) { + createdAt = Instant.now(); + } + updatedAt = Instant.now(); + } + + @PreUpdate + void onPreUpdate() { + updatedAt = Instant.now(); + } + + public String getId() { + return id; + } + + public String getUserId() { + return userId; + } + + public SourceType getSourceType() { + return sourceType; + } + + public String getInstanceId() { + return instanceId; + } + + /** + * Returns a defensive copy of the encrypted token blob. + * The plaintext token never exists at this layer. + */ + public byte[] getEncryptedToken() { + return Arrays.copyOf(encryptedToken, encryptedToken.length); + } + + public CredentialStatus getStatus() { + return status; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + /** + * Transitions the credential status and updates the + * {@code updatedAt} timestamp. + * + * @param newStatus the new status + */ + public void transitionTo(CredentialStatus newStatus) { + requireNonNull(newStatus, "newStatus must not be null"); + this.status = newStatus; + this.updatedAt = Instant.now(); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/repository/UserExternalCredentialRepository.java b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/repository/UserExternalCredentialRepository.java new file mode 100644 index 000000000..e6ced0d82 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/domain/model/associated_dataset/repository/UserExternalCredentialRepository.java @@ -0,0 +1,67 @@ +package life.qbic.projectmanagement.domain.model.associated_dataset.repository; + +import java.util.List; +import java.util.Optional; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; +import life.qbic.projectmanagement.domain.model.associated_dataset.UserExternalCredential; + +/** + * Repository port for per-user external provider credentials. + * + *Implemented by the infrastructure layer (JPA). The application layer + * depends only on this interface.
+ * + *Queries are scoped by {@link SourceType} so that the application + * service can look up credentials without knowing the storage details.
+ * + * @since 1.12.0 + */ +public interface UserExternalCredentialRepository { + + /** + * Finds a credential for the given user, source type, and instance. + * + * @param userId the DM user ID + * @param sourceType the source type + * @param instanceId the instance identifier + * @return the credential, or empty if none is stored + */ + Optional