From 108d04c8487a0b632811e6d9a4235bf7803cfdec Mon Sep 17 00:00:00 2001 From: Paul Millar Date: Fri, 12 Apr 2024 09:16:27 +0200 Subject: [PATCH] gplazma alise initial version of plugin Motivation: A new service has been developed within the interTwin project: ALISE. An ALISE service supports account linking; i.e., it allows a facility's users to register their federated identity against their facility-local identity. This service is intended for situation where the facility's IAM solution cannot easily be updated to support such federated account linking. ALISE works by the OIDC service (dCache) querying ALISE's REST API, providing the user's identity ('sub' claim and the issuer URI). The response is the user's facility username and possibly a display name. Modification: A new gPlazma module is added that targets the ALISE service. New gplazma configuraiton is added to support this plugin. Result: Without configuration changes, there is no user- or admin observable changes. This patch adds the integration possibility where dCache login queries an ALISE service to learn a user's username. Target: master Requires-notes: yes Requires-book: yes Request: 10.1 Request: 10.0 Request: 9.2 --- .../src/main/markdown/config-gplazma.md | 143 ++++++- .../org/dcache/util/PrincipalSetMaker.java | 27 ++ modules/gplazma2-alise/pom.xml | 64 +++ .../gplazma/alise/AliseLookupAgent.java | 244 ++++++++++++ .../org/dcache/gplazma/alise/AlisePlugin.java | 192 +++++++++ .../gplazma/alise/CachingLookupAgent.java | 95 +++++ .../org/dcache/gplazma/alise/Identity.java | 68 ++++ .../org/dcache/gplazma/alise/LookupAgent.java | 47 +++ .../resources/META-INF/gplazma-plugins.xml | 6 + .../gplazma/alise/AliseLookupAgentTest.java | 363 ++++++++++++++++++ .../dcache/gplazma/alise/AlisePluginTest.java | 262 +++++++++++++ packages/pom.xml | 5 + pom.xml | 1 + skel/share/defaults/gplazma.properties | 36 +- 14 files changed, 1535 insertions(+), 18 deletions(-) create mode 100644 modules/gplazma2-alise/pom.xml create mode 100644 modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AliseLookupAgent.java create mode 100644 modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/AlisePlugin.java create mode 100644 modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/CachingLookupAgent.java create mode 100644 modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/Identity.java create mode 100644 modules/gplazma2-alise/src/main/java/org/dcache/gplazma/alise/LookupAgent.java create mode 100644 modules/gplazma2-alise/src/main/resources/META-INF/gplazma-plugins.xml create mode 100644 modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AliseLookupAgentTest.java create mode 100644 modules/gplazma2-alise/src/test/java/org/dcache/gplazma/alise/AlisePluginTest.java 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..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..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 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 = + + # # # -----------------------------------------------------------------------