diff --git a/docs/TheBook/src/main/markdown/config-gplazma.md b/docs/TheBook/src/main/markdown/config-gplazma.md index 4a4810edf36..cfad93560b9 100644 --- a/docs/TheBook/src/main/markdown/config-gplazma.md +++ b/docs/TheBook/src/main/markdown/config-gplazma.md @@ -559,6 +559,117 @@ In the OP definition: #### map Plug-ins +##### alise + +[ALISE](https://github.com/m-team-kit/alise/) is a service developed through +the interTwin project. When deployed and configured, it allows a site's users +to register their federated identities (e.g. EGI Check-In, Helmholtz ID, some +community-managed INDICO-IAM service) against their site-local identity. This +registration process requires no admin intervention and a user typically does +this once. Once this link (between a user's federated and site-local +identities) is registered, ALISE allows a service (such as dCache) to discover +the local identity (a username) when that service presents that user's +federated identity (an OIDC `sub` claim). + +ALISE is intended for sites that have identity management (IAM) solutions that +do not support federated identities. Other solutions may be preferable for +sites that have IAM solutions that support account linking; e.g., sites running +Keycloak may be able to provide the same functionality without running an +additional service. + +The `alise` plugin processes a login request by taking the `sub` claim (e.g., +as provided by the `oidc` plugin) and sending a request to the ALISE service. +If that request is successful then the plugin will learn the user's username +and (optionally) that user's display name. The `alise` plugin will cache +result of the ALISE query for a short period. This is to improve latency (of +subsequent queries) and to avoid placing too much load on the ALISE server. + +When processing a login request, the `alise` plugin succeeds if it queries the +ALISE service with a `sub` claim and receives a corresponding local identity: a +username. The plugin fails if the ALISE service responds that no mapping is +known for this federated identity, if there is a problem making the request, or +if the login attempt does not contain a `sub` claim (either no access token was +provided or the `sub` claim was not extracted from the access token by the +`oidc` plugin). + +**Configuration properties** + +`gplazma.alise.endpoint` + +This is a URL that forms the base for all HTTP queries to the ALISE service. +The default value is not valid; you must supply this configuration. + +A typical value would look like `https://alise.example.org/`. + +For comparison, a typical HTTP request to ALISE would look like: + + https://alise.example.org/api/v1/target/vega-kc/mapping/issuer/95d[...] + +The `gplazma.alise.endpoint` value is this URL update (but not including) the +`/api/v1` part. + +`gplazma.alise.target` + +A specific ALISE endpoint may serve multiple families of related services: +the targets. Targets are independent of each other: the account mapping +information of a target is independent of the account information any other +target. + +The primary use-case for targets is to allow a single ALISE service to support +multiple sites; however, the concept could also be useful if a ALISE service +supports only a single site. + +The default value is not valid; you must supply this configuration. A typical +value would be a simple string; e.g., `vega-kc`. + +`gplazma.alise.apikey` + +The API key is the authorisation that allows dCache to query ALISE for account +information. The [ALISE documentation](https://github.com/m-team-kit/alise/) +provides information on how to obtain the API key. + +The following provides a quick summary of the process, using oidc-agent and +other common command-line tools. The + + SOME_OP=EGI-CHECKIN # or whichever oidc-agent account is appropriate + TOKEN=$(oidc-token $SOME_OP) + TARGET=vega-kc # see gplazma.alise.target + APIKEY_ENDPOINT=https://alise.example.org/api/v1/target/$TARGET/get_apikey + APIKEY=$(curl -sH "Authorization: Bearer $TOKEN" $APIKEY_ENDPOINT \ + | jq -r .apikey) + +`gplazma.alise.timeout` + +The time dCache will wait for the ALISE service to respond when requesting a +user's site-local identity. If there is no response within that time then the +alise plugin will fail the request. This, in turn, will (depending on gPlazma +configuration) likely result in gPlazma failing that login attempt. + +The value is expressed as an ISO 8601 duration; for example, `PT5M` is five +minutes and `PT10S` is ten seconds. + +`gplazma.alise.issuers` + +The alise plugin can limit the federated identities that it will send to the +ALISE service, based on the issuer of the access token. The +`gplazma.alise.issuers` configuration property contains a space-separated list +of issuers, where each issuer is specified as either the dCache alias (see +`oidc` plugin) or the issuer's URI. As a special case, if this list is empty +then all tokens are sent to the ALISE service for mapping. + +**Using with other plugins** + +When successful, the `alise` plugin provides information about the user; in +particular, the user's username and (optionally) a display name. By itself, +this is insufficient for a successful gPlazma map phase, as the user's uid and +gid must also be obtained. + +Out of the box, dCache supports multiple ways of obtaining the uid and gid from +a username. This could be done by querying an LDAP service (see `ldap` plugin), +an NIS service (see `nis` plugin) or the dCache server's local user account +lookup service (see `nsswitch` plugin). It is also possible to use explicit +configuration files (see `multimap` plugin). + ##### kpwd As a `map` plug-in it maps usernames to UID and GID. And as a `session` plug-in it adds root and home path information to the session based on the user’s username. @@ -1478,7 +1589,7 @@ voms-proxy-info ## Using OpenID Connect -dCache also supports the use of OpenID Connect bearer tokens as a means of authentication. +dCache also supports the use of OpenID Connect bearer tokens as a means of authentication. OpenID Connect is a federated identity system. The dCache users see federated identity as a way to use their existing username & password safely. From a dCache admin's point-of-view, this involves "outsourcing" responsibility for checking users identities to some external service: you must trust that the service is doing a good job. @@ -1488,17 +1599,17 @@ OpenID Connect is a federated identity system. The dCache users see federated i Common examples of Authorisation servers are Google, Indigo-IAM, Cern-Single-Signon etc. -As of version 2.16, dCache is able to perform authentication based on [OpendID Connect](http://openid.net/specs/openid-connect-core-1_0.html) credentials on its HTTP end-points. In this document, we outline the configurations necessary to enable this support for OpenID Connect. +As of version 2.16, dCache is able to perform authentication based on [OpendID Connect](http://openid.net/specs/openid-connect-core-1_0.html) credentials on its HTTP end-points. In this document, we outline the configurations necessary to enable this support for OpenID Connect. -OpenID Connect credentials are sent to dCache with Authorisation HTTP Header as follows +OpenID Connect credentials are sent to dCache with Authorisation HTTP Header as follows `Authorization: Bearer `. This bearer token is extracted, validated and verified against a **Trusted Authorisation Server** (Issue of the bearer token) and is used later to fetch additional user identity information from the corresponding Authorisation Server. -### Steps for configuration +### Steps for configuration -In order to configure the OpenID Connect support, we need to +In order to configure the OpenID Connect support, we need to 1. configure the gplazma plugins providing the support for authentication using OpenID credentials and mapping a verified OpenID credential to dCache specific `username`, `uid` and `gid`. -2. enabling the plugins in gplazma +2. enabling the plugins in gplazma ### Gplazma Plugins for OpenId Connect @@ -1507,7 +1618,7 @@ The support for OpenID Connect in Cache is achieved with the help of two gplazma #### OpenID Authenticate Plugin (oidc) It takes the extracted OpenID connect credentials (Bearer Token) from the HTTP requests and validates it against a OpenID Provider end-point. The admins need to obtain this information from their trusted OpenID Provider such as Google. -In case of Google, the provider end-point can be obtained from the url of its [Discovery Document](http://openid.net/specs/openid-connect-core-1_0.html#OpenID.Discovery), e.g. https://accounts.google.com/.well-known/openid-configuration. Hence, the provider end-point in this case would be **accounts.google.com**. +In case of Google, the provider end-point can be obtained from the url of its [Discovery Document](http://openid.net/specs/openid-connect-core-1_0.html#OpenID.Discovery), e.g. https://accounts.google.com/.well-known/openid-configuration. Hence, the provider end-point in this case would be **accounts.google.com**. This end-point has to be appended to the gplazma property **gplazma.oidc.hostnames**, which should be added to the layouts file. Multiple trusted OpenID providers can be added with space separated list as below. @@ -1538,7 +1649,7 @@ The two plugins above must be enabled in the gplazma.conf. > auth optional oidc -> map optional multimap +> map optional multimap Restart dCache and check that there are no errors in loading these gplazma plugins. @@ -1828,26 +1939,26 @@ Some file access examples: Roles are a way of describing what capabilities a given user has. They constitute a set of operations defined either explicitly or implicitly which the user who -is assigned that role is permitted to exercise. +is assigned that role is permitted to exercise. -Roles further allow users to act in more than one capacity without having to -change their basic identity. For instance, a "superuser" may wish to act as _janedoe_ +Roles further allow users to act in more than one capacity without having to +change their basic identity. For instance, a "superuser" may wish to act as _janedoe_ for some things, but as an administrator for others, without having to reauthenticate. -While the role framework in dCache is designed to be extensible, there -currently exists only one recognized role, that of _admin_. +While the role framework in dCache is designed to be extensible, there +currently exists only one recognized role, that of _admin_. To activate the use of the _admin_ role, the following steps are necessary. -1) Define the admin role using the property: +1) Define the admin role using the property: ```ini gplazma.roles.admin-gid= ``` - + 2) Add the _admin_ gid to the set of gids for any user who should have this capability. -3) Add the roles plugin to your gPlazma configuration (usually 'requisite' is sufficient): +3) Add the roles plugin to your gPlazma configuration (usually 'requisite' is sufficient): session requisite roles 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..6b61a237292 --- /dev/null +++ b/modules/gplazma2-alise/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.dcache + dcache-parent + 9.2.24-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..57de5f99f80 --- /dev/null +++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AliseLookupAgent.java @@ -0,0 +1,244 @@ +/* + * 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.base.Stopwatch; +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 java.util.Optional; +import org.dcache.auth.FullNamePrincipal; +import org.dcache.auth.UserNamePrincipal; +import org.dcache.util.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.util.Objects.requireNonNull; +import static org.dcache.util.TimeUtils.TimeUnitFormat.SHORT; +import static org.dcache.util.TimeUtils.appendDuration; + +/** + * Make an HTTP request to ALISE to discover the local identity of a user. + */ +public class AliseLookupAgent implements LookupAgent { + + private static final Logger LOGGER = LoggerFactory.getLogger(AliseLookupAgent.class); + + 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) { + LOGGER.debug("Querying for identity {}", identity); + URI queryUrl = buildQueryUrl(identity); + var request = HttpRequest.newBuilder(queryUrl).timeout(timeout).build(); + + try { + LOGGER.debug("Making ALISE request {}", queryUrl); + Stopwatch waitingForResponse = Stopwatch.createStarted(); + var response = client.send(request, BodyHandlers.ofString()); + + if (LOGGER.isDebugEnabled()) { + Duration delay = waitingForResponse.elapsed(); + var sb = new StringBuilder("ALISE response took "); + appendDuration(sb, delay, SHORT) + .append(": ") + .append(response.statusCode()) + .append(' ') + .append(response.body()); + LOGGER.debug(sb.toString()); + } + + return resultFromResponse(response); + } catch (InterruptedException | IOException e) { + LOGGER.debug("Problem contacting ALISE server: {}", e.toString()); + 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..8e59258723a --- /dev/null +++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AlisePlugin.java @@ -0,0 +1,192 @@ +/* + * 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("{} has no corresponding OAuthProviderPrincipal", + principal); + return Optional.empty(); + } + + if (!mappableIssuers.isEmpty() + && !mappableIssuers.contains(issuerAlias) + && !mappableIssuers.contains(issuer.toASCIIString())) { + LOGGER.debug("{} rejected because not issued by allowed OP", + principal); + 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.isInfoEnabled()) { + LOGGER.info("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..305bb72a9e1 --- /dev/null +++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/CachingLookupAgent.java @@ -0,0 +1,95 @@ +/* + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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 static final Logger LOGGER = LoggerFactory.getLogger(CachingLookupAgent.class); + + private final LookupAgent inner; + private final ExecutorService executor = new BoundedCachedExecutor(5); + + private final LoadingCache, String>> lookupResults = CacheBuilder.newBuilder() + .maximumSize(1_000) + .refreshAfterWrite(10, TimeUnit.SECONDS) + .expireAfterWrite(5, TimeUnit.MINUTES) + .build(new CacheLoader, String>>() { + @Override + public Result, String> load(Identity identity) { + LOGGER.debug("Populating cache with identity {}", identity); + return inner.lookup(identity); + } + + @Override + public ListenableFuture, String>> reload(Identity identity, Result,String> prevResult) { + LOGGER.debug("Scheduling refresh of identity {}", identity); + 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() { + LOGGER.debug("Shutting down executor service."); + 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..0f844cd7805 --- /dev/null +++ b/modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/Identity.java @@ -0,0 +1,68 @@ +/* + * 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); + } + + @Override + public String toString() { + return "{"+issuer.toASCIIString()+"}"+sub; + } +} 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/main/resources/META-INF/gplazma-plugins.xml b/modules/gplazma2-alise/src/main/resources/META-INF/gplazma-plugins.xml new file mode 100644 index 00000000000..f81338ba84a --- /dev/null +++ b/modules/gplazma2-alise/src/main/resources/META-INF/gplazma-plugins.xml @@ -0,0 +1,6 @@ + + + alise + org.dcache.gplazma.alise.AlisePlugin + + 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 11b867b29e5..a06a04a4c47 100644 --- a/packages/pom.xml +++ b/packages/pom.xml @@ -110,6 +110,11 @@ gplazma2-argus ${project.version} + + org.dcache + gplazma2-alise + ${project.version} + org.dcache gplazma2-fermi diff --git a/pom.xml b/pom.xml index 293276cca89..1fc2ba9421e 100644 --- a/pom.xml +++ b/pom.xml @@ -1428,6 +1428,7 @@ modules/cells modules/gplazma2 modules/gplazma2-argus + modules/gplazma2-alise modules/gplazma2-fermi modules/gplazma2-grid modules/gplazma2-krb5 diff --git a/skel/share/defaults/gplazma.properties b/skel/share/defaults/gplazma.properties index beccc914f6e..9ea0684cd4e 100644 --- a/skel/share/defaults/gplazma.properties +++ b/skel/share/defaults/gplazma.properties @@ -644,6 +644,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 = + + # # # -----------------------------------------------------------------------