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: *

* * @since 1.12.0 @@ -32,18 +55,101 @@ @EnableConfigurationProperties(InvenioRdmProperties.class) public class InvenioRdmConfiguration { + // ── Core integration beans ────────────────────────────────────── + @Bean public InvenioRdmClient invenioRdmClient() { return new InvenioRdmHttpClient(); } @Bean - public SourceInstanceRegistry sourceInstanceRegistry(InvenioRdmProperties properties) { + public SourceInstanceRegistry sourceInstanceRegistry( + InvenioRdmProperties properties) { return new PropertiesBackedSourceInstanceRegistry(properties); } + // ── Credential encryption (provider-agnostic) ─────────────────── + + /** + * Reads the dedicated master AES key from the PKCS12 vault and + * constructs the encryptor. Fails fast at application startup if + * the vault entry is missing — credential management cannot operate + * without it. + */ + @Bean + public CredentialEncryptor credentialEncryptor( + DataManagerVault vault, + @Value("${qbic.security.vault.external-credential.key-alias}") String keyAlias) { + // PKCS12 keystores force entry password = store password (the keystore key). + // The vault entry was created via keytool, which silently used the keystore + // password, so we must read it using the keystore password, not the + // separate entry password used for entries created via DataManagerVault.add(). + String keyString = vault.read(keyAlias, true) + .orElseThrow(() -> new IllegalStateException( + "Vault entry not found for alias '" + keyAlias + + "'. The external credential master key must be " + + "provisioned in the PKCS12 keystore before the " + + "application starts.")); + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(keyString); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Vault entry for alias '" + keyAlias + + "' is not valid Base64-encoded data. The key must be " + + "stored as a Base64-encoded 32-byte AES key.", e); + } + if (keyBytes.length != AesGcmCredentialEncryptor.AES_256_KEY_BYTES) { + throw new IllegalStateException( + "Vault entry for alias '" + keyAlias + + "' has incorrect key size: " + keyBytes.length + + " bytes (expected " + AesGcmCredentialEncryptor.AES_256_KEY_BYTES + + " for AES-256)."); + } + SecretKey secretKey = new SecretKeySpec(keyBytes, "AES"); + return new AesGcmCredentialEncryptor(secretKey); + } + + // ── Per-provider credential validator adapters ────────────────── + + @Bean + public CredentialValidatorAdapter invenioRdmCredentialValidatorAdapter( + InvenioRdmClient client) { + return new InvenioRdmCredentialValidatorAdapter(client); + } + + // ── Composite credential validation dispatcher ────────────────── + @Bean - public DatasetSource invenioRdmDatasetSource(InvenioRdmClient client) { - return new InvenioRdmDatasetSource(client); + public ExternalCredentialValidator externalCredentialValidator( + CredentialValidatorAdapter invenioRdmCredentialValidatorAdapter) { + return new SourceTypeDispatchingCredentialValidator(Map.of( + SourceType.INVENIO_RDM, invenioRdmCredentialValidatorAdapter + // Future providers: add entries here, e.g. + // SourceType.LIMS, new LimsCredentialValidatorAdapter(...) + )); } + + // ── Application service ───────────────────────────────────────── + + @Bean + public ExternalCredentialService externalCredentialService( + ExternalCredentialValidator validator, + UserExternalCredentialRepository credentialRepository, + CredentialEncryptor encryptor, + SourceInstanceRegistry registry) { + return new DefaultExternalCredentialService( + validator, credentialRepository, encryptor, registry); + } + + // ── Dataset source adapter (wired with credential support) ────── + + @Bean + public DatasetSource invenioRdmDatasetSource( + InvenioRdmClient client, + UserExternalCredentialRepository credentialRepository, + CredentialEncryptor encryptor) { + return new InvenioRdmDatasetSource(client, credentialRepository, encryptor); + } + } diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/ExternalProvidersMain.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/ExternalProvidersMain.java new file mode 100644 index 000000000..18c95ff4e --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/ExternalProvidersMain.java @@ -0,0 +1,567 @@ +package life.qbic.datamanager.views.account; + +import static java.util.Objects.requireNonNull; + +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.AnchorTarget; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.H2; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.icon.Icon; +import com.vaadin.flow.component.icon.VaadinIcon; +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.notification.Notification; +import com.vaadin.flow.component.notification.NotificationVariant; +import com.vaadin.flow.router.BeforeEnterEvent; +import com.vaadin.flow.router.BeforeEnterObserver; +import com.vaadin.flow.router.Route; +import com.vaadin.flow.spring.annotation.SpringComponent; +import com.vaadin.flow.spring.annotation.UIScope; +import jakarta.annotation.security.PermitAll; +import java.io.Serial; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import life.qbic.datamanager.views.UiHandle; +import life.qbic.datamanager.views.UserMainLayout; +import life.qbic.datamanager.views.general.Main; +import life.qbic.datamanager.views.general.Tag; +import life.qbic.datamanager.views.general.Tag.TagColor; +import life.qbic.datamanager.views.general.dialog.AlertDialog; +import life.qbic.datamanager.views.general.dialog.AppDialog; +import life.qbic.datamanager.views.general.dialog.DialogBody; +import life.qbic.datamanager.views.general.dialog.DialogFooter; +import life.qbic.datamanager.views.general.dialog.DialogHeader; +import life.qbic.logging.api.Logger; +import life.qbic.logging.service.LoggerFactory; +import life.qbic.projectmanagement.application.AuthenticationToUserIdTranslationService; +import life.qbic.projectmanagement.application.associated_dataset.ExternalCredentialService; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * External Providers configuration page. + * + *

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) ── + List statuses = + credentialService.listCredentialStatuses(userId()); + + if (statuses.isEmpty()) { + content.add(new Paragraph( + "No external data source instances are currently configured. " + + "Contact your administrator to add instances.")); + return; + } + + for (var status : statuses) { + content.add(renderInstanceCard(status)); + } + } + + // ── Card rendering ───────────────────────────────────────────── + + private Div renderInstanceCard( + ExternalCredentialService.CredentialStatusView status) { + String stateClass; + if (!status.configured()) { + stateClass = "instance-card--not-connected"; + } else if ("INVALIDATED".equals(status.status())) { + stateClass = "instance-card--invalidated"; + } else { + stateClass = "instance-card--connected"; + } + + var card = new Div(); + card.addClassNames("instance-card", stateClass); + + // ── Provider identity ── + String name = status.instanceDisplayName(); + String baseUrl = status.instanceBaseUrl(); + + // Header: name + URL (left), action button (right) + var header = new Div(); + header.addClassNames("instance-card__header"); + + var identity = new Div(); + identity.addClassNames("instance-card__identity"); + + var providerName = new Span(name); + providerName.addClassNames("font-bold", "text-size-l"); + + if (baseUrl != null && !baseUrl.isBlank()) { + String displayUrl = baseUrl.endsWith("/") + ? baseUrl.substring(0, baseUrl.length() - 1) + : baseUrl; + var urlAnchor = new Anchor(baseUrl + "/", displayUrl); + urlAnchor.addClassNames("instance-card__url", "text-size-s"); + urlAnchor.setTarget(AnchorTarget.BLANK); + identity.add(providerName, urlAnchor); + } else { + identity.add(providerName); + } + + header.add(identity); + + // Action buttons (top-right) + if ("INVALIDATED".equals(status.status())) { + // Side-by-side Reconnect + Disconnect for invalid state + var buttonGroup = new Div(); + buttonGroup.addClassNames("instance-card__action-group"); + buttonGroup.add(buildReconnectButton(status)); + buttonGroup.add(buildDisconnectButton(status)); + header.add(buttonGroup); + } else if (status.configured()) { + var actionButton = buildDisconnectButton(status); + actionButton.addClassNames("instance-card__action"); + header.add(actionButton); + } else { + var actionButton = buildConnectButton(status); + actionButton.addClassNames("instance-card__action"); + header.add(actionButton); + } + card.add(header); + + // Body: status label + date + description + var body = new Div(); + body.addClassNames("instance-card__body"); + + var statusLine = new Div(); + statusLine.addClassNames("instance-card__status-line"); + + if (status.configured()) { + if ("INVALIDATED".equals(status.status())) { + var tag = new Tag("Token invalid"); + tag.setTagColor(TagColor.ERROR); + statusLine.add(tag); + } else { + var tag = new Tag("Connected"); + tag.setTagColor(TagColor.SUCCESS); + statusLine.add(tag); + } + if (status.configuredAt() != null) { + var dateSep = new Span("·"); + dateSep.getStyle().set("color", "var(--lumo-contrast-30pct)"); + dateSep.addClassNames("extra-small-body-text"); + + String dateText = "since " + + DATE_FMT.format( + LocalDate.ofInstant(status.configuredAt(), ZoneOffset.UTC)); + var dateSpan = new Span(dateText); + dateSpan.addClassNames("small-body-text"); + dateSpan.getStyle().set("color", SECONDARY_COLOR); + + statusLine.add(dateSep, dateSpan); + } + } else { + var tag = new Tag("Not connected"); + tag.setTagColor(TagColor.CONTRAST); + statusLine.add(tag); + } + + body.add(statusLine); + + var description = new Paragraph(describeProvider(status, name)); + description.addClassNames("instance-card__description"); + body.add(description); + card.add(body); + + return card; + } + + // ── Descriptive text per state ───────────────────────────────── + + private String describeProvider( + ExternalCredentialService.CredentialStatusView status, + String name) { + if (!status.configured()) { + return "Connect your account to link " + name + + " datasets to your projects."; + } + if ("INVALIDATED".equals(status.status())) { + return "Your token was rejected by " + name + + " — please reconnect to restore access."; + } + return "Your personal access token allows Data Manager to read " + + "your access-restricted datasets on " + name + "."; + } + + // ── Action buttons ───────────────────────────────────────────── + + private Button buildConnectButton( + ExternalCredentialService.CredentialStatusView status) { + var button = new Button("Connect", VaadinIcon.PLUS.create()); + button.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + button.addClickListener( + e -> openAddTokenDialog(status)); + return button; + } + + /** Reconnect button — visually identical to Connect. */ + private Button buildReconnectButton( + ExternalCredentialService.CredentialStatusView status) { + var button = new Button("Reconnect", VaadinIcon.RECYCLE.create()); + button.addThemeVariants(ButtonVariant.LUMO_PRIMARY); + button.addClickListener( + e -> openAddTokenDialog(status)); + return button; + } + + private Button buildDisconnectButton( + ExternalCredentialService.CredentialStatusView status) { + var button = new Button("Disconnect", VaadinIcon.TRASH.create()); + button.addThemeVariants( + ButtonVariant.LUMO_TERTIARY_INLINE, + ButtonVariant.LUMO_ERROR); + button.addClickListener( + e -> confirmDisconnectToken(status)); + return button; + } + + // ── Disconnect confirmation ──────────────────────────────────── + + private void confirmDisconnectToken( + ExternalCredentialService.CredentialStatusView status) { + AlertDialog.alert(this) + .danger() + .title("Disconnect from " + status.instanceDisplayName() + "?") + .message("This will remove your personal access token for " + + status.instanceDisplayName() + + ". You will need to reconnect to link datasets " + + "from this instance again.") + .confirmButton("Disconnect", + () -> performDisconnect(status)) + .cancelButton("Cancel", () -> { /* dismiss only */ }) + .build() + .open(); + } + + private void performDisconnect( + ExternalCredentialService.CredentialStatusView status) { + boolean removed = credentialService.removeCredential( + userId(), status.instanceId()); + if (removed) { + showToast("Disconnected from " + status.instanceDisplayName(), + NotificationVariant.LUMO_WARNING); + } else { + showToast("No token found to remove for " + + status.instanceDisplayName(), + NotificationVariant.LUMO_WARNING); + } + renderContent(); + // If the verification sidebar is open, refresh it to reflect the + // new NOT_CONFIGURED state after disconnect + if (verificationSidebar.getClassNames().contains("vs-open")) { + verificationSidebar.refresh(); + } + } + + // ── Connect → Add Token dialog (AC-3, AC-4, AC-5) ───────────── + + /** + * Opens the Add Token dialog for the given instance ID. + * Used by both the card "Connect"/"Reconnect" buttons and the + * sidebar's Reconnect request event. + */ + private void openAddTokenDialog(String instanceId) { + List all = + credentialService.listCredentialStatuses(userId()); + var target = all.stream() + .filter(s -> s.instanceId().equals(instanceId)) + .findFirst(); + if (target.isEmpty()) { + showToast("Unknown instance: " + instanceId, + NotificationVariant.LUMO_WARNING); + return; + } + openAddTokenDialog(target.get()); + } + + private void openAddTokenDialog( + ExternalCredentialService.CredentialStatusView status) { + String tokenUrl = buildTokenCreationUrl( + status.instanceBaseUrl()); + + var dialog = AppDialog.medium(); + + var tokenInput = new TokenInput( + extractProviderName(status.instanceDisplayName()), + tokenUrl); + + var spinner = new Div(); + spinner.addClassName("spinner"); + + var loadingLabel = new Span( + "Validating your token with " + + extractProviderName(status.instanceDisplayName()) + + "…"); + loadingLabel.addClassName("token-loading-label"); + + var loadingHint = new Span("This may take a few seconds."); + loadingHint.addClassName("token-loading-hint"); + + var loadingOverlay = new Div(); + loadingOverlay.addClassNames("token-loading-overlay"); + loadingOverlay.add(spinner, loadingLabel, loadingHint); + loadingOverlay.getElement().getStyle() + .set("display", "none"); + + var bodyWrapper = new Div(tokenInput, loadingOverlay); + bodyWrapper.getElement().getStyle().set("position", "relative"); + + DialogHeader.with(dialog, + "Connect to " + extractProviderName(status.instanceDisplayName())); + DialogBody.with(dialog, bodyWrapper, tokenInput); + DialogFooter.with(dialog, "Cancel", "Validate & Save"); + + dialog.registerCancelAction(dialog::close); + + var validationRunning = new AtomicBoolean(false); + + dialog.registerConfirmAction(() -> { + if (!validationRunning.compareAndSet(false, true)) { + return; + } + tokenInput.setEnabled(false); + loadingOverlay.getElement().getStyle() + .set("display", "flex"); + + final char[] token = tokenInput.getToken(); + final var userIdCurrent = userId(); + uiHandle.bind(UI.getCurrent()); + final var securityContext = SecurityContextHolder.getContext(); + + CompletableFuture.supplyAsync(() -> { + SecurityContextHolder.setContext(securityContext); + try { + return credentialService.addCredential( + userIdCurrent, status.instanceId(), token); + } finally { + SecurityContextHolder.clearContext(); + } + }).whenComplete((result, throwable) -> { + uiHandle.onUiAndPush(() -> { + loadingOverlay.getElement().getStyle() + .set("display", "none"); + tokenInput.setEnabled(true); + validationRunning.set(false); + + if (throwable != null) { + tokenInput.setError( + "Could not validate the token. Please try again."); + showToast("Token validation failed due to an unexpected error.", + NotificationVariant.LUMO_ERROR); + log.error("Credential validation threw unexpectedly for instance '" + + status.instanceId() + "': " + throwable.getMessage(), + throwable); + return; + } + + if (result instanceof ExternalCredentialService.Success) { + tokenInput.clearField(); + dialog.close(); + showToast( + "Token validated successfully. " + + extractProviderName(status.instanceDisplayName()) + + " is now connected.", + NotificationVariant.LUMO_SUCCESS); + renderContent(); + // If the verification sidebar is open, refresh it so its + // rows reflect the newly-VALID state (not the stale red row) + if (verificationSidebar.getClassNames().contains("vs-open")) { + verificationSidebar.refresh(); + } + + } else if (result + instanceof ExternalCredentialService.InvalidToken inv) { + tokenInput.setError( + "Token was rejected by " + + extractProviderName(status.instanceDisplayName()) + + ". Please check and try again."); + showToast("Token validation failed: " + inv.reason() + + ". Please check your token and try again.", + NotificationVariant.LUMO_ERROR); + + } else if (result + instanceof ExternalCredentialService.ServiceError err) { + tokenInput.setError( + "Could not validate the token now. Please try again later."); + showToast("Token validation failed due to a transient error: " + + err.reason() + ". Please try again later.", + NotificationVariant.LUMO_ERROR); + log.warn("Credential validation service error for instance '" + + status.instanceId() + "': " + err.reason()); + + } else if (result + instanceof ExternalCredentialService.UnknownInstance) { + tokenInput.setError("Unknown instance."); + log.error("Add-token attempted for unknown instance '" + + status.instanceId() + "'"); + } + }); + }); + }); + + dialog.open(); + } + + // ── Helpers ─────────────────────────────────────────────────── + + private String extractProviderName(String displayName) { + if (displayName == null) { + return "this provider"; + } + int open = displayName.indexOf('('); + if (open > 0) { + return displayName.substring(0, open).trim(); + } + return displayName; + } + + static String buildTokenCreationUrl(String baseUrl) { + if (baseUrl == null || baseUrl.isBlank()) { + return "#"; + } + String normalized = baseUrl.endsWith("/") + ? baseUrl : baseUrl + "/"; + return normalized + "account/settings/applications/"; + } + + private String userId() { + return userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()) + .orElseThrow(); + } + + private void showToast( + String message, NotificationVariant variant) { + var notification = new Notification(); + notification.addThemeVariants(variant); + notification.setPosition( + Notification.Position.BOTTOM_START); + notification.setDuration(4000); + notification.add(new Span(message)); + notification.open(); + } + +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/account/TokenInput.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/TokenInput.java new file mode 100644 index 000000000..be9d24e21 --- /dev/null +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/account/TokenInput.java @@ -0,0 +1,122 @@ +package life.qbic.datamanager.views.account; + +import com.vaadin.flow.component.html.Anchor; +import com.vaadin.flow.component.html.AnchorTarget; +import com.vaadin.flow.component.html.Div; +import com.vaadin.flow.component.html.Paragraph; +import com.vaadin.flow.component.html.Span; +import com.vaadin.flow.component.textfield.PasswordField; +import life.qbic.datamanager.views.general.dialog.InputValidation; +import life.qbic.datamanager.views.general.dialog.UserInput; + +/** + * Token input component for the "Add External Provider Token" dialog. + * + *

