diff --git a/modules/common/src/main/java/org/dcache/util/PrincipalSetMaker.java b/modules/common/src/main/java/org/dcache/util/PrincipalSetMaker.java index 80d4f7bb07e..0282911a65c 100644 --- a/modules/common/src/main/java/org/dcache/util/PrincipalSetMaker.java +++ b/modules/common/src/main/java/org/dcache/util/PrincipalSetMaker.java @@ -1,6 +1,7 @@ package org.dcache.util; import com.google.common.collect.Sets; +import java.net.URI; import java.security.Principal; import java.util.Collections; import java.util.Set; @@ -9,8 +10,10 @@ import org.dcache.auth.EmailAddressPrincipal; import org.dcache.auth.ExemptFromNamespaceChecks; import org.dcache.auth.FQANPrincipal; +import org.dcache.auth.FullNamePrincipal; import org.dcache.auth.GidPrincipal; import org.dcache.auth.GroupNamePrincipal; +import org.dcache.auth.OAuthProviderPrincipal; import org.dcache.auth.OidcSubjectPrincipal; import org.dcache.auth.UidPrincipal; import org.dcache.auth.UserNamePrincipal; @@ -52,12 +55,25 @@ public PrincipalSetMaker withUid(int uid) { * Add a username Principal to the set. * * @param name the username to add + * */ public PrincipalSetMaker withUsername(String username) { _principals.add(new UserNamePrincipal(username)); return this; } + /** + * Add a Full Name Principal to the set. + * + * @param name the full name of the user. + * + */ + public PrincipalSetMaker withFullname(String name) { + _principals.add(new FullNamePrincipal(name)); + return this; + } + + /** * Add a primary groupname Principal to the set. * @@ -152,6 +168,17 @@ public PrincipalSetMaker withOidc(String sub, String op) { return this; } + /** + * Add an OAuth2 Provider (OP) to the set. + * + * @param alias the name/alias of this OAuth2 Provider. + * @param uri the URI identity of the OAuth2 Provider. + */ + public PrincipalSetMaker withOauth2Provider(String alias, URI uri) { + _principals.add(new OAuthProviderPrincipal(alias, uri)); + return this; + } + /** * Add an Email principal to the set. * diff --git a/modules/gplazma2-alise/pom.xml b/modules/gplazma2-alise/pom.xml new file mode 100644 index 00000000000..1b7eb2a8f2d --- /dev/null +++ b/modules/gplazma2-alise/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.dcache + dcache-parent + 10.2.0-SNAPSHOT + ../../pom.xml + + + gplazma2-alise + jar + + gPlazma 2 ALISE plugin + + + + org.slf4j + slf4j-api + + + org.slf4j + jcl-over-slf4j + + + com.google.guava + guava + + + org.dcache + dcache-common + ${project.version} + + + org.dcache + gplazma2 + ${project.version} + + + org.apache.httpcomponents + httpclient + + + com.fasterxml.jackson.core + jackson-databind + + + junit + junit + test + + + org.hamcrest + hamcrest + test + + + com.github.npathai + hamcrest-optional + test + + + diff --git a/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AliseLookupAgent.java b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AliseLookupAgent.java new file mode 100644 index 00000000000..c1e170b97aa --- /dev/null +++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AliseLookupAgent.java @@ -0,0 +1,220 @@ +/* + * 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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.hash.Hashing; +import com.google.common.net.PercentEscaper; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.charset.StandardCharsets; +import java.security.Principal; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import static java.util.Objects.requireNonNull; +import java.util.Optional; +import org.dcache.auth.FullNamePrincipal; +import org.dcache.auth.UserNamePrincipal; +import org.dcache.util.Result; + +/** + * Make an HTTP request to ALISE to discover the local identity of a user. + */ +public class AliseLookupAgent implements LookupAgent { + + private final String apikey; + private final HttpClient client; + private final URI endpoint; + private final String target; + private final Duration timeout; + + public AliseLookupAgent(URI endpoint, String target, String apikey, + String timeout) { + this(HttpClient.newHttpClient(), endpoint, target, apikey, timeout); + } + + @VisibleForTesting + AliseLookupAgent(HttpClient client, URI endpoint, String target, + String apikey, String timeout) { + this.client = requireNonNull(client); + this.apikey = requireNonNull(apikey); + this.endpoint = requireNonNull(endpoint); + this.target = requireNonNull(target); + this.timeout = Duration.parse(timeout); + } + + private URI buildQueryUrl(Identity identity) { + URI issuer = identity.issuer(); + var issuerHash = Hashing.sha1().hashString(issuer.toASCIIString(), StandardCharsets.UTF_8).toString(); + + String subject = identity.sub(); + var encodedSub = new PercentEscaper(".", false).escape(subject); + + String relPath = "api/v1/target/" + target + "/mapping/issuer/" + issuerHash + "/user/" + encodedSub + "?apikey=" + apikey; + return endpoint.resolve(relPath); + } + + @Override + public Result,String> lookup(Identity identity) { + URI queryUrl = buildQueryUrl(identity); + var request = HttpRequest.newBuilder(queryUrl).timeout(timeout).build(); + + try { + var response = client.send(request, BodyHandlers.ofString()); + return resultFromResponse(response); + } catch (InterruptedException | IOException e) { + return Result.failure("problem communicating with ALISE server: " + + e.toString()); + } + } + + private Result, String> resultFromResponse(HttpResponse response) { + Optional contentType = response.headers().firstValue("Content-Type"); + if (contentType.isPresent()) { + String mediaType = contentType.get(); + if (!mediaType.equals("application/json")) { + return Result.failure("Response not JSON (" + mediaType + ")"); + } + } + + JsonNode json; + try { + ObjectMapper mapper = new ObjectMapper(); + json = mapper.readValue(response.body(), JsonNode.class); + } catch (JsonProcessingException e) { + return Result.failure("Bad JSON in response: " + e.getMessage()); + } + + if (response.statusCode() != 200) { + String message = buildErrorMessage(json); + return Result.failure("ALISE reported a problem: " + message); + } + + return resultFromSuccessfulHttpRequest(json); + } + + private String buildErrorMessage(JsonNode body) { + if (body.has("message")) { + JsonNode messageNode = body.get("message"); + if (messageNode.isTextual()) { + return messageNode.asText(); + } else { + return "unknown (\"message\" field is not textual)"; + } + } + + if (body.has("detail")) { + JsonNode detailArrayNode = body.get("detail"); + if (!detailArrayNode.isArray()) { + return "unknown (\"detail\" not array)"; + } + + StringBuilder sb = new StringBuilder(); + for (JsonNode detail : detailArrayNode) { + if (sb.length() != 0) { + sb.append(", "); + } + + if (!detail.isObject()) { + sb.append("unknown (\"detail\" item not object)"); + continue; + } + + if (detail.has("msg")) { + JsonNode msgNode = detail.get("msg"); + if (msgNode.isTextual()) { + sb.append(msgNode.asText()); + } else { + sb.append("unknown (").append(msgNode).append(')'); + } + } else { + sb.append("unknown (no \"msg\" field)"); + } + + if (detail.has("loc")) { + JsonNode locArrayNode = detail.get("loc"); + if (locArrayNode.isArray()) { + sb.append('['); + boolean haveFirst = false; + for (JsonNode locNode : locArrayNode) { + if (haveFirst) { + sb.append(", "); + } + if (locNode.isTextual()) { + sb.append(locNode.asText()); + } else { + sb.append("unknown (").append(locNode).append(')'); + } + haveFirst = true; + } + sb.append(']'); + } + } + } + return sb.toString(); + } else { + return "Unknown problem"; + } + } + + private Result, String> resultFromSuccessfulHttpRequest(JsonNode json) { + if (!json.isObject()) { + return Result.failure("lookup not JSON object"); + } + + if (!json.has("internal")) { + return Result.failure("lookup missing \"internal\" field"); + } + + JsonNode internalNode = json.get("internal"); + if (!internalNode.isObject()) { + return Result.failure("\"internal\" field is not object"); + } + + if (!internalNode.has("username")) { + return Result.failure("\"internal\" field missing \"username\" field"); + } + + JsonNode usernameNode = internalNode.get("username"); + if (!usernameNode.isTextual()) { + return Result.failure("Non-textual \"username\" field"); + } + + List 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..fe333876262 --- /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.example.org/") + .withTarget("vega-kc")); + given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/")); + + whenAgentCalledWithIdentity(); + + var expected = URI.create("https://alise.example.org/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.example.org/") + .withTarget("vega-kc")); + given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/")); + + whenAgentCalledWithIdentity(); + + var expected = URI.create("https://alise.example.org/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.example.org/") + .withTarget("vega-kc")); + given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/")); + + whenAgentCalledWithIdentity(); + + var expected = URI.create("https://alise.example.org/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.example.org/") + .withTarget("vega-kc")); + given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/")); + + whenAgentCalledWithIdentity(); + + var expected = URI.create("https://alise.example.org/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.example.org/") + .withTarget("vega-kc")); + given(anIdentity().withSub("paul").withIssuer("https://issuer.example.org/")); + + whenAgentCalledWithIdentity(); + + var expected = URI.create("https://alise.example.org/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 = + + # # # -----------------------------------------------------------------------