Skip to content

Commit

Permalink
gplazma alise add new plugin
Browse files Browse the repository at this point in the history
Motivation:

A new service has been developed withIn the interTwin project: ALISE.
This 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 account linking.

The service works by the service (dCache) querying a REST API, providing
the user's identity ('sub' claim) and the issuer (a hash of 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: 9.2
  • Loading branch information
paulmillar committed Aug 1, 2024
1 parent 110017e commit 3912b57
Show file tree
Hide file tree
Showing 12 changed files with 1,364 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
*
Expand Down
64 changes: 64 additions & 0 deletions modules/gplazma2-alise/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.dcache</groupId>
<artifactId>dcache-parent</artifactId>
<version>10.2.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>

<artifactId>gplazma2-alise</artifactId>
<packaging>jar</packaging>

<name>gPlazma 2 ALISE plugin</name>

<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
<dependency>
<groupId>org.dcache</groupId>
<artifactId>dcache-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.dcache</groupId>
<artifactId>gplazma2</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.npathai</groupId>
<artifactId>hamcrest-optional</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/
package org.dcache.gplazma.alise;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.hash.Hashing;
import com.google.common.net.PercentEscaper;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.security.Principal;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import static java.util.Objects.requireNonNull;
import java.util.Optional;
import org.dcache.auth.FullNamePrincipal;
import org.dcache.auth.UserNamePrincipal;
import org.dcache.util.Result;

/**
* Make an HTTP request to ALISE to discover the local identity of a user.
*/
public class AliseLookupAgent implements LookupAgent {

private final String apikey;
private final HttpClient client;
private final URI endpoint;
private final String target;
private final Duration timeout;

public AliseLookupAgent(URI endpoint, String target, String apikey,
String timeout) {
this(HttpClient.newHttpClient(), endpoint, target, apikey, timeout);
}

@VisibleForTesting
AliseLookupAgent(HttpClient client, URI endpoint, String target,
String apikey, String timeout) {
this.client = requireNonNull(client);
this.apikey = requireNonNull(apikey);
this.endpoint = requireNonNull(endpoint);
this.target = requireNonNull(target);
this.timeout = Duration.parse(timeout);
}

private URI buildQueryUrl(Identity identity) {
URI issuer = identity.issuer();
var issuerHash = Hashing.sha1().hashString(issuer.toASCIIString(), StandardCharsets.UTF_8).toString();

String subject = identity.sub();
var encodedSub = new PercentEscaper(".", false).escape(subject);

String relPath = "api/v1/target/" + target + "/mapping/issuer/" + issuerHash + "/user/" + encodedSub + "?apikey=" + apikey;
return endpoint.resolve(relPath);
}

// TODO: maintain a cached list of supported endpoints via:
// ${ALISE}/alise/supported_issuers
// Use this to avoid sending tokens that from issuers that ALISE does not
// support

@Override
public Result<Collection<Principal>,String> lookup(Identity identity) {
URI queryUrl = buildQueryUrl(identity);
var request = HttpRequest.newBuilder(queryUrl).timeout(timeout).build();

try {
var response = client.send(request, BodyHandlers.ofString());
return resultFromResponse(response);
} catch (InterruptedException | IOException e) {
return Result.failure("problem communicating with ALISE server: "
+ e.toString());
}
}

private Result<Collection<Principal>, String> resultFromResponse(HttpResponse<String> response) {
Optional<String> 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<Collection<Principal>, 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<Principal> 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);
}
}
Loading

0 comments on commit 3912b57

Please sign in to comment.