Wraps 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 Map rowMap = new ConcurrentHashMap<>(); + + /** Maps instanceId → display name, captured at run start for stable rendering. */ + private final Map displayNameMap = new ConcurrentHashMap<>(); + + public VerificationSidebar( + ExternalCredentialService credentialService, + AuthenticationToUserIdTranslationService userIdTranslator) { + this.credentialService = credentialService; + this.userIdTranslator = userIdTranslator; + + addClassName("verification-sidebar"); + getStyle().set("display", "none"); + + // ── Overlay (backdrop) ── + overlay = new Div(); + overlay.addClassNames("vs-backdrop"); + overlay.addClickListener(e -> close()); + add(overlay); + + // ── Panel ── + panel = new Div(); + panel.addClassNames("vs-panel"); + + var body = new Div(); + body.addClassNames("vs-body"); + + // Header + var header = new Div(); + header.addClassNames("vs-header"); + + var titleSpan = new Span("Verify connections"); + titleSpan.addClassNames("vs-title", "heading-3"); + + var closeButton = new Button(VaadinIcon.CLOSE_SMALL.create()); + closeButton.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + closeButton.setTooltipText("Close"); + closeButton.addClickListener(e -> close()); + + header.add(titleSpan, closeButton); + body.add(header); + + // Content: Scrollable list of provider rows + contentContainer = new Div(); + contentContainer.addClassNames("vs-content"); + body.add(contentContainer); + + panel.add(body); + add(panel); + } + + // ── Public API ────────────────────────────────────────────────────── + + /** + * Opens the sidebar and starts a fresh verification run. + * If the sidebar is already open, use {@link #refresh()} instead + * so the user sees the "Reset + verify" behaviour rather than + * two concurrent runs. + */ + public void open() { + uiHandle.bind(UI.getCurrent()); + getStyle().set("display", "block"); + // Force a reflow so the CSS transform animation can run + panel.getElement().executeJs(""); + // Clear any previous state so repeated open() calls don't duplicate rows + contentContainer.removeAll(); + rowMap.clear(); + displayNameMap.clear(); + addClassName("vs-open"); + runVerification(); + } + + /** Closes the sidebar immediately (no slide-out transition). */ + public void close() { + removeClassName("vs-open"); + getStyle().set("display", "none"); + uiHandle.unbind(); + // Notify the parent view so it can re-fetch credential statuses. + // This catches every exit path (close button, backdrop click, programmatic + // close) in a single hook — the main view then re-renders with the + // freshest state, including any INVALIDATED transitions that the + // validation run may have persisted to the database. + fireEvent(new SidebarClosedEvent(this)); + } + + /** + * Clears current rows and starts a new verification run. + * Use when the user clicks the toolbar button while the sidebar + * is already open. + */ + public void refresh() { + contentContainer.removeAll(); + rowMap.clear(); + displayNameMap.clear(); + runVerification(); + } + + public Registration addReconnectRequestListener( + ComponentEventListener listener) { + return addListener(ReconnectRequestEvent.class, listener); + } + + /** + * Registers a listener that fires once per verification run when every + * configured provider has produced a result. The listener runs on the + * UI thread, so it is safe to trigger a full-page re-render from it. + */ + public Registration addValidationsCompletedListener( + ComponentEventListener listener) { + return addListener(ValidationsCompletedEvent.class, listener); + } + + /** + * Registers a listener that fires every time the sidebar is closed — + * regardless of exit path (button, backdrop, programmatic). + */ + public Registration addSidebarClosedListener( + ComponentEventListener listener) { + return addListener(SidebarClosedEvent.class, listener); + } + + // ── Verification logic ──────────────────────────────────────────── + + private void runVerification() { + String userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()).orElseThrow(); + + // Capture security context for propagation to async worker threads + SecurityContext securityContext = SecurityContextHolder.getContext(); + + // Fetch a fresh list of statuses up-front (one query, not N) + var statuses = credentialService.listCredentialStatuses(userId); + + // 1. Render all rows: configured ones start with a spinner, others + // show "Not connected" immediately. + for (var status : statuses) { + displayNameMap.put(status.instanceId(), status.instanceDisplayName()); + Div row = status.configured() + ? buildPendingRow(status.instanceDisplayName()) + : buildNotConnectedRow(status.instanceDisplayName()); + rowMap.put(status.instanceId(), row); + contentContainer.add(row); + } + + // 2. Fire async validation ONLY for providers that have a token. + // Unconfigured providers stay in the "Not connected" state — no + // validation is attempted, respecting ADR-0002 (no silent status + // changes without explicit user action). + // + // Track how many configured providers are still in flight so we + // can fire a single "all-done" event once every async call has + // settled — the main view listens for this and refreshes its + // cards to reflect the new INVALIDATED / VALID statuses. + final int configuredCount = (int) statuses.stream() + .filter(CredentialStatusView::configured).count(); + if (configuredCount == 0) { + // Nothing to validate — emit completion immediately so the main + // view can still refresh (e.g. after a second run). + fireEvent(new ValidationsCompletedEvent(this)); + } else { + var remaining = + new java.util.concurrent.atomic.AtomicInteger(configuredCount); + for (var status : statuses) { + if (status.configured()) { + triggerValidation(status, securityContext, remaining); + } + } + } + } + + private void triggerValidation( + CredentialStatusView status, + SecurityContext securityContext, + java.util.concurrent.atomic.AtomicInteger remainingInFlight) { + + CompletableFuture.supplyAsync(() -> { + SecurityContextHolder.setContext(securityContext); + try { + String userId = userIdTranslator.translateToUserId( + SecurityContextHolder.getContext().getAuthentication()).orElseThrow(); + return credentialService.validateCredential(userId, status.instanceId()); + } finally { + SecurityContextHolder.clearContext(); + } + }).whenComplete((result, throwable) -> { + uiHandle.onUiAndPush(() -> { + if (throwable != null) { + // Transient failure — keep it informative; the user can retry. + updateRowToStatus(status.instanceId(), + "Error", null, false, true, false); + } else if (result instanceof Success) { + updateRowToStatus(status.instanceId(), + "Valid", null, true, false, false); + } else if (result instanceof InvalidToken inv) { + // Token was there but is now rejected — ERROR state, NOT skipped. + // The row must render as red "Invalid" with a Reconnect link so + // the user can recover from either the sidebar or the main page + // (where the card mirrors the same INVALIDATED state). + updateRowToStatus(status.instanceId(), + "Invalid", inv.reason(), false, false, false); + } else { + // Unknown / unhandled result form — defensive fallback + updateRowToStatus(status.instanceId(), + "Error", null, false, true, false); + } + + // Last validation to finish — notify the main view to refresh + // its cards so the invalidation is reflected there as well. + if (remainingInFlight.decrementAndGet() == 0) { + fireEvent(new ValidationsCompletedEvent(this)); + } + }); + }); + } + + // ── Row construction ────────────────────────────────────────────── + + /** + * Initial placeholder row for a configured provider: spinner + "Verifying...". + */ + private Div buildPendingRow(String displayName) { + var row = new Div(); + row.addClassNames("vs-row", "vs-row--pending"); + + var identity = new Div(); + identity.addClassNames("vs-row__identity"); + + var nameSpan = new Span(displayName); + nameSpan.addClassNames("vs-row__name"); + + var statusDiv = new Div(); + statusDiv.addClassNames("vs-row__status"); + + var spinner = new Div(); + spinner.addClassName("vs-spinner"); + + var label = new Span("Verifying…"); + label.addClassName("vs-status-label--pending"); + + statusDiv.add(spinner, label); + identity.add(nameSpan, statusDiv); + row.add(identity); + return row; + } + + /** + * Immediate "skipped" row for a provider without a token. No spinner, no + * validation is attempted. + */ + private Div buildNotConnectedRow(String displayName) { + var row = new Div(); + row.addClassNames("vs-row", "vs-row--skipped"); + + var identity = new Div(); + identity.addClassNames("vs-row__identity"); + + var nameSpan = new Span(displayName); + nameSpan.addClassNames("vs-row__name"); + + var statusDiv = new Div(); + statusDiv.addClassNames("vs-row__status", "vs-status--skipped"); + + var icon = VaadinIcon.PLUG.create(); + icon.getStyle().set("color", "var(--lumo-tertiary-text-color)"); + statusDiv.add(icon); + statusDiv.add(new Span("Not connected")); + + identity.add(nameSpan, statusDiv); + row.add(identity); + return row; + } + + /** + * Updates an existing row (identified by instance id) to its final + * state. The row is rebuilt in place so the user sees an in-place + * transition rather than a flash. + * + * @param instanceId stable instance key + * @param message short status text ("Valid", "Invalid", "Error") + * @param invalidReason optional detail for {@code Invalid} rows + * @param isValid true → green row + * @param isError true → red row for transient failure + * @param isSkipped true → grey "skipped from verification" styling + */ + private void updateRowToStatus( + String instanceId, + String message, + String invalidReason, + boolean isValid, + boolean isError, + boolean isSkipped) { + + Div row = rowMap.get(instanceId); + if (row == null) { + return; + } + + String displayName = displayNameMap.getOrDefault(instanceId, instanceId); + + // Reset state classes that may have been set during the pending render + row.removeClassName("vs-row--pending"); + row.removeClassName("vs-row--valid"); + row.removeClassName("vs-row--invalid"); + row.removeClassName("vs-row--skipped"); + + if (isSkipped) { + row.addClassName("vs-row--skipped"); + } else if (isError) { + row.addClassName("vs-row--invalid"); + } else if (isValid) { + row.addClassName("vs-row--valid"); + } + + // Clear and rebuild + row.removeAll(); + + var identity = new Div(); + identity.addClassNames("vs-row__identity"); + + var nameSpan = new Span(displayName); + nameSpan.addClassNames("vs-row__name"); + + var statusDiv = new Div(); + statusDiv.addClassNames("vs-row__status"); + + if (isSkipped) { + var icon = VaadinIcon.PLUG.create(); + icon.getStyle().set("color", "var(--lumo-tertiary-text-color)"); + statusDiv.add(icon); + statusDiv.add(new Span("Not connected")); + } else if (isValid) { + var icon = VaadinIcon.CHECK_CIRCLE.create(); + icon.getStyle().set("color", "var(--lumo-success-color)"); + statusDiv.add(icon); + var label = new Span(message); + label.addClassName("vs-status-label--valid"); + statusDiv.add(label); + } else { + // Invalid or Error + VaadinIcon iconSymbol = isError + ? VaadinIcon.EXCLAMATION_CIRCLE_O + : VaadinIcon.EXCLAMATION_CIRCLE_O; + var icon = iconSymbol.create(); + icon.getStyle().set("color", "var(--lumo-error-color)"); + statusDiv.add(icon); + var label = new Span(message != null ? message : "Error"); + label.addClassName("vs-status-label--invalid"); + statusDiv.add(label); + if (invalidReason != null && !invalidReason.isBlank()) { + var reasonSpan = new Span("— " + invalidReason); + reasonSpan.addClassName("vs-invalid-reason"); + identity.add(reasonSpan); + } + } + + identity.add(nameSpan, statusDiv); + row.add(identity); + + // Actions column: Reconnect is only meaningful when the row shows + // an invalidated token (explicit user recovery path per ADR-0002). + if (!isValid && !isSkipped && !isError) { + var actions = new Div(); + actions.addClassNames("vs-row__actions"); + var reconnect = new Button("Reconnect"); + reconnect.addClassName("vs-reconnect-link"); + reconnect.addThemeVariants(ButtonVariant.LUMO_TERTIARY_INLINE); + reconnect.addClickListener(e -> + fireEvent(new ReconnectRequestEvent(this, instanceId))); + actions.add(reconnect); + row.add(actions); + } + } + + // ── Events ──────────────────────────────────────────────────────── + + /** + * Fired when the user clicks the inline "Reconnect" link in an + * invalidated row. The parent view opens the token input dialog + * (overlay) without closing this sidebar. + */ + public static class ReconnectRequestEvent + extends ComponentEvent { + + @Serial private static final long serialVersionUID = 1L; + + private final String instanceId; + + public ReconnectRequestEvent( + VerificationSidebar source, String instanceId) { + super(source, false); + this.instanceId = instanceId; + } + + /** Provider instance identifier (e.g. {@code "zenodo"}). */ + public String getInstanceId() { + return instanceId; + } + } + + /** + * Fired once per {@link #runVerification()} invocation when every + * configured provider's async validation has settled — success, + * failure, or rejection. Listeners can use this moment to re-paint + * dependent surfaces (e.g. the main provider cards, which must + * reflect the new {@code INVALIDATED} status). + */ + public static class ValidationsCompletedEvent + extends ComponentEvent { + + @Serial private static final long serialVersionUID = 1L; + + public ValidationsCompletedEvent(VerificationSidebar source) { + super(source, false); + } + } + + /** + * Fired every time the sidebar is closed — regardless of the exit path + * (close button, backdrop click, or programmatic {@link #close()} call). + * The parent view uses this as the signal that the drawer is no longer + * overlaying the page and the underlying cards should re-read their + * status from the database so any in-run transitions (e.g. a token + * proving invalid mid-flight) are reflected immediately. + */ + public static class SidebarClosedEvent + extends ComponentEvent { + + @Serial private static final long serialVersionUID = 1L; + + public SidebarClosedEvent(VerificationSidebar source) { + super(source, false); + } + } + +} diff --git a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java index 2f2a323bd..cf1aacd6b 100644 --- a/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java +++ b/datamanager-app/src/main/java/life/qbic/datamanager/views/general/DataManagerMenu.java @@ -9,6 +9,7 @@ import com.vaadin.flow.component.menubar.MenuBarVariant; import com.vaadin.flow.spring.security.AuthenticationContext; import java.util.Objects; +import life.qbic.datamanager.views.account.ExternalProvidersMain; import life.qbic.datamanager.views.account.PersonalAccessTokenMain; import life.qbic.datamanager.views.account.UserAvatar; import life.qbic.datamanager.views.account.UserProfileMain; @@ -48,6 +49,7 @@ private void initializeUserSubMenuItems(AuthenticationContext authenticationCont SubMenu userSubMenu = userMenuItem.getSubMenu(); userSubMenu.addItem("Personal Access Tokens (PAT)", event -> routeTo( PersonalAccessTokenMain.class)); + userSubMenu.addItem("External Providers", event -> routeTo(ExternalProvidersMain.class)); userSubMenu.addItem("User Profile", event -> routeTo(UserProfileMain.class)); userSubMenu.addItem("Log Out", event -> authenticationContext.logout()); } diff --git a/datamanager-app/src/main/resources/application.properties b/datamanager-app/src/main/resources/application.properties index df61a02d3..b2d643a8f 100644 --- a/datamanager-app/src/main/resources/application.properties +++ b/datamanager-app/src/main/resources/application.properties @@ -11,6 +11,11 @@ logging.file.path=${DM_LOG_PATH:./logs} qbic.security.vault.key.env=DATAMANAGER_VAULT_KEY qbic.security.vault.path=${DATAMANAGER_VAULT_PATH:keystore.p12} qbic.security.vault.entry.password.env=DATAMANAGER_VAULT_ENTRY_PASSWORD +# Dedicated master AES-256 key alias for encrypting user external-provider +# credentials (FEAT-DATSET-14, ADR-0002 S2). The key material itself lives in +# the PKCS12 keystore (deployed at startup); this property names the alias. +# Distinct from the OpenBIS vault entries. +qbic.security.vault.external-credential.key-alias=external-credential-master-key ################### Datasources ############################################### # Using Spring (Boot), you can provide multiple datasources. Each datasource # can be configured by properties in this file. diff --git a/docs/implementation-plan/feat-datset-14-checklist.md b/docs/implementation-plan/feat-datset-14-checklist.md new file mode 100644 index 000000000..674a8b280 --- /dev/null +++ b/docs/implementation-plan/feat-datset-14-checklist.md @@ -0,0 +1,285 @@ +# Checklist: FEAT-DATSET-14 Implementation Review + +> Companion to [`feat-datset-14-invenio-rdm-credentials.md`](feat-datset-14-invenio-rdm-credentials.md). +> Each checkpoint is self-contained: one logical implementation step that can be built, committed, +> and reviewed in one pass. Reviewers should be able to walk through in the order listed and +> confidently confirm each boundary before moving on to the next. + +**Legend:** +- 🔒 — security-critical check +- 🧱 — layer-boundary / architecture check +- 📐 — ADR compliance check + +--- + +## Checkpoint 1 — Database migration: `user_external_credential` + +> **Plan ref:** §4 Task 1 +> **Deliverables:** `sql/migrations/create-user-external-credential.sql`, updated `sql/complete-schema.sql` +> **Blocks:** all subsequent checkpoints + +### Reviewer checks + +- [ ] 📐 Table name is `user_external_credential` (generalised, not `user_invenio_rdm_credential`) +- [ ] 📐 Columns include `source_type varchar(32) NOT NULL` alongside `instance_id` (consistent with `associated_dataset` table) +- [ ] Unique key is on `(user_id, source_type, instance_id)` — three columns, not two +- [ ] `encrypted_token` is `VARBINARY(512)` with a comment documenting the AES-GCM blob layout (`nonce ‖ ciphertext ‖ tag`) +- [ ] `status` is `VARCHAR(16)` with only two values documented: `VALID`, `INVALIDATED` +- [ ] Migration file header references FEAT-DATSET-14 and the ADRs (0002, 0003) +- [ ] Migration is idempotent (uses `CREATE TABLE IF NOT EXISTS`) +- [ ] `complete-schema.sql` was updated in parallel with the migration (kept in sync per project convention) + +--- + +## Checkpoint 2 — Vault configuration property + +> **Plan ref:** §4 Task 9 +> **Deliverables:** new entry in `application.properties`, ops runbook note +> **Blocks:** Checkpoint 3 + +### Reviewer checks + +- [ ] Property key: `qbic.security.vault.external-credential.key-alias=external-credential-master-key` (or documented alternative) +- [ ] Property comment clearly states the key is dedicated to credential encryption and is distinct from the existing OpenBIS vault entries +- [ ] Documentation notes the ops prerequisite: the PKCS12 keystore must have a corresponding entry at deploy time before the application can start +- [ ] 🔒 No default/placeholder AES key value is committed (the key material comes from the environment/vault deployment, not from `application.properties`) + +--- + +## Checkpoint 3 — `ExternalCredentialEncryptor` (AES-256-GCM) + +> **Plan ref:** §4 Task 2 +> **Files:** `infrastructure/external/ExternalCredentialEncryptor.java` (interface), +> `infrastructure/external/AesGcmCredentialEncryptor.java` (impl) +> **Note:** Interface lives in the **provider-agnostic** `infrastructure/external/` package — not under `infrastructure/external/invenio/` +> **Blocks:** Checkpoints 4, 6, 7 + +### Reviewer checks + +- [ ] 🧱 Interface is in `life.qbic.projectmanagement.infrastructure.external` (not under a provider-specific sub-package) — encryption is provider-agnostic +- [ ] 🔒 Algorithm is `AES/GCM/NoPadding`, 256-bit key, 96-bit nonce, 128-bit tag +- [ ] 🔒 A **fresh random 12-byte nonce is generated per encryption call** (verified via `SecureRandom` or `SecureRandom.getInstanceStrong()` — no nonce caching, no counter reuse) +- [ ] 🔒 Output format is `nonce (12 bytes) ‖ ciphertext ‖ tag (16 bytes)` — documented in Javadoc +- [ ] 🔒 `decrypt()` returns `char[]` (not `String`) to enable zeroing by caller +- [ ] 🔒 Master key is loaded **once** at bean creation and held as a `javax.crypto.SecretKey` field +- [ ] 🔒 Master key value is never returned from any method, never logged, never included in `toString()`/`hashCode()`/exception messages +- [ ] 🔒 Exception messages do not include plaintext token value +- [ ] 🔒 Encryptor constructor validates that the master key material is exactly 32 bytes (AES-256); rejects AES-128/AES-192 keys with a clear error message +- [ ] 🔒 Spring configuration (`InvenioRdmConfiguration.credentialEncryptor`) reads the vault entry as a Base64-encoded string, decodes it, and validates the decoded length is exactly 32 bytes before constructing the `SecretKey` +- [ ] 🔒 Vault provisioning instructions document that the key must be stored Base64-encoded (e.g., `openssl rand -base64 32`) +- [ ] Spock spec `AesGcmCredentialEncryptorSpec` exists and covers: round-trip, nonce uniqueness, wrong-key failure, corrupted-data failure, key-size validation (rejects too-short and too-long keys) +- [ ] Vault integration is lazy or fail-fast — missing alias throws a clear `DataManagerVaultException` at startup, not a `KeyStoreException` deep in a request + +--- + +## Checkpoint 4 — Domain model: `UserExternalCredential` + repository port + +> **Plan ref:** §4 Task 3 +> **Files:** `domain/model/associated_dataset/UserExternalCredential.java`, +> `domain/model/associated_dataset/CredentialStatus.java`, +> `domain/model/associated_dataset/repository/UserExternalCredentialRepository.java` +> **Blocks:** Checkpoints 6, 7 + +### Reviewer checks + +- [ ] 🧱 All classes live in `life.qbic.projectmanagement.domain.model.associated_dataset` or its `repository` sub-package +- [ ] 🧱 **Zero imports from `*.infrastructure.*`** in these files (domain must not depend on infrastructure) +- [ ] 🧱 **No `char[]`, `String` token, or any plaintext credential field** on the entity — only `byte[] encryptedToken` +- [ ] `sourceType` is the existing `SourceType` enum (reused from `AssociatedDataset` — consistency with the aggregate) +- [ ] Entity carries: `id`, `userId`, `sourceType`, `instanceId`, `encryptedToken`, `status`, `createdAt`, `updatedAt` +- [ ] `CredentialStatus` has exactly two values: `VALID`, `INVALIDATED` +- [ ] Repository interface is in `repository` sub-package (not directly under `domain/model/`) +- [ ] Repository queries are parameterised by `sourceType` (the dispatcher needs to scope lookups by source) +- [ ] Entity is final or immutable where state transition is explicit (no public `setStatus()` that any caller can invoke from outside the domain) + +--- + +## Checkpoint 5 — JPA entity + Spring Data repository implementation + +> **Plan ref:** §4 Task 4 +> **Files:** `infrastructure/dataset/associated/UserExternalCredentialEntity.java`, +> `infrastructure/dataset/associated/UserExternalCredentialJpaRepository.java`, +> `infrastructure/dataset/associated/UserExternalCredentialRepositoryImpl.java` +> **Prerequisites for merge:** Checkpoints 1, 3, 4 pass + +### Reviewer checks + +- [ ] 🧱 `@Entity` is mapped to table `user_external_credential` (matching Checkpoint 1) +- [ ] 🧱 No domain type is annotated with `@Entity` — the JPA entity is a separate class from the domain entity +- [ ] `encryptedToken` is mapped as `byte[]` / `@Lob` with matching column definition +- [ ] `sourceType`, `status` are `@Enumerated(EnumType.STRING)` +- [ ] Unique constraint annotation matches the migration: `(userId, sourceType, instanceId)` +- [ ] Bidirectional mapping class exists: `UserExternalCredentialEntity ↔ UserExternalCredential` +- [ ] 🔒 Mapping never exposes the decrypted token — domain entity always holds `byte[]`, never a `String` or `char[]` derived from decryption +- [ ] Repository implementation correctly maps between entity and domain, including the `sourceType` parameter in queries +- [ ] `deleteByUserIdAndSourceTypeAndInstanceId` matches the method signature in the domain repository interface (Checkpoint 4) + +--- + +## Checkpoint 6 — `InvenioRdmClient.getAuthenticatedUser()` + `InvenioRdmCredentialValidatorAdapter` + +> **Plan ref:** §4 Task 5a–5c +> **Files:** `infrastructure/external/invenio/InvenioRdmClient.java` (addition), +> `infrastructure/external/CredentialValidatorAdapter.java`, +> `infrastructure/external/invenio/InvenioRdmCredentialValidatorAdapter.java` +> **Blocks:** Checkpoint 8 + +### Reviewer checks + +- [ ] 🔒 `getAuthenticatedUser(instanceUrl, authHeader)` uses the **spec-defined path** `GET /api/users` with `BearerAuth` (operationId: `getAUserById`) — not a guessed `/api/me` +- [ ] 🔒 The response DTO `AuthenticatedUserResponse` uses `@JsonIgnoreProperties(ignoreUnknown = true)` — the implementation must not rely on specific response fields, only the HTTP 200 status +- [ ] 🔒 Token is handled as `char[]` inside `InvenioRdmCredentialValidatorAdapter.validate()` — copied, used within a try/finally, zeroed with `Arrays.fill(tokenCopy, '\0')` in the finally block +- [ ] 🔒 Token is **never** converted to `String` and stored — the `"Bearer " + token` is built only for the HTTP request header, in local scope +- [ ] 🔒 Error mapping: 401/403 → returns `false` (token invalid); other 4xx → rethrown; 5xx/transient → wraps in `CredentialValidationException` +- [ ] 🧱 `CredentialValidatorAdapter` interface is in the **provider-agnostic** `infrastructure/external/` package — each provider has its own adapter; the dispatcher is source-type-aware, not provider-aware +- [ ] Existing retry policy (3 attempts, exponential backoff, HTTP 5xx/429, no retry on 401/403/404) is reused from `InvenioRdmClient.getWithRetry()` +- [ ] Tests: + - `InvenioRdmCredentialValidatorAdapterSpec`: 200 → `true`; 401 → `false`; 403 → `false`; transient → exception; token zeroed after call + - `InvenioRdmClientSpec.getAuthenticatedUser`: request URL, response DTO parsing, auth header threading + +--- + +## Checkpoint 7 — `SourceTypeDispatchingCredentialValidator` + +> **Plan ref:** §4 Task 5d +> **Files:** `infrastructure/external/SourceTypeDispatchingCredentialValidator.java` +> **Implementation:** `ExternalCredentialValidator` (application port) +> **Prerequisites for merge:** Checkpoint 6 passes +> **Blocks:** Checkpoint 8 + +### Reviewer checks + +- [ ] 🧱 Implements the application-layer port `ExternalCredentialValidator` — bridge from infrastructure into application +- [ ] 🧱 Adapter map is `Map` — keyed by source type enum, not by string +- [ ] Constructor validates that at least one adapter is registered (fail-fast at startup, not per-request) +- [ ] Map copy is defensive (`Map.copyOf(adapters)`) — cannot be mutated after construction +- [ ] 🔒 Unknown `SourceType` throws `CredentialValidationException` with a clear message — no silent fallback, no "best effort" dispatch +- [ ] 🔒 Token `char[]` is forwarded to the selected adapter — not copied again, not accumulated in a loggable collection +- [ ] Test: `SourceTypeDispatchingCredentialValidatorSpec` + - Dispatches to correct adapter for a registered `SourceType` + - Throws `CredentialValidationException` for unregistered `SourceType` + - Token passed to adapter unchanged (verifies the adapter reference receives the call) + +--- + +## Checkpoint 8 — Application service: `ExternalCredentialService` + +> **Plan ref:** §4 Task 6 +> **Files:** `application/associated_dataset/ExternalCredentialService.java` (interface), +> `application/associated_dataset/DefaultExternalCredentialService.java` (impl) +> **Prerequisites for merge:** Checkpoints 3, 5, 6, 7 pass + +### Reviewer checks + +- [ ] 🧱 Interface lives in `application` layer — no `infrastructure.*` imports +- [ ] 🧱 No plaintext token (`char[]`) escapes the `addCredential()` method beyond validation + storage: the method accepts `char[]`, passes it to the validator, then to the encryptor, then zeroes it in a `finally` — the return type is `AddCredentialResult`, not `char[]` +- [ ] 🔒 `addCredential()` zeroes the token in a `finally` block — verified by test (mock validator/encryptor; `Arrays.fill` call asserted via `char[]` content post-return) +- [ ] 🔒 Return type `AddCredentialResult` is a **sealed interface** with only `Success`, `InvalidToken`, `ServiceError`, `UnknownInstance` — no plaintext, no encrypted bytes +- [ ] 🔒 `listCredentialStatuses()` returns `CredentialStatusView` records that contain **no token value** — only `sourceType`, `instanceId`, `instanceDisplayName`, `configured`, `status` +- [ ] `addCredential()` resolves `SourceType` from the registry via `instanceId` — caller doesn't need to know the source type (decoupled from view-layer concerns) +- [ ] `addCredential()` flow (in order): + 1. Resolve instance + source type from `SourceInstanceRegistry` + 2. Call `ExternalCredentialValidator.validateToken(sourceType, config, token)` + 3. On failure → return `InvalidToken` (no persistence) + 4. On success → `encryptor.encrypt(token)` → `repository.save(credential)` → return `Success` + 5. Zero token in `finally` +- [ ] `addCredential()` handles idempotency: if a credential for `(userId, sourceType, instanceId)` already exists, the existing row is **replaced** (updated) with the new token — not rejected, not duplicated +- [ ] Test: `DefaultExternalCredentialServiceSpec` + - Valid token → persisted; encrypted blob stored; `Success` returned + - Invalid token → `InvalidToken` returned; repository **not** called to save + - Unknown instance → `UnknownInstance` returned + - Existing credential → replaced + - Transient validator failure → wrapped as `ServiceError`; zeroing still occurred + - Token content is all-zero after return (zeroing verification) + +--- + +## Checkpoint 9 — Wire token resolution into `InvenioRdmDatasetSource` + +> **Plan ref:** §4 Task 7 +> **Files:** `infrastructure/external/invenio/InvenioRdmDatasetSource.java` (modified constructor and methods) +> **Prerequisites for merge:** Checkpoints 3, 5 pass + +### Reviewer checks + +- [ ] 🔒 Token is resolved **inside** the `search()` / `resolveMetadata()` methods — not passed in as a parameter (the decryption boundary is enforced here) +- [ ] 🔒 Resolved token is `char[]`, decrypted by `encryptor.decrypt()`, used only to build `"Bearer " + token` for the HTTP `Authorization` header, then **zeroed in a `finally` block** — verified by test +- [ ] 🔒 No token value appears in any log statement, exception message, or return object +- [ ] Look-up uses `repository.findByUserIdAndSourceTypeAndInstanceId(actingUserId, SourceType.INVENIO_RDM, config.id())` — correctly scoped by source type +- [ ] No-token path: lookup returns empty → `authHeader` is `null` → client issues request without `Authorization` (public records only) — verified by test +- [ ] Constructor now takes `UserExternalCredentialRepository` and `ExternalCredentialEncryptor` as additional parameters +- [ ] Test: `InvenioRdmDatasetSourceSpec` (updated) + - Search with user who has token → HTTP request carries `Authorization: Bearer ...` + - Search with user who has no token → HTTP request carries no `Authorization` + - Token `char[]` content is all-`\0` after `search()` returns (zeroing assertion) + - Same pattern for `resolveMetadata()` + +--- + +## Checkpoint 10 — UI: External Providers view + Add Token dialog + +> **Plan ref:** §4 Task 8 +> **Files:** `views/account/ExternalProvidersMain.java`, `views/account/ExternalProvidersComponent.java`, +> `views/account/AddExternalCredentialTokenDialog.java`, +> `frontend/themes/datamanager/components/external-providers.css` +> **Prerequisites for merge:** Checkpoint 8 (service) passes + +### Reviewer checks + +- [ ] 🧱 View class uses `@Route("/account/external-providers", layout = UserMainLayout.class)` — sits alongside `/profile` and `/account` routes +- [ ] 🧱 View has `@PermitAll` — no additional ACL check is performed (credential management is user-scoped: the user only sees their own credentials) +- [ ] 🧱 View calls only `ExternalCredentialService` (application service) — **no direct access to `SourceInstanceRegistry`, `ExternalCredentialValidator`, or the repository** (layer discipline) +- [ ] 🔒 `AddExternalCredentialTokenDialog` uses `PasswordField` — no character echo +- [ ] 🔒 Dialog does not retain the token value in component state after submission — token is passed to the service and the field is immediately cleared (`passwordField.clear()` in both success and failure paths) +- [ ] 🔒 No token value is ever stored in `localStorage`, `sessionStorage`, Vaadin component state, or query parameters +- [ ] 🔒 Toast notification on validation failure: generic message ("Token validation failed. Please check your token and try again.") — no upstream error details, no token value, no internal error code +- [ ] Benefit paragraph (AC-6) is visible and explains the purpose of adding a token (connecting access-restricted datasets to a project) +- [ ] Instance list is loaded from `ExternalCredentialService.listCredentialStatuses(userId)` — not hard-coded instances +- [ ] Status indicators differentiate: + - 🟢 Connected / VALID + - 🔴 Not connected / NOT_CONFIGURED + - 🟡 (or neutral) INVALIDATED (future) +- [ ] External link points to `{baseUrl}/account/settings/applications/` for InvenioRDM instances — verified manually + +--- + +## Checkpoint 11 — Spring wiring + integration smoke test + +> **Plan ref:** §4 Task 10 +> **Files:** `datamanager-app/configuration/InvenioRdmConfiguration.java` (updated) +> **Prerequisites for merge:** All prior checkpoints pass + +### Reviewer checks + +- [ ] 🔒 All beans wired in a single `@Configuration` class with explicit constructor injection — no wildcard `@Autowired` field injection +- [ ] `SourceTypeDispatchingCredentialValidator` is registered with exactly one adapter for `SourceType.INVENIO_RDM` (today) — comment in `@Bean` method lists future adapters +- [ ] `ExternalCredentialService` is registered as the application-layer service (not `InvenioRdmCredentialService` — name change confirmed) +- [ ] `InvenioRdmDatasetSource` bean is updated to receive the repository + encryptor (Checkpoint 9 dependency) +- [ ] 🔒 Encryptor bean fails-fast at startup if the PKCS12 vault entry `external-credential-master-key` is missing +- [ ] 🔒 Token encryptor uses the **dedicated** master-key alias from `qbic.security.vault.external-credential.key-alias` — not shared with the OpenBIS vault entries +- [ ] `ExternalCredentialValidator` bean is typed as the composite dispatcher, not the individual adapter +- [ ] Smoke test (or `@SpringBootTest` integration test) runs end-to-end: token add → validation against a mock `/api/users` → encrypted write → read-back → decryption → search with `Authorization` header + +--- + +## Final review gate + +Before merging the feature branch, walk through the full security checklist: + +| Check | Where it's enforced | +|---|---| +| 🔒 Plaintext token is `char[]`, never `String` | Checkpoints 3, 6, 8, 9 | +| 🔒 Token zeroed with `Arrays.fill(char[], '\0')` in `finally` | Checkpoints 6, 8, 9 | +| 🔒 Master key in PKCS12; AES-256-GCM in DB | Checkpoints 2, 3 | +| 🔒 Decryption boundary: token decrypted only in infrastructure | Checkpoints 5, 8, 9 | +| 🔒 Application service never returns/holds token bytes | Checkpoint 8 | +| 🔒 UI never persists token in component state or storage | Checkpoint 10 | +| 🔒 Log statements never contain token value | Checkpoints 3, 6, 9 | +| 🔒 Validation endpoint is the spec-defined `GET /api/users` | Checkpoint 6 | +| 🔒 Generic user-facing error messages (no upstream leakage) | Checkpoints 6, 10 | +| 📐 Provider-agnostic composite dispatcher pattern | Checkpoints 3, 6, 7 | +| 📐 Generalised table name `user_external_credential` | Checkpoint 1 | +| 📐 `SourceType` reused from `AssociatedDataset` aggregate | Checkpoint 4 | +| 📐 ADR-0002 D1 decryption boundary enforced | Checkpoints 5, 6, 9 | +| 📐 ADR-0002 I2 admin-configured instances (no UI entry) | Checkpoint 10 | +| 📐 ADR-0002 T1 never-borrow-credentials (user's own token, per-instance) | Checkpoints 4, 9 | diff --git a/docs/implementation-plan/feat-datset-14-invenio-rdm-credentials.md b/docs/implementation-plan/feat-datset-14-invenio-rdm-credentials.md new file mode 100644 index 000000000..da018c706 --- /dev/null +++ b/docs/implementation-plan/feat-datset-14-invenio-rdm-credentials.md @@ -0,0 +1,1023 @@ +# Implementation Plan: FEAT-DATSET-14 — Add credentials for an InvenioRDM instance + +> **Story:** [FEAT-DATSET-14](https://github.com/qbicsoftware/data-manager-app/issues/1478) +> **Parent Feature:** [FEAT-DATASET-CONNECTION](https://github.com/qbicsoftware/data-manager-app/issues/1466) (#1466) +> **Requirement:** `DATA-R-03` (InvenioRDM Credential Management) +> **Branch:** `feature/feat-datset-14-invenio-rdm-credentials` +> **Depends on:** No prerequisite — this story is a prerequisite for FEAT-DATSET-05 (connecting restricted datasets) and FEAT-DATSET-06 (viewing restricted datasets) +> **ADRs:** [ADR-0002](../adr/0002-invenio-rdm-api-client-credentials.md) (credential storage + client), [ADR-0003](../adr/0003-connection-lifecycle-stewardship.md) (lifecycle) + +--- + +## 1. Story Summary + +> *As a researcher, I want to provide my access token for an available InvenioRDM instance, so that I can connect access-restricted resources in a project.* + +### Acceptance Criteria + +| # | Given | When | Then | +|---|---|---|---| +| AC-1 | User is in account settings | Navigates to "External Providers" | System offers a way to configure InvenioRDM instances | +| AC-2 | User is configuring instances | Views available instances | System displays the list of configured InvenioRDM instances (e.g. Zenodo, FDAT) | +| AC-3 | User pastes/enters a token | Submits | Token is validated against InvenioRDM API via `GET /api/users` ([`getAUserById`](https://inveniosoftware.github.io/invenio-openapi/), operationId: `getAUserById`) — the spec-defined authenticated-user endpoint; no parameters, security: `BearerAuth`; returns `200` with a JSON object on success, `401` on invalid/expired token | +| AC-4 | Validation fails | Token invalid/expired | Token **not** added; user informed it is invalid | +| AC-5 | Validation succeeds | Token valid | User informed token valid; instance now connected | +| AC-6 | User views the section | Sees benefit description | System explains that providing a token enables connection of access-restricted datasets | + +--- + +## 2. Architectural Context + +### 2.1 What already exists + +| Component | Layer | Module | Status | +|---|---|---|---| +| `DatasetSource` port (`search`, `resolveMetadata`) | Application | `project-management` | ✅ Done | +| `InvenioRdmDatasetSource` adapter | Infrastructure | `project-management-infrastructure` | ✅ Done (public-only; `actingUserId` param unused) | +| `InvenioRdmClient` interface + `InvenioRdmHttpClient` | Infrastructure | `project-management-infrastructure` | ✅ Done (`authHeader` param exists in retry flow but never populated by caller) | +| `SourceInstanceRegistry` + `PropertiesBackedSourceInstanceRegistry` | Application/Infrastructure | — | ✅ Done | +| `InvenioRdmProperties` (config binding) | Infrastructure | `project-management-infrastructure` | ✅ Done | +| `InvenioRdmConfiguration` (Spring bean wiring) | App | `datamanager-app` | ✅ Done | +| `associated_dataset` table + migration | DB | `sql/migrations/` | ✅ Done | +| `DataManagerVault` (PKCS12 keystore) | Infrastructure | `project-management-infrastructure` | ✅ Done | +| `/profile` and `/account` Vaadin routes | Views | `datamanager-app` | ✅ Done | +| `InstanceConfig` + `SourceInstanceDescriptor` value objects | Application | `project-management` | ✅ Done | + +### 2.2 What needs to be built + +| Component | Layer | Module | Plan section | +|---|---|---|---| +| `user_external_credential` DB table + migration | Schema | `sql/migrations/` | §4 Task 1 | +| `ExternalCredentialEncryptor` (AES-256-GCM) | Infrastructure | `project-management-infrastructure` | §4 Task 2 | +| `UserExternalCredential` domain entity | Domain | `project-management` | §4 Task 3 | +| `UserExternalCredentialRepository` domain interface | Domain | `project-management` | §4 Task 3 | +| JPA entity + Spring Data repo implementation | Infrastructure | `project-management-infrastructure` | §4 Task 4 | +| `InvenioRdmClient.getAuthenticatedUser()` — new endpoint method | Infrastructure | `project-management-infrastructure` | §4 Task 5 | +| Composite credential validator (dispatcher + per-provider adapters) | Infrastructure | `project-management-infrastructure` | §4 Task 5 | +| `ExternalCredentialValidator` port (application) | Application | `project-management` | §4 Task 5 | +| `ExternalCredentialService` (application orchestration) | Application | `project-management` | §4 Task 6 | +| Wire token resolution into `InvenioRdmDatasetSource` | Infrastructure | `project-management-infrastructure` | §4 Task 7 | +| `ExternalProvidersMain` Vaadin route (`/account/external-providers`) | Views | `datamanager-app` | §4 Task 8 | +| Dedicated master key entry in PKCS12 vault + config property | Config | `datamanager-app` | §4 Task 9 | +| Spring wiring: new beans in `InvenioRdmConfiguration` | App | `datamanager-app` | §4 Task 10 | +| Unit tests (Spock) + integration tests | Test | — | §4 Task 11 | + +--- + +## 3. Security Model + +### 3.1 Threat model + +| Threat vector | Mitigation | +|---|---| +| **At-rest exposure** (DB breach) | AES-256-GCM with per-token random 12-byte nonce. Dedicated master key in PKCS12 vault, deployed to HA nodes at deploy time. | +| **In-flight** | HTTPS only. All target instances (Zenodo, FDAT) are HTTPS. No plaintext over network. | +| **In-memory** | Plaintext token exists as `char[]` only in the infrastructure method performing the HTTP call. Zeroed in `finally` block (`Arrays.fill(token, '\0')`). | +| **Layer boundary leak** | Application layer never holds, logs, or serialises the plaintext token. Only `actingUserId` and `instanceId` cross layer boundaries. | +| **Logging leak** | Token values never logged. Logs carry user ID + instance ID only. | +| **Error message leak** | Generic user-facing errors ("Token validation failed"). Raw upstream errors to server logs only. | +| **Replay / unauthorized access to other users' tokens** | Repository queries enforce `user_id` filter. No cross-user lookup path exists. | +| **Admin / ops plaintext exposure** | Master key is in PKCS12 vault; ops has keystore access but the AES-GCM blobs require both key + nonce. Token values are not visible in DB dumps without the master key. | + +### 3.2 Decryption boundary (ADR-0002 D1) + +``` +┌───────────────────────────────────────────────────────────────────┐ +│ Views (UI) │ +│ → Never sees token. Only status (VALID/INVALIDATED) per instance. │ +├───────────────────────────────────────────────────────────────────┤ +│ Application layer (services) │ +│ → Passes (userId, instanceId). Never decrypts, never holds token │ +│ beyond the scope of the add/validate operation. │ +├───────────────────────────────────────────────────────────────────┤ +│ Infrastructure layer (adapters) │ +│ → Decrypts token to char[]. Passes as Bearer header in HTTP call. │ +│ → Zeroes char[] in finally block. Returns SearchResult, not token.│ +│ → Validates tokens via per-provider adapters dispatched by type. │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Never-borrow-credentials (ADR-0002 T1 + ADR-0003 C1) + +Each user owns their own tokens per instance. The system never uses one user's token to fulfil another user's action. Forensic correctness: external platform audit logs accurately reflect who initiated each call. + +--- + +## 4. Task Breakdown + +--- + +### Task 1: Database schema — `user_external_credential` + +**Files:** +- New: `sql/migrations/create-user-external-credential.sql` +- Update: `sql/complete-schema.sql` (append the table definition) + +**Schema (per ADR-0002 S2):** +```sql +-- ============================================================================= +-- Migration: Create `user_external_credential` table +-- Feature: FEAT-DATSET-14 — Add credentials for an InvenioRDM instance +-- ADRs: 0002 (credential storage), 0003 (lifecycle) +-- +-- Stores per-user, per-instance personal access tokens for external providers. +-- The table is source-agnostic at the schema level: `source_type` and +-- `instance_id` together identify the provider and instance. Today the only +-- rows will be INVENIO_RDM / zenodo | fdat. Future providers add rows with +-- a different source_type — no schema change required. +-- +-- Tokens are AES-256-GCM encrypted at rest (nonce + ciphertext + tag). +-- The master key is stored in the PKCS12 vault at deploy time. +-- +-- Unique constraint on (user_id, source_type, instance_id): one token per +-- user per instance. source_type is included in the unique key to keep the +-- door open for the same instance_id value across different providers in +-- a hypothetical future scenario. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `user_external_credential` +( + `id` varchar(36) NOT NULL, + `user_id` varchar(255) NOT NULL COMMENT 'DM user ID', + `source_type` varchar(32) NOT NULL COMMENT 'e.g. INVENIO_RDM — matches SourceType enum', + `instance_id` varchar(64) NOT NULL COMMENT 'matches InstanceConfig.id (e.g. zenodo, fdat)', + `encrypted_token` varbinary(512) NOT NULL COMMENT 'AES-256-GCM: 12-byte nonce + ciphertext + 16-byte auth tag', + `status` varchar(16) NOT NULL COMMENT 'VALID | INVALIDATED', + `created_at` timestamp(3) NOT NULL, + `updated_at` timestamp(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_src_instance` (`user_id`, `source_type`, `instance_id`), + KEY `idx_cred_user` (`user_id`), + KEY `idx_cred_user_src` (`user_id`, `source_type`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; +``` + +**Design rationale for generalised table name:** +- `user_external_credential` (not `user_invenio_rdm_credential`) — the table stores credentials for any external provider. Adding a second provider type (e.g. LIMS) requires zero schema changes. +- `source_type` column mirrors the `source_type` on `associated_dataset` — consistency with the existing aggregate and repository queries. +- `instance_id` references `InstanceConfig.id` which is already provider-agnostic. +- The unique key includes `source_type` so that theoretically the same `instance_id` string could coexist under different provider types, and practically to keep the schema fully generalised. + +**Notes:** +- `encrypted_token` is `VARBINARY(512)` — sufficient for AES-GCM nonce (12) + encrypted token (variable) + tag (16). Typical PATs are 64–128 hex chars. +- `instance_id` is plain-text, admin-configured, not secret. +- `status` has only two values in v1: `VALID` and `INVALIDATED`. Updated only on explicit user action (AC-3/AC-4), never from a failed sync (ADR-0002 §9). + +--- + +### Task 2: Encryption service — `ExternalCredentialEncryptor` + +**Files:** +- Interface: `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/ExternalCredentialEncryptor.java` +- Implementation: `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptor.java` + +**Note:** The interface lives in a **provider-agnostic** package (`infrastructure/external/`) rather than `infrastructure/external/invenio/`. This is deliberate: encryption is not provider-specific. Future providers use the same encryptor. + +**Responsibility:** Symmetric AES-256-GCM encryption/decryption of user tokens using the dedicated master key from `DataManagerVault`. + +**Contract:** +```java +package life.qbic.projectmanagement.infrastructure.external; + +/** + * Encrypts and decrypts external provider user tokens using AES-256-GCM. + * + *

Provider-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 { + + Optional findByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); + + List findByUserId(String userId); + + List findByUserIdAndSourceType( + String userId, SourceType sourceType); + + void save(UserExternalCredential credential); + + void deleteByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); +} +``` + +--- + +### Task 4: JPA entity + Repository implementation + +**Files:** +- `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialEntity.java` +- `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialJpaRepository.java` (Spring Data JPA) +- `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialRepositoryImpl.java` + +**JPA entity — key design decisions:** +- `@Entity` mapped to `user_external_credential` +- `sourceType` as `@Enumerated(EnumType.STRING)` +- `encryptedToken` as `byte[]` with `@Column(columnDefinition = "varbinary(512)")` +- `status` as `@Enumerated(EnumType.STRING)` +- `id` generated as UUID (application-generated) +- Unique constraint on `(userId, sourceType, instanceId)` + +**Domain model mapping:** +- Bidirectional mapping between `UserExternalCredentialEntity` (JPA) ↔ `UserExternalCredential` (domain) +- Domain entity never holds the plaintext token — only the encrypted blob + +--- + +### Task 5: Token validation — composite dispatcher + per-provider adapters + +This task introduces the token validation infrastructure. The design uses a **composite dispatcher** pattern so that adding a new provider type (e.g. LIMS with PAT authentication) requires zero changes to the application-layer port, application service, or UI. + +#### 5a. Application-layer port — `ExternalCredentialValidator` + +**File:** `project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialValidator.java` + +```java +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.

