principals = new ArrayList<>(2);
+ principals.add(new UserNamePrincipal(usernameNode.asText()));
+
+ if (internalNode.has("display_name")) {
+ JsonNode displayNameNode = internalNode.get("display_name");
+ if (displayNameNode.isTextual()) {
+ principals.add(new FullNamePrincipal(displayNameNode.asText()));
+ }
+ }
+
+ return Result.success(principals);
+ }
+}
diff --git a/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AlisePlugin.java b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AlisePlugin.java
new file mode 100644
index 00000000000..55683cdd437
--- /dev/null
+++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AlisePlugin.java
@@ -0,0 +1,190 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import com.google.common.annotations.VisibleForTesting;
+import static com.google.common.base.Preconditions.checkArgument;
+import com.google.common.base.Splitter;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Properties;
+import java.util.Set;
+import java.util.stream.Collectors;
+import javax.annotation.Nonnull;
+import org.dcache.auth.OAuthProviderPrincipal;
+import org.dcache.auth.OidcSubjectPrincipal;
+import org.dcache.gplazma.AuthenticationException;
+import org.dcache.gplazma.plugins.GPlazmaMappingPlugin;
+import org.dcache.util.Result;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A mapping plugin that makes use of the Account LInking SErvice: ALISE.
+ *
+ * ALISE allows a user to link their federated identity (an OIDC issuer + 'sub'
+ * claim) and their site-local user identity (expressed as a username). In
+ * addition, ALISE returns other information, such as the user's display name.
+ *
+ * ALISE works by allowing external services (such as dCache) to query its
+ * internal database of mapped identity with an issuer and a sub-claim value.
+ * If the query is successful then information about that user is returned,
+ * otherwise an error is returned. These queries require an API KEY.
+ *
+ * For further details, see the project's
+ * GitHub page
+ *
+ * TODO: ALISE provides a list of issuers it accepts. This list is available
+ * via the ${ALISE}/alise/supported_issuers endpoint. A future version of this
+ * plugin could maintain a (dCache-internal) cached copy of this list and use
+ * it to avoid sending ALISE tokens that it does not support.
+ */
+public class AlisePlugin implements GPlazmaMappingPlugin {
+ private static final Logger LOGGER = LoggerFactory.getLogger(AlisePlugin.class);
+
+ private final List mappableIssuers;
+ private final LookupAgent lookupAgent;
+
+ @Nonnull
+ private static String getRequiredProperty(Properties config, String key) {
+ var value = config.getProperty(key);
+ checkArgument(value != null, "Config property \"%s\" is missing.", key);
+ return value;
+ }
+
+ private static LookupAgent buildLookupAgent(Properties config)
+ throws URISyntaxException {
+ var configEndpoint = getRequiredProperty(config,"gplazma.alise.endpoint");
+ var endpoint = new URI(configEndpoint);
+ var apikey = getRequiredProperty(config,"gplazma.alise.apikey");
+ var target = getRequiredProperty(config, "gplazma.alise.target");
+ var timeout = getRequiredProperty(config, "gplazma.alise.timeout");
+ LookupAgent alise = new AliseLookupAgent(endpoint, target, apikey, timeout);
+
+ LookupAgent cache = new CachingLookupAgent(alise);
+
+ return cache;
+ }
+
+ public AlisePlugin(Properties config) throws URISyntaxException {
+ this(config, buildLookupAgent(config));
+ }
+
+ @VisibleForTesting
+ AlisePlugin(Properties config, LookupAgent agent) {
+ var issuerList = getRequiredProperty(config, "gplazma.alise.issuers");
+ mappableIssuers = Splitter.on(' ').omitEmptyStrings().splitToList(issuerList);
+ lookupAgent = requireNonNull(agent);
+ }
+
+ private Optional toIdentity(OidcSubjectPrincipal principal,
+ Map issuers) {
+ String issuerAlias = principal.getOP();
+ URI issuer = issuers.get(issuerAlias);
+ if (issuer == null) {
+ // This is probably a bug, skip this identity.
+ LOGGER.warn("sub from issuer {}, but no corresponding OAuthProviderPrincipal",
+ issuerAlias);
+ return Optional.empty();
+ }
+
+ if (!mappableIssuers.isEmpty()
+ && !mappableIssuers.contains(issuerAlias)
+ && !mappableIssuers.contains(issuer.toASCIIString())) {
+ return Optional.empty();
+ }
+
+ return Optional.of(new Identity(issuer, principal.getSubClaim()));
+ }
+
+ private Result,String> buildIdentityList(Collection principals)
+ throws AuthenticationException {
+ var allSubs = principals.stream()
+ .filter(OidcSubjectPrincipal.class::isInstance)
+ .map(OidcSubjectPrincipal.class::cast)
+ .collect(toList());
+
+ if (allSubs.isEmpty()) {
+ return Result.failure("No 'sub' claims");
+ }
+
+ Map allIssuers = principals.stream()
+ .filter(OAuthProviderPrincipal.class::isInstance)
+ .map(OAuthProviderPrincipal.class::cast)
+ .collect(toMap(OAuthProviderPrincipal::getName,
+ OAuthProviderPrincipal::getIssuer));
+
+ var identities = allSubs.stream()
+ .map(s -> toIdentity(s, allIssuers))
+ .flatMap(Optional::stream)
+ .collect(toList());
+
+ return identities.isEmpty()
+ ? Result.failure("No mappable 'sub' claim")
+ : Result.success(identities);
+ }
+
+ private String buildFailureMessage(List,String>> results) {
+ return results.stream()
+ .filter(Result::isFailure)
+ .map(Result::getFailure)
+ .map(Optional::orElseThrow)
+ .collect(Collectors.joining(", "));
+ }
+
+ @Override
+ public void map(Set principals) throws AuthenticationException {
+
+ var identities = buildIdentityList(principals);
+
+ var lookups = identities.orElseThrow(AuthenticationException::new).stream()
+ .map(lookupAgent::lookup)
+ .collect(toList());
+
+ if (lookups.stream().allMatch(Result::isFailure)) {
+ throw new AuthenticationException("Lookup failed: "
+ + buildFailureMessage(lookups));
+ }
+
+ if (lookups.stream().anyMatch(Result::isFailure) && LOGGER.isDebugEnabled()) {
+ LOGGER.debug("Mapping succeeded with some failures: {}",
+ buildFailureMessage(lookups));
+ }
+
+ lookups.stream()
+ .map(Result::getSuccess)
+ .filter(Optional::isPresent)
+ .flatMap(o -> o.orElseThrow().stream())
+ .forEach(principals::add);
+ }
+
+ @Override
+ public void stop() throws Exception {
+ lookupAgent.shutdown();
+ }
+}
diff --git a/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/CachingLookupAgent.java b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/CachingLookupAgent.java
new file mode 100644
index 00000000000..7532705f470
--- /dev/null
+++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/CachingLookupAgent.java
@@ -0,0 +1,88 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListenableFutureTask;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.dcache.util.BoundedCachedExecutor;
+import org.dcache.util.Result;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A simple caching layer to prevents dCache from hammering ALISE, if there are
+ * repeated requests for the same identity.
+ *
+ * The primary use-case is when there are multiple clients using dCache with
+ * different (distinct) OIDC tokens but that have the same underlying identity
+ * ('iss' and 'sub' claim). The dCache doors will forward each OIDC token to
+ * gPlazma as the bearer tokens are distinct, but repeated calls to ALISE for
+ * the same identity makes no sense.
+ */
+public class CachingLookupAgent implements LookupAgent {
+
+ private final LookupAgent inner;
+ private final ExecutorService executor = new BoundedCachedExecutor(5);
+
+ private final LoadingCache, String>> lookupResults = CacheBuilder.newBuilder()
+ .maximumSize(1_000)
+ .refreshAfterWrite(5, TimeUnit.SECONDS)
+ .expireAfterWrite(60, TimeUnit.SECONDS)
+ .build(new CacheLoader, String>>() {
+ @Override
+ public Result, String> load(Identity identity) {
+ return inner.lookup(identity);
+ }
+
+ @Override
+ public ListenableFuture, String>> reload(Identity identity, Result,String> prevResult) {
+ var task = ListenableFutureTask.create(() -> inner.lookup(identity));
+ executor.execute(task);
+ return task;
+ }
+ });
+
+ public CachingLookupAgent(LookupAgent inner) {
+ this.inner = requireNonNull(inner);
+ }
+
+ @Override
+ public Result, String> lookup(Identity identity) {
+ try {
+ return lookupResults.get(identity);
+ } catch (ExecutionException e) {
+ Throwable reported = e.getCause() == null ? e : e.getCause();
+ return Result.failure("Cache lookup failed: " + reported);
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ executor.shutdown();
+ inner.shutdown();
+ }
+}
diff --git a/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/Identity.java b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/Identity.java
new file mode 100644
index 00000000000..f4427e696cc
--- /dev/null
+++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/Identity.java
@@ -0,0 +1,63 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import com.google.common.base.Objects;
+import java.net.URI;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * The identity that ALISE will look up.
+ */
+public class Identity {
+ private final String sub;
+ private final URI issuer;
+
+ public Identity(URI issuer, String sub) {
+ this.issuer = requireNonNull(issuer);
+ this.sub = requireNonNull(sub);
+ }
+
+ public String sub() {
+ return sub;
+ }
+
+ public URI issuer() {
+ return issuer;
+ }
+
+ @Override
+ public boolean equals(Object other) {
+ if (other == this) {
+ return true;
+ }
+
+ if (!(other instanceof Identity)) {
+ return false;
+ }
+
+ Identity otherIdentity = (Identity)other;
+ return otherIdentity.sub.equals(sub) && otherIdentity.issuer.equals(issuer);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hashCode(sub, issuer);
+ }
+}
diff --git a/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/LookupAgent.java b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/LookupAgent.java
new file mode 100644
index 00000000000..488814b5847
--- /dev/null
+++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/LookupAgent.java
@@ -0,0 +1,47 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.Collection;
+import org.dcache.util.Result;
+
+/**
+ * A service that allows looking up principals (local identity) from the user's
+ * an OIDC subject.
+ */
+@FunctionalInterface
+public interface LookupAgent {
+ /**
+ * Look up the principals associated with an OIDC subject from some
+ * trusted issuer. The result is either a corresponding set of principals
+ * or an error message.
+ * @param identity the federated identity.
+ * @return The result of the lookup; if successful a collection of
+ * principals that identify the user, if failure a suitable error message.
+ */
+ public Result,String> lookup(Identity identity);
+
+ /**
+ * A method called when the plugin is shutting down. Should release
+ * established resources (e.g., shutting down threads).
+ */
+ default public void shutdown() {}
+}
diff --git a/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AliseLookupAgentTest.java b/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AliseLookupAgentTest.java
new file mode 100644
index 00000000000..0577b944283
--- /dev/null
+++ b/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AliseLookupAgentTest.java
@@ -0,0 +1,363 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpHeaders;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.security.Principal;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.dcache.auth.FullNamePrincipal;
+import org.dcache.auth.UserNamePrincipal;
+import org.dcache.util.Result;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import static com.github.npathai.hamcrestopt.OptionalMatchers.*;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.assertTrue;
+import org.junit.Before;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+public class AliseLookupAgentTest {
+
+ private HttpClient client;
+ private AliseLookupAgent agent;
+ private Identity identity;
+ private HttpRequest request;
+ private Result, String> result;
+
+ @Before
+ public void setup() {
+ client = null;
+ agent = null;
+ identity = null;
+ request = null;
+ result = null;
+ }
+
+ @Test
+ public void testGoodResponseNoDisplayName() throws Exception {
+ given(anHttpClient().thatRespondsWith(aResponse()
+ .withHeaders(someHeaders().withHeader("Content-Type", "application/json"))
+ .withBody("{\"internal\": {\"username\": \"pmillar\"}}")
+ .withStatusCode(200)));
+ given(anAliseLookupAgent()
+ .withApiKey("APIKEY")
+ .withEndpoint("https://alise.data.kit.edu/")
+ .withTarget("vega-kc"));
+ given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/"));
+
+ whenAgentCalledWithIdentity();
+
+ var expected = URI.create("https://alise.data.kit.edu/api/v1"
+ + "/target/vega-kc"
+ + "/mapping/issuer/95d86dadf13ccfbd25f13d5d15edb31b4b30c971"
+ + "/user/paul?apikey=APIKEY");
+ assertThat(request.uri(), is(equalTo(expected)));
+ assertThat(request.method(), is(equalTo("GET")));
+
+ assertThat(result.getSuccess(), isPresentAnd(contains(
+ new UserNamePrincipal("pmillar"))));
+ }
+
+ @Test
+ public void testGoodResponseWithDisplayName() throws Exception {
+ given(anHttpClient().thatRespondsWith(aResponse()
+ .withHeaders(someHeaders().withHeader("Content-Type", "application/json"))
+ .withBody("{\"internal\": {\"username\": \"pmillar\", \"display_name\": \"Paul Millar\"}}")
+ .withStatusCode(200)));
+ given(anAliseLookupAgent()
+ .withApiKey("APIKEY")
+ .withEndpoint("https://alise.data.kit.edu/")
+ .withTarget("vega-kc"));
+ given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/"));
+
+ whenAgentCalledWithIdentity();
+
+ var expected = URI.create("https://alise.data.kit.edu/api/v1"
+ + "/target/vega-kc"
+ + "/mapping/issuer/95d86dadf13ccfbd25f13d5d15edb31b4b30c971"
+ + "/user/paul?apikey=APIKEY");
+ assertThat(request.uri(), is(equalTo(expected)));
+ assertThat(request.method(), is(equalTo("GET")));
+
+ assertThat(result.getSuccess(), isPresentAnd(containsInAnyOrder(
+ new UserNamePrincipal("pmillar"),
+ new FullNamePrincipal("Paul Millar"))));
+ }
+
+ @Test
+ public void testMalformedResponse() throws Exception {
+ given(anHttpClient().thatRespondsWith(aResponse()
+ .withHeaders(someHeaders().withHeader("Content-Type", "application/json"))
+ .withBody("{}")
+ .withStatusCode(200)));
+ given(anAliseLookupAgent()
+ .withApiKey("APIKEY")
+ .withEndpoint("https://alise.data.kit.edu/")
+ .withTarget("vega-kc"));
+ given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/"));
+
+ whenAgentCalledWithIdentity();
+
+ var expected = URI.create("https://alise.data.kit.edu/api/v1"
+ + "/target/vega-kc"
+ + "/mapping/issuer/95d86dadf13ccfbd25f13d5d15edb31b4b30c971"
+ + "/user/paul?apikey=APIKEY");
+ assertThat(request.uri(), is(equalTo(expected)));
+ assertThat(request.method(), is(equalTo("GET")));
+
+ assertThat(result.getFailure(), isPresentAnd(containsString("internal")));
+ }
+
+ @Test
+ public void testMessageErrorResponse() throws Exception {
+ given(anHttpClient().thatRespondsWith(aResponse()
+ .withHeaders(someHeaders().withHeader("Content-Type", "application/json"))
+ .withBody("{\"message\": \"FNORD\"}")
+ .withStatusCode(400)));
+ given(anAliseLookupAgent()
+ .withApiKey("APIKEY")
+ .withEndpoint("https://alise.data.kit.edu/")
+ .withTarget("vega-kc"));
+ given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/"));
+
+ whenAgentCalledWithIdentity();
+
+ var expected = URI.create("https://alise.data.kit.edu/api/v1"
+ + "/target/vega-kc"
+ + "/mapping/issuer/95d86dadf13ccfbd25f13d5d15edb31b4b30c971"
+ + "/user/paul?apikey=APIKEY");
+ assertThat(request.uri(), is(equalTo(expected)));
+ assertThat(request.method(), is(equalTo("GET")));
+
+ assertThat(result.getFailure(), isPresentAnd(containsString("FNORD")));
+ }
+
+ @Test
+ public void testDetailErrorResponse() throws Exception {
+ given(anHttpClient().thatRespondsWith(aResponse()
+ .withHeaders(someHeaders().withHeader("Content-Type", "application/json"))
+ .withBody("{\"detail\": [{\"msg\": \"FOO\", \"loc\": [\"item-1\", \"item-2\"]}]}")
+ .withStatusCode(400)));
+ given(anAliseLookupAgent()
+ .withApiKey("APIKEY")
+ .withEndpoint("https://alise.data.kit.edu/")
+ .withTarget("vega-kc"));
+ given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/"));
+
+ whenAgentCalledWithIdentity();
+
+ var expected = URI.create("https://alise.data.kit.edu/api/v1"
+ + "/target/vega-kc"
+ + "/mapping/issuer/95d86dadf13ccfbd25f13d5d15edb31b4b30c971"
+ + "/user/paul?apikey=APIKEY");
+ assertThat(request.uri(), is(equalTo(expected)));
+ assertThat(request.method(), is(equalTo("GET")));
+
+ assertTrue(result.isFailure());
+ String failureMessage = result.getFailure().get();
+ assertThat(failureMessage, containsString("FOO"));
+ assertThat(failureMessage, containsString("item-1"));
+ assertThat(failureMessage, containsString("item-2"));
+ }
+
+ private void whenAgentCalledWithIdentity() {
+ checkState(identity != null);
+ result = agent.lookup(identity);
+
+ ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ try {
+ verify(client).send(requestCaptor.capture(), any());
+ } catch (InterruptedException | IOException e) {
+ throw new RuntimeException("verify unexpectedly throw an exception", e);
+ }
+
+ request = requestCaptor.getValue();
+ }
+
+ public AliseLookupAgentBuilder anAliseLookupAgent() {
+ return new AliseLookupAgentBuilder();
+ }
+
+ public HttpClientBuilder anHttpClient() {
+ return new HttpClientBuilder();
+ }
+
+ public HttpResponseBuilder aResponse() {
+ return new HttpResponseBuilder();
+ }
+
+ public HttpHeadersBuilder someHeaders() {
+ return new HttpHeadersBuilder();
+ }
+
+ public IdentityBuilder anIdentity() {
+ return new IdentityBuilder();
+ }
+
+ public void given(HttpClientBuilder builder) {
+ client = builder.build();
+ }
+
+ public void given(AliseLookupAgentBuilder builder) {
+ agent = builder.build();
+ }
+
+ public void given(IdentityBuilder builder) {
+ identity = builder.build();
+ }
+
+ public static class HttpClientBuilder {
+ private final HttpClient client = mock(HttpClient.class);
+ private HttpResponse response;
+
+ public HttpClientBuilder thatRespondsWith(HttpResponseBuilder builder) {
+ checkState(response==null);
+ response = builder.build();
+ return this;
+ }
+
+ public HttpClient build() {
+ try {
+ when(client.send(any(), any())).thenReturn(response);
+ } catch (InterruptedException | IOException e) {
+ throw new RuntimeException("Mocking generated exception", e);
+ }
+ return client;
+ }
+ }
+
+ public static class HttpResponseBuilder {
+ private final HttpResponse response = mock(HttpResponse.class);
+ private int statusCode;
+ private HttpHeaders headers;
+ private String body;
+
+ public HttpResponseBuilder withHeaders(HttpHeadersBuilder builder) {
+ checkState(headers==null);
+ headers = builder.build();
+ return this;
+ }
+
+ public HttpResponseBuilder withStatusCode(int code) {
+ checkArgument(code > 0);
+ checkState(statusCode == 0);
+ statusCode = code;
+ return this;
+ }
+
+ public HttpResponseBuilder withBody(String value) {
+ checkState(body == null);
+ body = requireNonNull(value);
+ return this;
+ }
+
+ public HttpResponse build() {
+ if (headers != null) {
+ when(response.headers()).thenReturn(headers);
+ }
+ if (statusCode != 0) {
+ when(response.statusCode()).thenReturn(statusCode);
+ }
+ if (body != null) {
+ when(response.body()).thenReturn(body);
+ }
+ return response;
+ }
+ }
+
+ public static class HttpHeadersBuilder {
+ private final Map> headers = new HashMap<>();
+
+ public HttpHeadersBuilder withHeader(String key, String value) {
+ headers.computeIfAbsent(key, k -> new ArrayList())
+ .add(value);
+ return this;
+ }
+
+ public HttpHeaders build() {
+ return HttpHeaders.of(headers, (k,v) -> true);
+ }
+ }
+
+ public class AliseLookupAgentBuilder {
+ private String apikey;
+ private String timeout = "PT1M";
+ private URI endpoint;
+ private String target;
+
+ public AliseLookupAgentBuilder withApiKey(String key) {
+ apikey = requireNonNull(key);
+ return this;
+ }
+
+ public AliseLookupAgentBuilder withTimeout(String timeout) {
+ this.timeout = requireNonNull(timeout);
+ return this;
+ }
+
+ public AliseLookupAgentBuilder withEndpoint(String endpoint) {
+ this.endpoint = URI.create(requireNonNull(endpoint));
+ return this;
+ }
+
+ public AliseLookupAgentBuilder withTarget(String target) {
+ this.target = requireNonNull(target);
+ return this;
+ }
+
+ public AliseLookupAgent build() {
+ return new AliseLookupAgent(requireNonNull(client), endpoint,
+ target, requireNonNull(apikey), timeout);
+ }
+ }
+
+ public static class IdentityBuilder {
+ private String subject;
+ private URI issuer;
+
+ public IdentityBuilder withIssuer(String issuer) {
+ this.issuer = URI.create(requireNonNull(issuer));
+ return this;
+ }
+
+ public IdentityBuilder withSub(String sub) {
+ subject = requireNonNull(sub);
+ return this;
+ }
+
+ public Identity build() {
+ return new Identity(requireNonNull(issuer), requireNonNull(subject));
+ }
+ }
+}
diff --git a/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AlisePluginTest.java b/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AlisePluginTest.java
new file mode 100644
index 00000000000..7eebf262981
--- /dev/null
+++ b/modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AlisePluginTest.java
@@ -0,0 +1,262 @@
+/*
+ * dCache - http://www.dcache.org/
+ *
+ * Copyright (C) 2024 Deutsches Elektronen-Synchrotron
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package org.dcache.gplazma.alise;
+
+import java.net.URI;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Properties;
+import java.util.Set;
+import org.dcache.auth.UserNamePrincipal;
+import org.dcache.gplazma.AuthenticationException;
+import org.dcache.util.PrincipalSetMaker;
+import org.dcache.util.Result;
+import org.junit.Test;
+import org.junit.Before;
+import org.mockito.ArgumentCaptor;
+import java.util.HashSet;
+import java.util.List;
+import org.dcache.auth.FullNamePrincipal;
+
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasItems;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+public class AlisePluginTest {
+
+ private AlisePlugin plugin;
+ private Set principals;
+ private LookupAgent agent;
+ private List lookupCalls;
+
+ @Before
+ public void setup() {
+ plugin = null;
+ principals = null;
+ agent = null;
+ lookupCalls = null;
+ }
+
+ @Test(expected=AuthenticationException.class)
+ public void shouldFailIfNoSubClaim() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "")
+ .withAgent(aLookupAgent().thatFailsTestIfCalled()));
+
+ whenPluginMapWith(principals().withDn("/O=ACME/CN=Wile E Coyote"));
+ }
+
+ @Test(expected=AuthenticationException.class)
+ public void shouldFailIfMissingOpPrincipal() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "")
+ .withAgent(aLookupAgent().thatFailsTestIfCalled()));
+
+ whenPluginMapWith(principals().withOidc("paul", "EXAMPLE-OP"));
+ }
+
+ @Test(expected=AuthenticationException.class)
+ public void shouldFailIfOpNotListedByAlias() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "EXAMPLE-OP")
+ .withAgent(aLookupAgent().thatFailsTestIfCalled()));
+
+ whenPluginMapWith(principals().withOidc("paul", "SOME-OTHER-OP"));
+ }
+
+ @Test(expected=AuthenticationException.class)
+ public void shouldFailIfOpNotListedByUri() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "https://issuer.example.org/")
+ .withAgent(aLookupAgent().thatFailsTestIfCalled()));
+
+ whenPluginMapWith(principals().withOidc("paul", "SOME-OTHER-OP"));
+ }
+
+ @Test
+ public void shouldAcceptSuccessfulLookupWithUsername() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "")
+ .withAgent(aLookupAgent().thatReturnsSuccess(principals().withUsername("paul"))));
+
+ whenPluginMapWith(principals()
+ .withOidc("paul", "EXAMPLE-OP")
+ .withOauth2Provider("EXAMPLE-OP", URI.create("https://issuer.example.org/")));
+
+ assertThat(lookupCalls, contains(new Identity(URI.create("https://issuer.example.org/"), "paul")));
+ assertThat(principals, hasItem(new UserNamePrincipal("paul")));
+ }
+
+ @Test(expected=AuthenticationException.class)
+ public void shouldFailIfLookupFails() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "")
+ .withAgent(aLookupAgent().thatReturnsFailure("fnord")));
+
+ try {
+ whenPluginMapWith(principals()
+ .withOidc("paul", "EXAMPLE-OP")
+ .withOauth2Provider("EXAMPLE-OP", URI.create("https://issuer.example.org/")));
+ } catch (AuthenticationException e) {
+ assertThat(e.getMessage(), containsString("fnord"));
+ throw e;
+ }
+ }
+
+ @Test
+ public void shouldAcceptSuccessfulLookupWithUsernameAndFullname() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "")
+ .withAgent(aLookupAgent().thatReturnsSuccess(principals()
+ .withUsername("paul")
+ .withFullname("Paul Millar"))));
+
+ whenPluginMapWith(principals()
+ .withOidc("paul", "EXAMPLE-OP")
+ .withOauth2Provider("EXAMPLE-OP", URI.create("https://issuer.example.org/")));
+
+ assertThat(lookupCalls, contains(new Identity(URI.create("https://issuer.example.org/"), "paul")));
+ assertThat(principals, hasItems(new UserNamePrincipal("paul"),
+ new FullNamePrincipal("Paul Millar")));
+ }
+
+ @Test
+ public void shouldFilterSuppressedOPByIssuerAlias() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "EXAMPLE-OP")
+ .withAgent(aLookupAgent().thatReturnsSuccess(principals()
+ .withUsername("paul"))));
+
+ whenPluginMapWith(principals()
+ .withOidc("paul", "EXAMPLE-OP")
+ .withOauth2Provider("EXAMPLE-OP", URI.create("https://issuer.example.org/"))
+ .withOidc("paul", "SOME-OTHER-OP")
+ .withOauth2Provider("SOME-OTHER-OP", URI.create("https://some-other-issuer.example.com/")));
+
+ assertThat(lookupCalls, contains(new Identity(URI.create("https://issuer.example.org/"), "paul")));
+ assertThat(principals, hasItems(new UserNamePrincipal("paul")));
+ }
+
+ @Test
+ public void shouldFilterSuppressedOPByIssuerUri() throws Exception {
+ given(anAlisePlugin()
+ .withProprety("gplazma.alise.issuers", "https://issuer.example.org/")
+ .withAgent(aLookupAgent().thatReturnsSuccess(principals()
+ .withUsername("paul"))));
+
+ whenPluginMapWith(principals()
+ .withOidc("paul", "EXAMPLE-OP")
+ .withOauth2Provider("EXAMPLE-OP", URI.create("https://issuer.example.org/"))
+ .withOidc("paul", "SOME-OTHER-OP")
+ .withOauth2Provider("SOME-OTHER-OP", URI.create("https://some-other-issuer.example.com/")));
+
+ assertThat(lookupCalls, contains(new Identity(URI.create("https://issuer.example.org/"), "paul")));
+ assertThat(principals, hasItems(new UserNamePrincipal("paul")));
+ }
+
+ private AlisePluginBuilder anAlisePlugin() {
+ return new AlisePluginBuilder();
+ }
+
+ private LookupAgentBuilder aLookupAgent() {
+ return new LookupAgentBuilder();
+ }
+
+ private PrincipalSetMaker principals() {
+ return new PrincipalSetMaker();
+ }
+
+ private void given(AlisePluginBuilder builder) {
+ plugin = builder.build();
+ }
+
+ private void whenPluginMapWith(PrincipalSetMaker maker) throws AuthenticationException {
+ // PrincipalSetMaker creates an unmodifiable Set; but, for gPlazma, we need a Set that can be modified.
+ principals = new HashSet<>(maker.build());
+
+ plugin.map(principals);
+
+ ArgumentCaptor identityCaptor = ArgumentCaptor.forClass(Identity.class);
+ verify(agent).lookup(identityCaptor.capture());
+ lookupCalls = identityCaptor.getAllValues();
+ }
+
+ private class AlisePluginBuilder {
+ private final Properties config = new Properties();
+
+ private AlisePluginBuilder withProprety(String key, String value) {
+ config.setProperty(key, value);
+ return this;
+ }
+
+ private AlisePluginBuilder withAgent(LookupAgentBuilder builder) {
+ agent = builder.build();
+ return this;
+ }
+
+ private AlisePlugin build() {
+ checkState(agent != null);
+ return new AlisePlugin(config, agent);
+ }
+ }
+
+ private static class LookupAgentBuilder {
+ private final LookupAgent agent = mock(LookupAgent.class);
+ private Result,String> result;
+ private boolean failTest;
+
+ private LookupAgentBuilder thatReturnsFailure(String failure) {
+ checkState(result == null);
+ checkState(!failTest);
+ result = Result.failure(requireNonNull(failure));
+ return this;
+ }
+
+ private LookupAgentBuilder thatReturnsSuccess(PrincipalSetMaker maker) {
+ checkState(result == null);
+ checkState(!failTest);
+ result = Result.success(maker.build());
+ return this;
+ }
+
+ private LookupAgentBuilder thatFailsTestIfCalled() {
+ checkState(result == null);
+ failTest = true;
+ return this;
+ }
+
+ private LookupAgent build() {
+ checkState(result != null || failTest);
+ if (failTest) {
+ when(agent.lookup(any())).thenThrow(new AssertionError("Unexpected call to LookupAgent#lookup"));
+ } else {
+ when(agent.lookup(any())).thenReturn(result);
+ }
+ return agent;
+ }
+ }
+}
diff --git a/packages/pom.xml b/packages/pom.xml
index a8fff140fe0..7caee8b0ee1 100644
--- a/packages/pom.xml
+++ b/packages/pom.xml
@@ -105,6 +105,11 @@
missingfiles-semsg
${project.version}
+
+ org.dcache
+ gplazma2-alise
+ ${project.version}
+
org.dcache
gplazma2-argus
diff --git a/pom.xml b/pom.xml
index 5d871f617e3..49ee761b13d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1467,6 +1467,7 @@
modules/common-security
modules/cells
modules/gplazma2
+ modules/gplazma2-alise
modules/gplazma2-argus
modules/gplazma2-fermi
modules/gplazma2-grid
diff --git a/skel/share/defaults/gplazma.properties b/skel/share/defaults/gplazma.properties
index c6835c5ca64..af1ca675e4c 100644
--- a/skel/share/defaults/gplazma.properties
+++ b/skel/share/defaults/gplazma.properties
@@ -523,11 +523,11 @@ gplazma.oidc.audience-targets =
#
gplazma.oidc-te.url = ${gplazma.oidc.provider}
-gplazma.oidc-te.client-id = token-exchange
+gplazma.oidc-te.client-id = token-exchange
gplazma.oidc-te.client-secret = xxx
gplazma.oidc-te.grant-type = urn:ietf:params:oauth:grant-type:token-exchange
gplazma.oidc-te.subject-issuer = oidc
-gplazma.oidc-te.subject-token-type = urn:ietf:params:oauth:token-type:access_token
+gplazma.oidc-te.subject-token-type = urn:ietf:params:oauth:token-type:access_token
gplazma.oidc-te.audience = token-exchange
@@ -656,6 +656,38 @@ gplazma.roles.observer-gid =
#
gplazma.roles.qos-gid=
+
+# ---- ALISE plugin
+#
+# The ALISE plugin provides support in dCache to query an ALISE service
+# when converting a user's federated identity (as asserted by some OIDC
+# access token) to the site-local identity (given by a username).
+#
+# The endpoint of the ALISE service; e.g., https://alise.example.org/. This
+# configuration property is required.
+gplazma.alise.endpoint =
+
+# The API KEY used to authorise dCache's access to ALISE. This configuration
+# property is required.
+gplazma.alise.apikey =
+
+# The ALISE-internal name for the set of identities to use; required. This
+# configuration property is required.
+gplazma.alise.target =
+
+# The timeout when making a request, using ISO-8601 notation for a duration.
+# This configuration property is required, but may be left with the default
+# value.
+gplazma.alise.timeout = PT10S
+
+# A space-separated list of issuers. If this list is non-empty then only
+# identities asserted by an OP on this list are looked up within ALISE,
+# identities asserted by other OPs are not sent. The list entries may be
+# either the issuer's alises (dCache-internal names for OPs) or the issuer's
+# URI. If the list is empty then all OIDC identities are sent to ALISE.
+gplazma.alise.issuers =
+
+
#
#
# -----------------------------------------------------------------------