+ * + *

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 }.

+ * + * @since 1.12.0 + */ +public class InvenioRdmCredentialValidatorAdapter implements CredentialValidatorAdapter { + + private final InvenioRdmClient client; + + public InvenioRdmCredentialValidatorAdapter(InvenioRdmClient client) { + this.client = Objects.requireNonNull(client); + } + + @Override + public boolean validate(InstanceConfig config, char[] token) { + char[] tokenCopy = Arrays.copyOf(token, token.length); + try { + String authHeader = "Bearer " + new String(tokenCopy); + client.getAuthenticatedUser(config.baseUrl(), authHeader); // 200 → valid + return true; + } catch (InvenioRdmClient.InvenioRdmPermanentException e) { + if (e.getStatusCode() == 401 || e.getStatusCode() == 403) { + return false; // token invalid + } + throw e; // unexpected 4xx + } catch (InvenioRdmClient.InvenioRdmTransientException e) { + throw new CredentialValidationException( + "Token validation failed due to transient error", e); + } finally { + Arrays.fill(tokenCopy, '\0'); + } + } +} +``` + +#### 5d. Composite dispatcher — `SourceTypeDispatchingCredentialValidator` + +**File:** `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/SourceTypeDispatchingCredentialValidator.java` + +```java +package life.qbic.projectmanagement.infrastructure.external; + +import java.util.Map; +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 {@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 Map adapters; + + public SourceTypeDispatchingCredentialValidator( + Map adapters) { + if (adapters.isEmpty()) { + throw new IllegalArgumentException( + "At least one credential validator adapter must be registered"); + } + this.adapters = Map.copyOf(adapters); + } + + @Override + public boolean validateToken(SourceType sourceType, InstanceConfig config, + char[] token) { + CredentialValidatorAdapter adapter = adapters.get(sourceType); + if (adapter == null) { + throw new CredentialValidationException( + "No credential validator registered for source type: " + + sourceType); + } + return adapter.validate(config, token); + } +} +``` + +**Registration today:** One entry — `SourceType.INVENIO_RDM → InvenioRdmCredentialValidatorAdapter`. + +**Adding a future provider (e.g. LIMS):** +1. Implement `LimsCredentialValidatorAdapter implements CredentialValidatorAdapter` +2. Register it in the Spring config: `adapters.put(SourceType.LIMS, new LimsCredentialValidatorAdapter(...))` +3. Add the LIMS instance to `application.properties` +4. Done. The application service, port, UI, and DB table all remain unchanged. + +#### 5e. InvenioRdmClient extension — `getAuthenticatedUser()` + +**File to modify:** `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmClient.java` + +**API contract (source of truth):** +Per the official [InvenioRDM OpenAPI specification](https://inveniosoftware.github.io/invenio-openapi/): + +| Field | Value | +|---|---| +| Path | `GET /api/users` | +| Operation ID | `getAUserById` | +| Parameters | none | +| Security | `BearerAuth` (i.e. `Authorization: Bearer `) | +| Success (200) | `application/json` → `type: object` (generic JSON object; spec does not constrain the schema) | +| Unauthorized (401) | Authentication required — token is missing, invalid, or expired | +| Forbidden (403) | Insufficient permissions | + +The response body is typed simply as `type: object` in the spec (no named fields), so the implementation must **not rely on specific fields** beyond the HTTP status code. The 200 status alone is sufficient to prove the token is valid; we capture a best-effort display name for informational/logging purposes only, using `@JsonIgnoreProperties(ignoreUnknown = true)` for forward compatibility against schema evolution. + +```java +/** + * Retrieves the authenticated user's identity from the InvenioRDM instance. + * + *

This is the token validation endpoint defined in the InvenioRDM + * OpenAPI spec (operationId: {@code getAUserById}).

+ * + *
    + *
  • {@code 200} — token is valid; response contains the user's + * identity as a JSON object.
  • + *
  • {@code 401} — token is missing, invalid, or expired.
  • + *
+ * + *

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 Bearer }) + * @return authenticated user response + * @throws InvenioRdmPermanentException on 4xx (401 = invalid token) + * @throws InvenioRdmTransientException on 5xx or network errors after retries + */ +AuthenticatedUserResponse getAuthenticatedUser(String instanceUrl, String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException, + InvenioRdmResponseParsingException; + +@JsonIgnoreProperties(ignoreUnknown = true) +record AuthenticatedUserResponse( + @JsonProperty("id") String id, + @JsonProperty("username") String username, + @JsonProperty("email") String email +) {} +``` + +--- + +### Task 6: Application service — `ExternalCredentialService` + +**File:** `project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialService.java` + +**Responsibility:** Orchestrates the user token lifecycle — add, list, remove. The service is **provider-agnostic**: it works with any source type that has a registered `CredentialValidatorAdapter`. + +**Contract:** +```java +package life.qbic.projectmanagement.application.associated_dataset; + +import java.util.List; + +/** + * Application service for managing user-level external provider + * credentials. + * + *

Orchestrates 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: + *

    + *
  1. Resolve the instance from the registry to obtain the source type
  2. + *
  3. Validate the token against the instance via + * {@link ExternalCredentialValidator} (dispatcher routes to the + * correct provider adapter)
  4. + *
  5. If valid, encrypt and persist the token
  6. + *
  7. If invalid, return failure — no persistence
  8. + *
+ * + * @param userId the DM user adding the token + * @param instanceId the target instance (e.g. "zenodo") + * @param token the plaintext token — zeroed by this method after use + * @return result indicating success or failure with reason + */ + 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 true if a credential was removed, false if none existed + */ + boolean removeCredential(String userId, String instanceId); + + /** + * Lists the user's credential status for all configured instances + * across all source types. + * + *

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 + */ + List listCredentialStatuses(String userId); + + /** + * Result of an add-credential operation. + */ + sealed interface AddCredentialResult + permits AddCredentialResult.Success, AddCredentialResult.InvalidToken, + AddCredentialResult.ServiceError, + AddCredentialResult.UnknownInstance {} + + record Success() implements AddCredentialResult {} + record InvalidToken(String reason) implements AddCredentialResult {} + record ServiceError(String reason) implements AddCredentialResult {} + record UnknownInstance(String instanceId) implements AddCredentialResult {} + + /** + * Credential status view for a single instance (no plaintext token). + */ + record CredentialStatusView( + SourceType sourceType, + String instanceId, + String instanceDisplayName, + boolean configured, + String status // "VALID" | "INVALIDATED" | "NOT_CONFIGURED" + ) {} +} +``` + +**Key change from the original plan:** The `addCredential` method no longer needs the caller to specify the `SourceType`. It resolves the source type from the `SourceInstanceRegistry` via the `instanceId`. This keeps the view-layer call simple: "add this token for instance X." + +--- + +### Task 7: Wire token resolution into `InvenioRdmDatasetSource` + +**Files to modify:** +- `project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmDatasetSource.java` + +**Current state:** The `InvenioRdmClient` already threads `authHeader` through `getWithRetry()` but it is always passed as `null`. The `InvenioRdmDatasetSource.search()` receives `actingUserId` but ignores it. + +**Changes:** +1. `InvenioRdmDatasetSource` receives `UserExternalCredentialRepository` and `ExternalCredentialEncryptor` via constructor injection. +2. In `search()` and `resolveMetadata()`: + - Look up `repository.findByUserIdAndSourceTypeAndInstanceId(actingUserId, SourceType.INVENIO_RDM, config.id())` + - If found: decrypt token, build `Authorization: Bearer {token}` header, pass to client + - If not found: pass `null` (public records only) + - Zero token `char[]` in `finally` block + +**Decryption boundary enforcement (ADR-0002 D1):** +```java +@Override +public SearchResult search(SearchQuery query, InstanceConfig config, + String actingUserId) throws DatasetSearchException { + char[] token = resolveTokenForUser(actingUserId, config.id()); + try { + String authHeader = token != null ? "Bearer " + new String(token) : null; + // ... perform search with authHeader ... + } finally { + if (token != null) { + Arrays.fill(token, '\0'); + } + } +} + +private char[] resolveTokenForUser(String userId, String instanceId) { + return credentialRepository + .findByUserIdAndSourceTypeAndInstanceId( + userId, SourceType.INVENIO_RDM, instanceId) + .map(cred -> encryptor.decrypt(cred.encryptedToken())) + .orElse(null); +} +``` + +**Note:** This task creates a dependency from `InvenioRdmDatasetSource` → `UserExternalCredentialRepository`. Since the repository is a domain port (domain-layer interface) and `InvenioRdmDatasetSource` is in the infrastructure layer, this is an acceptable infrastructure-to-domain dependency (infrastructure depends on domain, not the reverse). + +--- + +### Task 8: UI — "External Providers" view + +**New route:** `/account/external-providers` (alongside `/profile` and `/account`) + +**Files:** +- `datamanager-app/src/main/java/life/qbic/datamanager/views/account/ExternalProvidersMain.java` — Vaadin `@Route` class +- `datamanager-app/src/main/java/life/qbic/datamanager/views/account/ExternalProvidersComponent.java` — UI logic +- `datamanager-app/src/main/java/life/qbic/datamanager/views/account/AddExternalCredentialTokenDialog.java` — Token input dialog +- CSS: `datamanager-app/frontend/themes/datamanager/components/external-providers.css` + +**UI layout:** + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ External Providers │ +│ │ +│ Connect your personal access tokens to enable access to │ +│ access-restricted datasets on external instances. │ +│ │ +│ InvenioRDM Instances │ +│ ┌────────────────────────────────────────────┐ ┌───────────────┐ │ +│ │ 🟢 Zenodo (zenodo.org) │ │ Connected │ │ +│ │ Token configured · Status: VALID │ │ [Remove] │ │ +│ └────────────────────────────────────────────┘ └───────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────┐ ┌───────────────┐ │ +│ │ 🔴 FDAT (fdat.uni-tuebingen.de) │ │ Not connected │ │ +│ │ No token configured │ │ [Add Token] │ │ +│ └────────────────────────────────────────────┘ └───────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Add Token Dialog:** +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Add Token — Zenodo (zenodo.org) │ +│ │ +│ Paste your personal access token from your Zenodo account: │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Personal Access Token │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ⓘ Your token is stored encrypted and used only to access your │ +│ own restricted datasets. You can create one at: │ +│ https://zenodo.org/account/settings/applications/ │ +│ │ +│ [Cancel] [Validate & Save] │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +**Key UX decisions:** +1. **Benefit text (AC-6):** Shown as a descriptive paragraph at the top of the view — explains that providing a token enables connection of access-restricted datasets to a project. +2. **Instance list:** The view iterates all configured instances by source type (currently only `INVENIO_RDM`). Grouped under section headings. Not hardcoded, not user-configurable (ADR-0002 I2). +3. **Status indicators:** Green checkmark + "Connected" for configured/VALID instances; red X + "Not connected" for unconfigured or INVALIDATED instances. +4. **Token input:** `PasswordField` component — no echo of characters. +5. **Validation feedback:** Toast notification — "Token validated successfully. Zenodo is now connected." or "Token validation failed. Please check your token and try again." +6. **External link:** Help text links to the instance's token creation page. The URL pattern `{baseUrl}/account/settings/applications/` is standard for InvenioRDM. For non-InvenioRDM providers, the `SourceInstanceDescriptor` could carry an optional `tokenCreationUrl` field in the future. +7. **Security:** No token value ever displayed after saving. No copy button. Field is cleared on cancel. + +**Navigation integration:** The "External Providers" route should be accessible from the existing account/profile area navigation. This may require adding a link to the account navigation sidebar or tabs. + +--- + +### Task 9: Vault master key + configuration + +**New application property:** +```properties +# Dedicated master key for encrypting user external provider credentials (ADR-0002 S2) +# This is the keystore entry alias. The actual key bytes come from the PKCS12 vault. +# The same key is used for all source types (InvenioRDM, future LIMS, etc.). +qbic.security.vault.external-credential.key-alias=external-credential-master-key +``` + +**Vault setup:** At deployment, a new PKCS12 keystore entry is created with alias `external-credential-master-key` containing an AES-256 secret key **encoded as Base64**. The key must be exactly 32 bytes (256 bits) before encoding; Base64 encoding produces a 44-character string. Provisioning example: + +```bash +# Generate a 32-byte key, Base64-encode, and store in the PKCS12 keystore +# (The string produced by openssl rand -base64 32 is exactly the form the application expects) +AES_KEY=$(openssl rand -base64 32) +echo "$AES_KEY" | keytool -importpass \ + -alias external-credential-master-key \ + -keystore /path/to/shared/keystore.p12 \ + -storepass $DATAMANAGER_VAULT_KEY \ + -keypass $DATAMANAGER_VAULT_ENTRY_PASSWORD \ + -storetype PKCS12 +``` + +This is separate from the existing OpenBIS vault entries. + +**Bean wiring:** `InvenioRdmConfiguration.credentialEncryptor()` reads the alias from the configuration, loads the corresponding Base64-encoded string from the `DataManagerVault`, decodes it via `Base64.getDecoder()`, and validates the decoded length is exactly 32 bytes. The application fails fast at startup with a clear error if the entry is missing, not valid Base64, or has the wrong length. + +**Security consideration:** The key alias is configurable so that environments can use different keys (e.g., for key rotation in the future). The vault entry password is the same existing `DATAMANAGER_VAULT_ENTRY_PASSWORD` env var — the alias is the distinguishing factor. + +--- + +### Task 10: Spring wiring in `InvenioRdmConfiguration` + +**File:** `datamanager-app/src/main/java/life/qbic/datamanager/configuration/InvenioRdmConfiguration.java` + +**Bean wiring for the credential encryptor:** +```java +// ── Encryption (provider-agnostic) ───────────────────────────────── +@Bean +public CredentialEncryptor credentialEncryptor( + DataManagerVault vault, + @Value("${qbic.security.vault.external-credential.key-alias}") String keyAlias) { + String keyString = vault.read(keyAlias) + .orElseThrow(() -> new IllegalStateException( + "Vault entry not found for alias '" + keyAlias + "'.")); + byte[] keyBytes; + try { + keyBytes = Base64.getDecoder().decode(keyString); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Vault entry for alias '" + keyAlias + + "' is not valid Base64-encoded data.", e); + } + if (keyBytes.length != AesGcmCredentialEncryptor.AES_256_KEY_BYTES) { + throw new IllegalStateException( + "Vault entry for alias '" + keyAlias + "' has incorrect key size: " + + keyBytes.length + " bytes (expected 32 for AES-256)."); + } + SecretKey secretKey = new SecretKeySpec(keyBytes, "AES"); + return new AesGcmCredentialEncryptor(secretKey); +} +``` + +**Note:** The bean validates three things at startup: +1. The vault entry exists (clear error naming the alias). +2. The entry content is valid Base64 (clear error explaining Base64 requirement). +3. The decoded key is exactly 32 bytes (clear error naming the expected size for AES-256). + +**Additional beans:** Same as previous revision — `InvenioRdmClient`, `SourceInstanceRegistry`, `CredentialValidatorAdapter`, composite `ExternalCredentialValidator`, `ExternalCredentialService`, and `DatasetSource`. See the existing `InvenioRdmConfiguration` for the complete list and wiring. The only change to Task 10 from the previous revision is the `credentialEncryptor` bean, which now Base64-decodes and validates the key length. + +--- + +### Task 11: Tests + +| Test class | Type | Framework | Module | +|---|---|---|---| +| `AesGcmCredentialEncryptorSpec` | Unit | Spock | `project-management-infrastructure` | +| `InvenioRdmCredentialValidatorAdapterSpec` | Unit | Spock | `project-management-infrastructure` | +| `SourceTypeDispatchingCredentialValidatorSpec` | Unit | Spock | `project-management-infrastructure` | +| `DefaultExternalCredentialServiceSpec` | Unit | Spock | `project-management` | +| `InvenioRdmDatasetSourceSpec` (update existing) | Unit | Spock | `project-management-infrastructure` | +| `InvenioRdmClientSpec.getAuthenticatedUser` (update existing) | Unit | Spock | `project-management-infrastructure` | + +**Key test scenarios:** + +**`AesGcmCredentialEncryptorSpec`:** +- Encryption → decryption roundtrip preserves plaintext +- Different nonces produce different ciphertexts for same plaintext +- Decryption with wrong key fails +- Decrypting corrupted data throws meaningful exception +- AES key shorter than 32 bytes fails at construction (AES-128 rejected) +- AES key longer than 32 bytes fails at construction (48 bytes rejected) +- Null master key fails at construction +- Non-AES algorithm key fails at construction + +**`InvenioRdmCredentialValidatorAdapterSpec`:** +- 200 response → `validate()` returns `true` +- 401 response → `validate()` returns `false` +- 403 response → `validate()` returns `false` +- 500 response (transient) → exception (not swallowed as "invalid") +- Token `char[]` is zeroed after the validate call completes + +**`SourceTypeDispatchingCredentialValidatorSpec`:** +- Correct adapter is invoked for a known `SourceType` +- `CredentialValidationException` thrown for unknown `SourceType` (no registered adapter) +- Token is forwarded to the correct adapter (not lost or duplicated) + +**`DefaultExternalCredentialServiceSpec`:** +- `addCredential` with valid token → resolves source type from registry → persists encrypted blob +- `addCredential` with invalid token → does NOT persist, returns `InvalidToken` +- `addCredential` with unknown instance → returns `UnknownInstance` +- `addCredential` when instance already has a token → replaces with new token +- `removeCredential` with existing credential → deletes it +- `removeCredential` with no credential → returns false +- `listCredentialStatuses` returns all configured instances (configured + unconfigured) with correct source type +- Token is zeroed after `addCredential` completes + +**`InvenioRdmDatasetSourceSpec` (update):** +- `search()` with user who has token → includes auth header +- `search()` with user who has no token → no auth header (public only) +- Token `char[]` is zeroed after search completes +- `resolveMetadata()` follows same token resolution pattern + +--- + +## 5. Module Dependency Graph + +``` +datamanager-app (composition root) +├── InvenioRdmConfiguration wires: +│ ├── ExternalCredentialEncryptor (infrastructure interface) +│ │ └── AesGcmCredentialEncryptor ← DataManagerVault (PKCS12) +│ │ +│ ├── ExternalCredentialValidator (port: application) +│ │ └── SourceTypeDispatchingCredentialValidator (composite, infra) +│ │ ├── INVENIO_RDM → InvenioRdmCredentialValidatorAdapter +│ │ │ └── InvenioRdmClient.getAuthenticatedUser() +│ │ └── (future) LISMS → LimsCredentialValidatorAdapter +│ │ +│ ├── ExternalCredentialService (application) +│ │ ├── ExternalCredentialValidator (dispatcher) +│ │ ├── SourceInstanceRegistry +│ │ ├── UserExternalCredentialRepository (port: domain) +│ │ └── ExternalCredentialEncryptor +│ │ +│ ├── DatasetSource (port: application) +│ │ └── InvenioRdmDatasetSource (adapter: infrastructure) +│ │ ├── InvenioRdmClient +│ │ ├── UserExternalCredentialRepository (for token resolution) +│ │ └── ExternalCredentialEncryptor (for token decryption) +│ │ +│ └── CredentialValidatorAdapter beans (per provider) +│ +└── ExternalProvidersMain (@Route: /account/external-providers) + └── ExternalCredentialService +``` + +--- + +## 6. Implementation Order (recommended) + +This order minimises blocked work and allows incremental validation: + +| Step | Tasks | Rationale | +|---|---|---| +| **Phase 1: Foundation** | Task 1 (DB schema), Task 9 (vault key config) | No code dependencies. Can be done first to unblock everything. | +| **Phase 2: Encryption** | Task 2 (`ExternalCredentialEncryptor`) | Core security primitive. Must be right before anything else touches tokens. | +| **Phase 3: Domain** | Task 3 (domain entity + repo interface) | Defines the domain contract. Infrastructure implements it in Phase 4. | +| **Phase 4: Infrastructure persistence** | Task 4 (JPA entity + Spring Data repo) | Implements the domain repository. Depends on Task 1 (table exists) and Task 2 (encryptor exists). | +| **Phase 5: Validation layer** | Task 5 (InvenioRdmClient.getAuthenticatedUser + per-provider adapter + composite dispatcher) | The validation primitive needed by the application service in Task 6. | +| **Phase 6: Application service** | Task 6 (`ExternalCredentialService`) | Orchestrates validation + encryption + persistence. Depends on Tasks 2–5. | +| **Phase 7: Wire token into DatasetSource** | Task 7 | Modifies the existing adapter to use stored tokens. Depends on Tasks 3, 4. | +| **Phase 8: UI** | Task 8 | Frontend work. Can be prototyped earlier with mock data, but final integration requires Task 6. | +| **Phase 9: Wiring** | Task 10 (Spring config) | Ties all beans together. Done last or iteratively alongside Tasks 6–7. | +| **Phase 10: Tests** | Task 11 | Written alongside each phase, but final integration tests last. | + +**Parallelism opportunities:** +- Tasks 5 (validation) and 7 (token resolution in DatasetSource) are independent and can be done in parallel. +- Task 8 (UI) can be prototyped in parallel with Tasks 2–6 using hardcoded/mock statuses. + +--- + +## 7. Risk Register + +| Risk | Impact | Mitigation | +|---|---|---| +| `GET /api/users` endpoint contract differs across InvenioRDM versions | Token validation fails with unexpected response shape | The official spec defines this as a stable, parameter-less `BearerAuth` endpoint returning `type: object`. Implementation must not depend on specific response fields (spec leaves the body open-ended). Validation is based solely on the 200 status. The `@JsonIgnoreProperties(ignoreUnknown = true)` DTO handles forward-compatible field extraction. If a future instance removes the endpoint, a fallback to any authenticated call (e.g. `GET /api/records?size=1` with the auth header) can be introduced as an adapter-level change without touching the port. | +| PKCS12 vault key distribution to HA nodes | Encryption/decryption fails on some nodes | Same deployment pattern as existing OpenBIS vault keys — already proven. Document in ops runbook. | +| Token expiry / revocation on InvenioRDM | Stored token becomes invalid unexpectedly | AC-4 covers this for re-validation. For sync (FEAT-DATSET-04/08), the invoking user is informed they need to re-add their token (ADR-0003 C1). | +| InvenioRDM rate limits on `GET /api/users` during token add | Validation fails transiently | `InvenioRdmClient` already has retry logic for 429/5xx. Token validation will benefit from this. | +| Plaintext token leak in logs / error messages | Security incident | Decryption boundary (ADR-0002 D1), audit checklist in Task 2, code review emphasis. | + +--- + +## 8. Out of Scope (explicitly deferred) + +| Item | Reason | Linked story | +|---|---|---| +| Remove credential from UI | Separate story with its own ACs | FEAT-DATSET-15 (#1479) | +| Background sync / auto-refresh | Deferred to future (ADR-0003 Y1) | — | +| Service-account / institutional tokens | Not in v1 scope (ADR-0002 T1) | — | +| Token rotation reminders / expiry warnings | No user story drives this yet | — | +| Cross-project credential sharing | Out of scope per feature boundaries | — | + +--- + +## 9. Design Decisions Summary + +| Decision | Choice | Rationale | +|---|---|---| +| DB table name | `user_external_credential` (not `user_invenio_rdm_credential`) | Schema is provider-agnostic. The `source_type` + `instance_id` columns handle any provider. Adding a second provider requires zero schema changes. | +| Token validation dispatch | Composite dispatcher (`SourceTypeDispatchingCredentialValidator`) | Application service is provider-agnostic. Adding a new provider = implement adapter + register bean. No changes to port, service, UI, or DB. | +| Validation port shape | `ExternalCredentialValidator.validateToken(SourceType, InstanceConfig, char[])` | Source type parameter enables dispatch. Application service resolves source type from registry via `instanceId` — the caller doesn't need to know. | +| Encryptor scope | Provider-agnostic (`ExternalCredentialEncryptor`) | Encryption algorithm is the same regardless of provider. One master key, one encryptor, one set of security tests. | +| Per-provider adapter | `CredentialValidatorAdapter` interface in infrastructure | Each provider's auth scheme (endpoint, header format, response shape) is encapsulated in its own adapter class. Dispatcher routes to the right one by `SourceType`. | diff --git a/docs/migrations/NEXT.md b/docs/migrations/NEXT.md index a6beb4663..7be40ba80 100644 --- a/docs/migrations/NEXT.md +++ b/docs/migrations/NEXT.md @@ -21,6 +21,339 @@ For the migration documentation structure, see [`README.md`](README.md). | # | Script | Description | Risk | |---|---|---|---| +| 2 | [`sql/migrations/create-user-external-credential.sql`](../sql/migrations/create-user-external-credential.sql) | Create `user_external_credential` table for storing per-user tokens to external providers (FEAT-DATSET-14) | low — new empty table; requires vault master key provisioned on all HA nodes before application starts | + +Each row links to its incremental script. The sections below expand each entry +with apply / verify / rollback detail. + +--- + +## Migration #1: `associated_dataset` table + +| Field | Value | +|---|---| +| **Story** | [#1467 — FEAT-DATSET-01: Connecting open, published datasets](https://github.com/qbicsoftware/data-manager-app/issues/1467) | +| **Feature** | [#1466 — FEAT-DATASET-CONNECTION](https://github.com/qbicsoftware/data-manager-app/issues/1466) | +| **ADRs** | [0001](../adr/0001-associated-datasets-domain-model.md), [0002](../adr/0002-invenio-rdm-api-client-credentials.md), [0003](../adr/0003-connection-lifecycle-stewardship.md) | +| **Scope** | Database schema only (new table) | +| **Script** | [`sql/migrations/create-associated-dataset.sql`](../sql/migrations/create-associated-dataset.sql) | +| **Target datasource** | `data_management` | + +### What it does + +Creates an empty `associated_dataset` table. Per [ADR-0001](../adr/0001-associated-datasets-domain-model.md): + +- Lives in the `project-management` bounded context (table name starts with `associated_dataset`, not under the existing `dataset_*` namespace, to keep the InvenioRDM connection concept distinct from the legacy OpenBIS raw-data tables). +- Has four **universal columns** (`title`, `pid`, `version`, `publication_date`) duplicated from the JSON blob for SQL sort/filter. +- Stores **source-specific metadata** (InvenioRDM creators, community, access details) in a MariaDB `JSON` column (`resource_metadata`). At the expected scale of <100 rows/project, this is efficient; the escape hatch for future scale is a generated virtual column + index — no entity change required. +- **Soft-delete for removal** (ADR-0001, ADR-0003): deleting a connection sets `connection_state = 'REMOVED'`, leaving the row as an audit tombstone. Active query paths filter `REMOVED` out. There is no `DELETE` statement in v1. +- **No SQL FK constraint on `experiment_id`** — the application enforces referential integrity in memory so that project-ACL changes and experiment deletions don't cascade-delete dataset connections. + +### What is NOT in this migration + +A second table, `user_external_credential` (for per-user Personal Access +Tokens to access restricted datasets on external providers), is added +as **Migration #2 below** in this same release (FEAT-DATSET-14). It is +intentionally **not** created by this migration because FEAT-DATSET-01 +covers only public, open datasets. See Migration #2 for the credential +table definition and its vault-provisioning pre-flight requirement. + +### Pre-flight + +Run these checks against the target database **before** applying: + +```sql +-- 1. Confirm the table does NOT already exist +SELECT COUNT(*) AS table_exists +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name = 'associated_dataset'; +-- Expected: 0 + +-- 2. Confirm target datasource charset/collation matches the rest of the schema +SELECT @@character_set_database AS charset, + @@collation_database AS collation; +-- Expected: utf8mb4, utf8mb4_unicode_ci + +-- 3. Confirm you are connected to the data-management datasource +SELECT DATABASE(); +-- Expected: data_management (or whatever your DM schema is named) +``` + +If the table already exists (return value = 1), this migration was already +applied — no further action required (the DDL is idempotent). + +### Apply + +```bash +mysql -u -h -P data_management \ + < sql/migrations/create-associated-dataset.sql +``` + +Or inline: + +```sql +-- The full DDL is inlined in the script for review; see +-- sql/migrations/create-associated-dataset.sql +``` + +### Verify + +```sql +-- 1. Confirm 15 columns +SELECT COUNT(*) AS column_count +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'associated_dataset'; +-- Expected: 15 + +-- 2. Confirm indexes are in place (4 keys + PK = 5 rows) +SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'associated_dataset' +GROUP BY index_name +ORDER BY index_name; +-- Expected rows: +-- PRIMARY id +-- idx_assoc_ds_project project_id +-- idx_assoc_ds_project_state project_id,connection_state +-- idx_assoc_ds_source_type source_type +-- idx_assoc_ds_state connection_state + +-- 3. Confirm the application can query the table +SELECT COUNT(*) AS active_connected_count +FROM associated_dataset +WHERE connection_state <> 'REMOVED'; +-- Expected: 0 (empty table immediately after migration) + +-- 4. Confirm no unintended table was created on the finance datasource +SELECT table_schema, table_name +FROM information_schema.tables +WHERE table_name = 'associated_dataset'; +-- Expected: exactly one row, for the data-management schema +``` + +### Rollback + +Rollback is destructive — any rows inserted by the application after the +migration will be lost. Because FEAT-DATSET-01 is the first release introducing +this table, rollback is only meaningful before any datasets have been connected +by users. + +```sql +-- DANGER: drops all connected-dataset connections. +-- Safe only when the table is empty (see rollback plan below). +DROP TABLE IF EXISTS `associated_dataset`; +``` + +If the table contains connected-dataset rows, rollback requires one of: + +1. **Reverse the feature deployment** to a version that does not reference the + table, then drop the table. +2. **Truncate the table**, then drop it. Both cases lose all connection data. + +In either case, notify users that previously connected datasets will no longer +appear in their projects. + +### Operator notes + +- **No data is moved.** This migration only creates an empty table. +- **No downtime required.** `CREATE TABLE IF NOT EXISTS` on MariaDB takes + milliseconds and does not lock other tables. +- **No foreign key constraint is created** between + `associated_dataset.experiment_id` and `experiments_datamanager.id`. The + application enforces referential integrity in memory. +- **Future migration (same feature):** Stories 14/15 will add the + `user_invenio_rdm_credential` table. Expect a follow-up entry in this file + (or in a subsequent release's `NEXT.md`). + +--- + +## Migration #2: `user_external_credential` table + +| Field | Value | +|---|---| +| **Story** | [#1478 — FEAT-DATSET-14: Add credentials for an InvenioRDM instance](https://github.com/qbicsoftware/data-manager-app/issues/1478) | +| **Feature** | [#1466 — FEAT-DATASET-CONNECTION](https://github.com/qbicsoftware/data-manager-app/issues/1466) | +| **ADRs** | [0002](../adr/0002-invenio-rdm-api-client-credentials.md), [0003](../adr/0003-connection-lifecycle-stewardship.md) | +| **Scope** | Database schema (new table) + vault provisioning | +| **Script** | [`sql/migrations/create-user-external-credential.sql`](../sql/migrations/create-user-external-credential.sql) | +| **Target datasource** | `data_management` | + +### What it does + +Creates an empty `user_external_credential` table for storing per-user Personal +Access Tokens to external data source instances (e.g., InvenioRDM instances like +Zenodo, FDAT). Per [ADR-0002](../adr/0002-invenio-rdm-api-client-credentials.md): + +- **Source-agnostic design**: includes `source_type` column alongside + `instance_id`, allowing future provider types without schema changes. Today + the only rows will be `INVENIO_RDM` / `zenodo` | `fdat`. +- **Per-user, per-instance tokens**: unique constraint on `(user_id, + source_type, instance_id)` — one token per user per instance. +- **Encrypted at rest**: `encrypted_token` column stores AES-256-GCM blobs + (nonce ‖ ciphertext ‖ tag). The AES-256 master key is stored in the PKCS12 + vault (shared keystore across all HA nodes) and protected by the shared entry + password (`DATAMANAGER_VAULT_ENTRY_PASSWORD`). This is the same shared model + used for OpenBIS credentials — the blast radius for a compromised entry + password is consistent with the existing application design. +- **Status tracking**: `status` column tracks `VALID` / `INVALIDATED` lifecycle + states (updated only on explicit user action, per ADR-0002 §9). + +### What is NOT in this migration + +- No data is inserted — the table starts empty and is populated by users at + runtime through the `/external-providers` page. +- No foreign key constraint is added; the application enforces referential + integrity in memory. + +### Vault provisioning requirement + +**This migration requires a new vault entry to be provisioned by ops before the +application starts.** + +The application reads the master AES-256 key from the PKCS12 vault at startup. +If the entry is missing, the application will fail to start with a clear error +message. + +**What ops needs to do:** + +1. **Add a new entry to the PKCS12 keystore** under the alias + `external-credential-master-key`: + + ```bash + # Generate a 32-byte key and Base64-encode it + AES_KEY=$(openssl rand -base64 32) + + # Store in keystore (run once, on one node — the keystore file is shared) + echo "$AES_KEY" | keytool -importpass \ + -alias external-credential-master-key \ + -keystore /path/to/shared/keystore.p12 \ + -storepass $DATAMANAGER_VAULT_KEY \ + -keypass $DATAMANAGER_VAULT_ENTRY_PASSWORD \ + -storetype PKCS12 + ``` + + **Key format**: The vault entry **must** contain a Base64-encoded string + representing exactly 32 bytes (256 bits). Use: + - `openssl rand -base64 32` (produces 44-char Base64 string encoding 32 bytes) + + The application decodes this Base64 string at startup and validates the + decoded length is exactly 32 bytes. Non-Base64 strings and incorrectly + sized keys will cause the application to fail-fast with a clear error. + + ⚠️ **Important**: Do not use hex encoding (`openssl rand -hex 32`) — the + application expects Base64. A 64-char hex string represents only 32 bytes + after hex-decoding, but the application's Base64 decoder will reject it + as invalid. + +2. **Deploy the keystore** to all HA nodes. The existing vault deployment + pattern (shared keystore file across nodes) applies here — no new + distribution mechanism needed. + +3. **Verify the alias name** in `application.properties` matches: + + ```properties + qbic.security.vault.external-credential.key-alias=external-credential-master-key + ``` + +### Pre-flight + +Run these checks against the target database **before** applying: + +```sql +-- 1. Confirm the table does NOT already exist +SELECT COUNT(*) AS table_exists +FROM information_schema.tables +WHERE table_schema = DATABASE() + AND table_name = 'user_external_credential'; +-- Expected: 0 + +-- 2. Confirm target datasource charset/collation matches the rest of the schema +SELECT @@character_set_database AS charset, + @@collation_database AS collation; +-- Expected: utf8mb4, utf8mb4_unicode_ci + +-- 3. Confirm you are connected to the data-management datasource +SELECT DATABASE(); +-- Expected: data_management (or whatever your DM schema is named) +``` + +If the table already exists (return value = 1), this migration was already +applied — no further action required (the DDL is idempotent). + +### Apply + +```bash +mysql -u -h -P data_management \ + < sql/migrations/create-user-external-credential.sql +``` + +### Verify + +```sql +-- 1. Confirm 8 columns +SELECT COUNT(*) AS column_count +FROM information_schema.columns +WHERE table_schema = DATABASE() + AND table_name = 'user_external_credential'; +-- Expected: 8 + +-- 2. Confirm indexes are in place (3 keys + PK = 4 rows) +SELECT index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns +FROM information_schema.statistics +WHERE table_schema = DATABASE() + AND table_name = 'user_external_credential' +GROUP BY index_name +ORDER BY index_name; +-- Expected rows: +-- PRIMARY id +-- idx_cred_user_src user_id,source_type +-- idx_cred_user user_id +-- uk_user_src_instance user_id,source_type,instance_id + +-- 3. Confirm no unintended table was created on the finance datasource +SELECT table_schema, table_name +FROM information_schema.tables +WHERE table_name = 'user_external_credential'; +-- Expected: exactly one row, for the data-management schema +``` + +### Rollback + +Rollback is destructive — any rows inserted by the application after the +migration will be lost. Because FEAT-DATSET-14 is the first release introducing +this table, rollback is only meaningful before any credentials have been added +by users. + +```sql +-- DANGER: drops all stored credentials. Users will need to re-add their tokens. +DROP TABLE IF EXISTS `user_external_credential`; +``` + +If the table contains credential rows, rollback requires one of: + +1. **Reverse the feature deployment** to a version that does not reference the + table, then drop the table. All users will lose their configured tokens. +2. **Truncate the table**, then drop it. Both cases lose all credential data. + +In either case, notify users that their configured InvenioRDM tokens will need +to be re-added after the rollback. + +**Note:** The vault entry (`external-credential-master-key`) can remain in the +PKCS12 keystore after rollback — it is harmless if unused. + +### Operator notes + +- **No data is moved.** This migration only creates an empty table. +- **No downtime required.** `CREATE TABLE IF NOT EXISTS` on MariaDB takes + milliseconds and does not lock other tables. +- **Vault provisioning is required** before the application can start. See + "Vault provisioning requirement" above. +- **Future providers:** The `source_type` column allows adding new provider + types (e.g., LIMS) without schema changes — only application configuration + changes are needed. --- @@ -84,4 +417,4 @@ When this release is ready to ship: 5. **Reset `NEXT.md`:** create a fresh empty copy from the template above for the *subsequent* release. -See [`README.md`](README.md) for the full migration documentation structure. \ No newline at end of file +See [`README.md`](README.md) for the full migration documentation structure. diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/DataManagerVault.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/DataManagerVault.java index 5b5784d99..241b64291 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/DataManagerVault.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/DataManagerVault.java @@ -161,9 +161,23 @@ private void writeToFileSystem() { * @since 1.8.0 */ public Optional read(String alias) throws DataManagerVaultException { + return read(alias, false); + } + + /** + * Reads a secret from the vault. + * + * @param alias the alias of the secret to read + * @param useKeystorePassword true to use the keystore password (for PKCS12 keystores), + * false to use the entry password + * @return the secret value, or empty if the alias doesn't exist + * @throws DataManagerVaultException if the read fails + */ + public Optional read(String alias, boolean useKeystorePassword) throws DataManagerVaultException { + String passwordEnvVar = useKeystorePassword ? envVarKeystorePassword : envVarKeystoreEntryPassword; try { return Optional.ofNullable( - this.keyStore.getKey(alias, System.getenv(envVarKeystoreEntryPassword).toCharArray())) + this.keyStore.getKey(alias, System.getenv(passwordEnvVar).toCharArray())) .map(k -> new String(k.getEncoded(), StandardCharsets.UTF_8)); } catch (KeyStoreException | NoSuchAlgorithmException e) { throw new DataManagerVaultException(UNEXPECTED_VAULT_EXCEPTION, e); diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialJpaRepository.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialJpaRepository.java new file mode 100644 index 000000000..51ce7f950 --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialJpaRepository.java @@ -0,0 +1,28 @@ +package life.qbic.projectmanagement.infrastructure.dataset.associated; + +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; +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * Spring Data JPA repository for {@link UserExternalCredential} entities. + * + * @since 1.12.0 + */ +interface UserExternalCredentialJpaRepository + extends JpaRepository { + + Optional findByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); + + List findByUserId(String userId); + + List findByUserIdAndSourceType( + String userId, SourceType sourceType); + + void deleteByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialRepositoryImpl.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialRepositoryImpl.java new file mode 100644 index 000000000..d9130d8b8 --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/dataset/associated/UserExternalCredentialRepositoryImpl.java @@ -0,0 +1,75 @@ +package life.qbic.projectmanagement.infrastructure.dataset.associated; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +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; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** + * Domain repository adapter for {@link UserExternalCredential}. + * + *

Wraps {@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 Optional findByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId) { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(sourceType, "sourceType must not be null"); + Objects.requireNonNull(instanceId, "instanceId must not be null"); + return jpaRepository.findByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + } + + @Override + public List findByUserId(String userId) { + Objects.requireNonNull(userId, "userId must not be null"); + return jpaRepository.findByUserId(userId); + } + + @Override + public List findByUserIdAndSourceType( + String userId, SourceType sourceType) { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(sourceType, "sourceType must not be null"); + return jpaRepository.findByUserIdAndSourceType(userId, sourceType); + } + + @Override + @Transactional + public void save(UserExternalCredential credential) { + Objects.requireNonNull(credential, "credential must not be null"); + jpaRepository.save(credential); + } + + @Override + @Transactional + public void deleteByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId) { + Objects.requireNonNull(userId, "userId must not be null"); + Objects.requireNonNull(sourceType, "sourceType must not be null"); + Objects.requireNonNull(instanceId, "instanceId must not be null"); + jpaRepository.deleteByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + } + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptor.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptor.java new file mode 100644 index 000000000..4d84a276f --- /dev/null +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/AesGcmCredentialEncryptor.java @@ -0,0 +1,155 @@ +package life.qbic.projectmanagement.infrastructure.external; + +import static java.util.Objects.requireNonNull; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import life.qbic.projectmanagement.application.associated_dataset.CredentialEncryptor; + +/** + * AES-256-GCM implementation of {@link CredentialEncryptor}. + * + *

Uses 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.

+ * + *

Binary layout

+ * Each call to {@link #encrypt(char[])} produces: + *
+ *   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). + * + *

Security invariants

+ *
    + *
  • Fresh nonce per encryption — never reused with the same key
  • + *
  • Master key loaded once at construction, never exposed via any + * method, never included in {@code toString()} or exception messages
  • + *
  • {@link #decrypt(byte[])} returns {@code char[]} to enable + * zeroing by the caller
  • + *
  • Exception messages never include plaintext token content
  • + *
+ * + * @since 1.12.0 + */ +public class AesGcmCredentialEncryptor implements CredentialEncryptor { + + static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding"; + static final int GCM_NONCE_BYTES = 12; + static final int GCM_TAG_BITS = 128; + public static final int AES_256_KEY_BYTES = 32; + + private final SecretKey masterKey; + private final SecureRandom secureRandom; + + /** + * Creates the encryptor with the given AES master key. + * + * @param masterKey an AES SecretKey (must be 256 bits / 32 bytes) + * @throws IllegalArgumentException if the key is null, has an + * unsupported algorithm, or has an incorrect key size + */ + public AesGcmCredentialEncryptor(SecretKey masterKey) { + this(masterKey, new SecureRandom()); + } + + // Visible for testing — allows injecting a deterministic SecureRandom + AesGcmCredentialEncryptor(SecretKey masterKey, SecureRandom secureRandom) { + this.masterKey = requireNonNull(masterKey, "masterKey must not be null"); + if (!"AES".equalsIgnoreCase(masterKey.getAlgorithm())) { + throw new IllegalArgumentException( + "masterKey algorithm must be AES, got: " + masterKey.getAlgorithm()); + } + byte[] keyBytes = masterKey.getEncoded(); + if (keyBytes == null || keyBytes.length != AES_256_KEY_BYTES) { + throw new IllegalArgumentException( + "masterKey must be 256 bits (32 bytes), got: " + + (keyBytes == null ? "null" : keyBytes.length + " bytes")); + } + this.secureRandom = requireNonNull(secureRandom, + "secureRandom must not be null"); + } + + @Override + public byte[] encrypt(char[] plaintext) { + requireNonNull(plaintext, "plaintext must not be null"); + + byte[] nonce = new byte[GCM_NONCE_BYTES]; + secureRandom.nextBytes(nonce); + + try { + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, masterKey, + new GCMParameterSpec(GCM_TAG_BITS, nonce)); + byte[] ciphertextWithTags = cipher.doFinal( + new String(plaintext).getBytes(StandardCharsets.UTF_8)); + + // Layout: nonce (12) ‖ ciphertext+tag + ByteBuffer output = ByteBuffer.allocate( + GCM_NONCE_BYTES + ciphertextWithTags.length); + output.put(nonce); + output.put(ciphertextWithTags); + return output.array(); + } catch (GeneralSecurityException e) { + throw new ExternalCredentialEncryptorException( + "Encryption failed", e); + } + } + + @Override + public char[] decrypt(byte[] encrypted) { + requireNonNull(encrypted, "encrypted must not be null"); + if (encrypted.length < GCM_NONCE_BYTES + GCM_TAG_BITS / 8) { + throw new ExternalCredentialEncryptorException( + "Encrypted data too short to contain nonce and authentication tag"); + } + + ByteBuffer buffer = ByteBuffer.wrap(encrypted); + byte[] nonce = new byte[GCM_NONCE_BYTES]; + buffer.get(nonce); + byte[] ciphertextWithTags = new byte[buffer.remaining()]; + buffer.get(ciphertextWithTags); + + try { + Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, masterKey, + new GCMParameterSpec(GCM_TAG_BITS, nonce)); + byte[] plaintextBytes = cipher.doFinal(ciphertextWithTags); + return new String(plaintextBytes, StandardCharsets.UTF_8).toCharArray(); + } catch (GeneralSecurityException e) { + throw new ExternalCredentialEncryptorException( + "Decryption failed — data may be corrupted or the master key " + + "may have changed", e); + } + } + + /** + * Exception thrown when credential encryption or decryption fails. + * + *

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 Map adapters; + + public SourceTypeDispatchingCredentialValidator( + Map adapters) { + Objects.requireNonNull(adapters, "adapters must not be null"); + if (adapters.isEmpty()) { + throw new IllegalArgumentException( + "At least one credential validator adapter must be registered"); + } + this.adapters = Map.copyOf(adapters); + } + + @Override + public boolean validateToken(SourceType sourceType, InstanceConfig config, + char[] token) { + Objects.requireNonNull(sourceType, "sourceType must not be null"); + Objects.requireNonNull(config, "config must not be null"); + Objects.requireNonNull(token, "token must not be null"); + + CredentialValidatorAdapter adapter = adapters.get(sourceType); + if (adapter == null) { + throw new CredentialValidationException( + "No credential validator registered for source type: " + + sourceType); + } + return adapter.validate(config, token); + } + +} diff --git a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmClient.java b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmClient.java index 18dcbf728..7dabd4722 100644 --- a/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmClient.java +++ b/project-management-infrastructure/src/main/java/life/qbic/projectmanagement/infrastructure/external/invenio/InvenioRdmClient.java @@ -190,7 +190,23 @@ final class InvenioRdmInterruptedException extends InvenioRdmException { SearchResultResponse search(String instanceUrl, SearchParams params) throws InvenioRdmPermanentException, InvenioRdmTransientException, InvenioRdmResponseParsingException; - + + /** + * Search for records with an optional Authorization header. + * + * @param instanceUrl the base URL of the InvenioRDM instance + * @param params search parameters (query, page, size) + * @param authHeader the full Authorization header value (e.g. + * {@code "Bearer "}), or {@code null} for + * unauthenticated (public) access + * @return search results containing matching records + * @throws InvenioRdmPermanentException on 4xx + * @throws InvenioRdmTransientException on transient errors after retries + */ + SearchResultResponse search(String instanceUrl, SearchParams params, + String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException; + /** * Retrieve a single record by its ID. * @@ -207,6 +223,51 @@ RecordResponse getRecord(String instanceUrl, String recordId) throws InvenioRdmPermanentException, InvenioRdmTransientException, InvenioRdmResponseParsingException; + /** + * Retrieve a single record by its ID, with an optional Authorization header. + * + * @param instanceUrl the base URL of the InvenioRDM instance + * @param recordId the record identifier + * @param authHeader the full Authorization header value (e.g. + * {@code "Bearer "}), or {@code null} for + * unauthenticated (public) access + * @return the record details + * @throws InvenioRdmPermanentException on 4xx + * @throws InvenioRdmTransientException on transient errors after retries + */ + RecordResponse getRecord(String instanceUrl, String recordId, + String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException; + + /** + * Retrieves the authenticated user's identity from the InvenioRDM instance. + * + *

This is the token validation endpoint defined in the InvenioRDM + * OpenAPI specification (operationId: {@code getAUserById}).

+ * + *
    + *
  • {@code 200} — token is valid; response contains the user's + * identity as a JSON object.
  • + *
  • {@code 401} — token is missing, invalid, or expired.
  • + *
+ * + *

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 "Bearer "}) + * @return authenticated user response + * @throws InvenioRdmPermanentException on 4xx (401 = invalid token) + * @throws InvenioRdmTransientException on 5xx or network errors after retries + * @throws InvenioRdmResponseParsingException if the response cannot be parsed + */ + AuthenticatedUserResponse getAuthenticatedUser(String instanceUrl, String authHeader) + throws InvenioRdmPermanentException, InvenioRdmTransientException, + InvenioRdmResponseParsingException; + /** * Parameters for a search request. All nullable fields are optional. */ @@ -473,6 +534,21 @@ record RecordAccess( @JsonProperty("status") String status ) {} + /** + * Authenticated user response from the InvenioRDM spec-defined + * {@code GET /api/users} endpoint (operationId: {@code getAUserById}). + * + *

The 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 }.

+ * + *
    + *
  • {@code 200} — token is valid → returns {@code true}
  • + *
  • {@code 401 / 403} — token is invalid or expired → returns + * {@code false}
  • + *
  • Transient failures (5xx, network errors) → throws + * {@link CredentialValidationException}
  • + *
+ * + *

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); List hits = mapSearchHits(response, config.displayName()); - return new SearchResult(hits, response.hits().total(), query.page(), - query.pageSize()); + return new SearchResult(hits, response.hits().total(), + query.page(), query.pageSize()); } catch (InvenioRdmClient.InvenioRdmException e) { log.error("Search failed on %s for query '%s'".formatted( config.displayName(), query.effectiveQuery())); throw new DatasetSearchException("Search failed", e); + } finally { + if (token != null) { + Arrays.fill(token, '\0'); + } } } @@ -79,8 +98,12 @@ public Optional resolveMetadata( "externalHandleValue must not be null"); Objects.requireNonNull(config, "config must not be null"); + char[] token = resolveTokenForUser(actingUserId, config.id()); try { - var invenioRecord = client.getRecord(config.baseUrl(), externalHandleValue); + String authHeader = token != null + ? "Bearer " + new String(token) : null; + var invenioRecord = client.getRecord(config.baseUrl(), + externalHandleValue, authHeader); InvenioRdmResourceMetadata metadata = mapRecordToResourceMetadata( invenioRecord, config.displayName()); return Optional.of(metadata); @@ -92,7 +115,31 @@ public Optional resolveMetadata( log.error("Failed to resolve record %s on %s" .formatted(externalHandleValue, config.displayName())); throw new DatasetResolveException("Failed to resolve metadata record", e); + } finally { + if (token != null) { + Arrays.fill(token, '\0'); + } + } + } + + /** + * Resolves the decrypted user token for the given user and + * InvenioRDM instance, or returns {@code null} if no credential + * is configured. + * + *

The 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 + Optional descriptor = + instanceRegistry.find(instanceId); + if (descriptor.isEmpty()) { + return new UnknownInstance(instanceId); + } + SourceInstanceDescriptor instance = descriptor.get(); + InstanceConfig config = instance.toInstanceConfig(); + SourceType sourceType = instance.sourceType(); + + // 2. Validate the token against the external instance + boolean valid; + try { + valid = validator.validateToken(sourceType, config, token); + } catch (CredentialValidationException e) { + return new ServiceError( + "Token validation could not be completed: " + e.getMessage()); + } + if (!valid) { + return new InvalidToken( + "The token was rejected by " + instance.displayName()); + } + + // 3. Encrypt and persist + byte[] encryptedToken = encryptor.encrypt(token); + Optional existing = + credentialRepository.findByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + + if (existing.isPresent()) { + // Replace: update the existing credential + UserExternalCredential cred = existing.get(); + cred.transitionTo(CredentialStatus.VALID); + // The entity holds encryptedToken as a field — create a new + // credential with updated token blob + credentialRepository.deleteByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + } + + UserExternalCredential newCredential = new UserExternalCredential( + userId, sourceType, instanceId, encryptedToken, + CredentialStatus.VALID); + credentialRepository.save(newCredential); + + return new Success(); + } finally { + Arrays.fill(token, '\0'); + } + } + + @Override + public AddCredentialResult validateCredential(String userId, String instanceId) { + requireNonNull(userId, "userId must not be null"); + requireNonNull(instanceId, "instanceId must not be null"); + + Optional descriptor = + instanceRegistry.find(instanceId); + if (descriptor.isEmpty()) { + return new UnknownInstance(instanceId); + } + SourceInstanceDescriptor instance = descriptor.get(); + InstanceConfig config = instance.toInstanceConfig(); + SourceType sourceType = instance.sourceType(); + + Optional existing = + credentialRepository.findByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + + if (existing.isEmpty()) { + return new ServiceError("No credential found for instance: " + instanceId); + } + + UserExternalCredential cred = existing.get(); + byte[] encryptedToken = cred.getEncryptedToken(); + char[] token = null; + try { + token = encryptor.decrypt(encryptedToken); + boolean valid; + try { + valid = validator.validateToken(sourceType, config, token); + } catch (CredentialValidationException e) { + return new ServiceError( + "Token validation could not be completed: " + e.getMessage()); + } + + if (valid) { + cred.transitionTo(CredentialStatus.VALID); + credentialRepository.save(cred); + return new Success(); + } else { + cred.transitionTo(CredentialStatus.INVALIDATED); + credentialRepository.save(cred); + return new InvalidToken( + "The token was rejected by " + instance.displayName()); + } + } finally { + if (token != null) { + Arrays.fill(token, '\0'); + } + } + } + + @Override + public boolean removeCredential(String userId, String instanceId) { + requireNonNull(userId, "userId must not be null"); + requireNonNull(instanceId, "instanceId must not be null"); + + // Check if the credential exists for any source type at this instance + Optional descriptor = + instanceRegistry.find(instanceId); + if (descriptor.isEmpty()) { + return false; + } + SourceType sourceType = descriptor.get().sourceType(); + + Optional existing = + credentialRepository.findByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + if (existing.isEmpty()) { + return false; + } + credentialRepository.deleteByUserIdAndSourceTypeAndInstanceId( + userId, sourceType, instanceId); + return true; + } + + @Override + public List listCredentialStatuses(String userId) { + requireNonNull(userId, "userId must not be null"); + + List allInstances = + instanceRegistry.findBySourceType(SourceType.INVENIO_RDM); + + return allInstances.stream() + .map(instance -> { + var credential = credentialRepository + .findByUserIdAndSourceTypeAndInstanceId( + userId, instance.sourceType(), instance.id()); + boolean configured = credential.isPresent(); + + String status; + Instant configuredAt = null; + if (!configured) { + status = "NOT_CONFIGURED"; + } else { + status = credential.map(c -> c.getStatus().name()) + .orElse("NOT_CONFIGURED"); + configuredAt = credential + .map(life.qbic.projectmanagement.domain.model.associated_dataset.UserExternalCredential::getCreatedAt) + .orElse(null); + } + + return new CredentialStatusView( + instance.sourceType(), + instance.id(), + instance.displayName(), + instance.baseUrl(), + configured, + status, + configuredAt); + }) + .toList(); + } + +} diff --git a/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialService.java b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialService.java new file mode 100644 index 000000000..2cfe27216 --- /dev/null +++ b/project-management/src/main/java/life/qbic/projectmanagement/application/associated_dataset/ExternalCredentialService.java @@ -0,0 +1,127 @@ +package life.qbic.projectmanagement.application.associated_dataset; + +import java.time.Instant; +import java.util.List; +import life.qbic.projectmanagement.domain.model.associated_dataset.SourceType; + +/** + * Application service for managing user-level external provider + * credentials. + * + *

Orchestrates 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 + */ + List listCredentialStatuses(String userId); + + /** + * Validates the currently stored token for a user and instance against + * the external provider, and updates the stored status accordingly. + * + *

This 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 findByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); + + /** + * Finds all credentials for the given user, regardless of source type. + * + * @param userId the DM user ID + * @return all stored credentials; never null (may be empty) + */ + List findByUserId(String userId); + + /** + * Finds all credentials for the given user and source type. + * + * @param userId the DM user ID + * @param sourceType the source type + * @return all stored credentials of that type; never null (may be empty) + */ + List findByUserIdAndSourceType( + String userId, SourceType sourceType); + + /** + * Persists a credential (insert or update). + * + * @param credential the credential to persist + */ + void save(UserExternalCredential credential); + + /** + * Removes 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 + */ + void deleteByUserIdAndSourceTypeAndInstanceId( + String userId, SourceType sourceType, String instanceId); + +} diff --git a/sql/complete-schema.sql b/sql/complete-schema.sql index e676ef79d..e6ab96777 100644 --- a/sql/complete-schema.sql +++ b/sql/complete-schema.sql @@ -1037,3 +1037,33 @@ CREATE TABLE IF NOT EXISTS `associated_dataset` ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- =========================================================================== +-- User External Credential +-- Per ADR-0002 S2: per-user, per-instance personal access tokens for external +-- data providers. The master AES-256 key lives in the PKCS12 vault (deploy +-- time); each token is individually AES-GCM-encrypted (nonce + ciphertext + +-- tag) and stored as a VARBINARY blob. Any HA node can decrypt (same key, +-- shared DB). +-- +-- The table is source-agnostic: `source_type` + `instance_id` together +-- identify the provider and instance. Adding a second provider requires no +-- schema change — just insert rows with a different `source_type`. +-- =========================================================================== +CREATE TABLE IF NOT EXISTS `user_external_credential` +( + `id` varchar(36) NOT NULL, + `user_id` varchar(255) NOT NULL COMMENT 'DM user ID', + `source_type` varchar(32) NOT NULL COMMENT 'e.g. INVENIO_RDM — matches SourceType enum', + `instance_id` varchar(64) NOT NULL COMMENT 'matches InstanceConfig.id (e.g. zenodo, fdat)', + `encrypted_token` varbinary(512) NOT NULL COMMENT 'AES-256-GCM: 12-byte nonce ‖ ciphertext ‖ 16-byte auth tag', + `status` varchar(16) NOT NULL COMMENT 'VALID | INVALIDATED', + `created_at` timestamp(3) NOT NULL, + `updated_at` timestamp(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_src_instance` (`user_id`, `source_type`, `instance_id`), + KEY `idx_cred_user` (`user_id`), + KEY `idx_cred_user_src` (`user_id`, `source_type`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci; diff --git a/sql/migrations/create-user-external-credential.sql b/sql/migrations/create-user-external-credential.sql new file mode 100644 index 000000000..6b773465d --- /dev/null +++ b/sql/migrations/create-user-external-credential.sql @@ -0,0 +1,37 @@ +-- ============================================================================= +-- Migration: Create `user_external_credential` table +-- Feature: FEAT-DATSET-14 — Add credentials for an InvenioRDM instance +-- ADRs: 0002 (credential storage), 0003 (lifecycle) +-- +-- Stores per-user, per-instance personal access tokens for external data +-- providers. The table is source-agnostic at the schema level: `source_type` +-- and `instance_id` together identify the provider and instance. Today the +-- only rows will be INVENIO_RDM / zenodo | fdat. Future providers add rows +-- with a different source_type — no schema change required. +-- +-- Tokens are AES-256-GCM encrypted at rest (nonce + ciphertext + 16-byte GCM +-- tag). The master key is stored in the PKCS12 vault at deploy time under a +-- dedicated alias (see qbic.security.vault.external-credential.key-alias). +-- +-- Unique constraint on (user_id, source_type, instance_id): one token per +-- user per instance. source_type is included to keep the schema fully +-- provider-agnostic. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS `user_external_credential` +( + `id` varchar(36) NOT NULL, + `user_id` varchar(255) NOT NULL COMMENT 'DM user ID', + `source_type` varchar(32) NOT NULL COMMENT 'e.g. INVENIO_RDM — matches SourceType enum', + `instance_id` varchar(64) NOT NULL COMMENT 'matches InstanceConfig.id (e.g. zenodo, fdat)', + `encrypted_token` varbinary(512) NOT NULL COMMENT 'AES-256-GCM: 12-byte nonce ‖ ciphertext ‖ 16-byte auth tag', + `status` varchar(16) NOT NULL COMMENT 'VALID | INVALIDATED', + `created_at` timestamp(3) NOT NULL, + `updated_at` timestamp(3) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_src_instance` (`user_id`, `source_type`, `instance_id`), + KEY `idx_cred_user` (`user_id`), + KEY `idx_cred_user_src` (`user_id`, `source_type`) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4 + COLLATE = utf8mb4_unicode_ci;