Skip to content

Commit

Permalink
Bug 66665: Provide option to supply role mapping from a properties file
Browse files Browse the repository at this point in the history
  • Loading branch information
michael-o committed Jun 29, 2023
1 parent ecca27f commit c8adc4c
Show file tree
Hide file tree
Showing 12 changed files with 391 additions and 0 deletions.
7 changes: 7 additions & 0 deletions java/org/apache/catalina/core/LocalStrings.properties
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,13 @@ noPluggabilityServletContext.notAllowed=Section 4.4 of the Servlet 3.0 specifica

pushBuilder.noPath=It is illegal to call push() before setting a path

propertiesRoleMappingListener.roleMappingFileNull=Role mapping file cannot be null
propertiesRoleMappingListener.roleMappingFileEmpty=Role mapping file cannot be empty
propertiesRoleMappingListener.roleMappingFileNotFound=Role mapping file [{0}] not found
propertiesRoleMappingListener.roleMappingFileFail=Failed to load role mapping file [{0}]
propertiesRoleMappingListener.linkedRole=Successfully linked application role [{0}] to technical role [{1}]
propertiesRoleMappingListener.linkedRoleCount=Linked [{0}] application roles to technical roles

standardContext.applicationListener=Error configuring application listener of class [{0}]
standardContext.applicationSkipped=Skipped installing application listeners due to previous error(s)
standardContext.backgroundProcess.instanceManager=Exception processing instance manager [{0}] background process
Expand Down
168 changes: 168 additions & 0 deletions java/org/apache/catalina/core/PropertiesRoleMappingListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.catalina.core;

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Properties;

import org.apache.catalina.Context;
import org.apache.catalina.Lifecycle;
import org.apache.catalina.LifecycleEvent;
import org.apache.catalina.LifecycleListener;
import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.file.ConfigFileLoader;
import org.apache.tomcat.util.res.StringManager;

/**
* Implementation of {@code LifecycleListener} that will populate the context's role mapping from a properties file.
* <p>
* This listener must only be nested within {@link Context} elements.
* <p>
* The keys represent application roles (e.g., admin, user, uservisor, etc.) while the values represent technical roles
* (e.g., DNs, SIDs, UUIDs, etc.). A key can also be prefixed if, e.g., the properties file contains generic
* application configuration as well: {@code app-roles.}.
* <p>
* Note: The default value for the {@code roleMappingFile} is {@code webapp:/WEB-INF/role-mapping.properties}.
*/
public class PropertiesRoleMappingListener implements LifecycleListener {

private static final String WEBAPP_PROTOCOL = "webapp:";

private static final Log log = LogFactory.getLog(PropertiesRoleMappingListener.class);
/**
* The string manager for this package.
*/
private static final StringManager sm = StringManager.getManager(ContextNamingInfoListener.class);

private String roleMappingFile = WEBAPP_PROTOCOL + "/WEB-INF/role-mapping.properties";
private String keyPrefix;

/**
* Sets the path to the role mapping properties file. You can use protocol {@code webapp:} and whatever
* {@link ConfigFileLoader} supports.
*
* @param roleMappingFile the role mapping properties file to load from
* @throws NullPointerException if roleMappingFile is null
* @throws IllegalArgumentException if roleMappingFile is empty
*/
public void setRoleMappingFile(String roleMappingFile) {
Objects.requireNonNull(roleMappingFile, sm.getString("propertiesRoleMappingListener.roleMappingFileNull"));
if (roleMappingFile.isEmpty()) {
throw new IllegalArgumentException(sm.getString("propertiesRoleMappingListener.roleMappingFileEmpty"));
}

this.roleMappingFile = roleMappingFile;
}

/**
* Gets the path to the role mapping properties file.
*
* @return the path to the role mapping properties file
*/
public String getRoleMappingFile() {
return roleMappingFile;
}

/**
* Sets the prefix to filter from property keys. All other keys will be ignored which do not have the prefix.
*
* @param keyPrefix the properties key prefix
*/
public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}

/**
* Gets the prefix to filter from property keys.
*
* @return the properties key prefix
*/
public String getKeyPrefix() {
return keyPrefix;
}

@Override
public void lifecycleEvent(LifecycleEvent event) {
if (event.getType().equals(Lifecycle.CONFIGURE_START_EVENT)) {
if (!(event.getLifecycle() instanceof Context)) {
log.warn(sm.getString("listener.notContext", event.getLifecycle().getClass().getSimpleName()));
return;
}
Context context = (Context) event.getLifecycle();

InputStream is;
if (roleMappingFile.startsWith(WEBAPP_PROTOCOL)) {
String path = roleMappingFile.substring(WEBAPP_PROTOCOL.length());
is = context.getServletContext().getResourceAsStream(path);
} else {
try {
is = ConfigFileLoader.getSource().getResource(roleMappingFile).getInputStream();
} catch (FileNotFoundException e1) {
is = null;
} catch (IOException e2) {
throw new IllegalStateException(
sm.getString("propertiesRoleMappingListener.roleMappingFileFail", roleMappingFile), e2);
}
}

if (is == null) {
throw new IllegalStateException(
sm.getString("propertiesRoleMappingListener.roleMappingFileNotFound", roleMappingFile));
}

Properties props = new Properties();

try (InputStream _is = is) {
props.load(_is);
} catch (IOException e) {
throw new IllegalStateException(
sm.getString("propertiesRoleMappingListener.roleMappingFileFail", roleMappingFile), e);
}

int linkCount = 0;
for (Entry<Object, Object> prop : props.entrySet()) {
String role = (String) prop.getKey();

if (keyPrefix != null) {
if (role.startsWith(keyPrefix)) {
role = role.substring(keyPrefix.length());
} else {
continue;
}
}

String link = (String) prop.getValue();

if (log.isTraceEnabled()) {
log.trace(sm.getString("propertiesRoleMappingListener.linkedRole", role, link));
}
context.addRoleMapping(role, link);
linkCount++;
}

if (log.isDebugEnabled()) {
log.debug(sm.getString("propertiesRoleMappingListener.linkedRoleCount", linkCount));
}
}
}

}
168 changes: 168 additions & 0 deletions test/org/apache/catalina/core/TestPropertiesRoleMappingListener.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.catalina.core;

import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.apache.catalina.Context;
import org.apache.catalina.LifecycleException;
import org.apache.catalina.authenticator.BasicAuthenticator;
import org.apache.catalina.servlets.DefaultServlet;
import org.apache.catalina.startup.TesterMapRealm;
import org.apache.catalina.startup.Tomcat;
import org.apache.catalina.startup.TomcatBaseTest;
import org.apache.tomcat.util.buf.ByteChunk;
import org.apache.tomcat.util.codec.binary.Base64;
import org.apache.tomcat.util.descriptor.web.LoginConfig;
import org.apache.tomcat.util.descriptor.web.SecurityCollection;
import org.apache.tomcat.util.descriptor.web.SecurityConstraint;
import org.junit.Assert;
import org.junit.Test;

import jakarta.servlet.http.HttpServletRequest;

public class TestPropertiesRoleMappingListener extends TomcatBaseTest {

@Test(expected = NullPointerException.class)
public void testNullRoleMappingFile() throws Exception {
PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener();
listener.setRoleMappingFile(null);
}

@Test(expected = IllegalArgumentException.class)
public void testEmptyRoleMappingFile() throws Exception {
PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener();
listener.setRoleMappingFile("");
}

@Test(expected = LifecycleException.class)
public void testNotFoundRoleMappingFile() throws Exception {
Tomcat tomcat = getTomcatInstance();

Context ctx = tomcat.addContext("", null);

PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener();
ctx.addLifecycleListener(listener);

try {
tomcat.start();
} finally {
tomcat.stop();
}
}

@Test
public void testFileFromServletContext() throws Exception {
doTest("webapp:/WEB-INF/role-mapping.properties", null);
}

@Test
public void testFileFromServletContextWithKeyPrefix() throws Exception {
doTest("webapp:/WEB-INF/prefixed-role-mapping.properties", "app-roles.");
}

@Test
public void testFileFromClasspath() throws Exception {
doTest("classpath:/com/example/role-mapping.properties", null);
}

@Test
public void testFileFromClasspathWithKeyPrefix() throws Exception {
doTest("classpath:/com/example/prefixed-role-mapping.properties", "app-roles.");
}

@Test
public void testFileFromFile() throws Exception {
File appDir = new File("test/webapp-role-mapping");
File file = new File(appDir, "WEB-INF/role-mapping.properties");
doTest(file.getAbsoluteFile().toURI().toASCIIString(), null);
}

@Test
public void testFileFromFileWithKeyPrefix() throws Exception {
File appDir = new File("test/webapp-role-mapping");
File file = new File(appDir, "WEB-INF/prefixed-role-mapping.properties");
doTest(file.getAbsoluteFile().toURI().toASCIIString(), "app-roles.");
}

private void doTest(String roleMappingFile, String keyPrefix) throws Exception {
Tomcat tomcat = getTomcatInstance();

File appDir = new File("test/webapp-role-mapping");
Context ctx = tomcat.addContext("", appDir.getAbsolutePath());

PropertiesRoleMappingListener listener = new PropertiesRoleMappingListener();
listener.setRoleMappingFile(roleMappingFile);
listener.setKeyPrefix(keyPrefix);
ctx.addLifecycleListener(listener);

Tomcat.addServlet(ctx, "default", new DefaultServlet());
ctx.addServletMappingDecoded("/", "default");

LoginConfig loginConfig = new LoginConfig();
loginConfig.setAuthMethod(HttpServletRequest.BASIC_AUTH);
ctx.setLoginConfig(loginConfig);
ctx.getPipeline().addValve(new BasicAuthenticator());

TesterMapRealm realm = new TesterMapRealm();
realm.addUser("foo", "bar");
// role 'admin'
realm.addUserRole("foo", "de25f8f5-e534-4980-9351-e316384b1127");
realm.addUser("waldo", "fred");
// role 'user'
realm.addUserRole("waldo", "13f6b886-cba8-4b5b-9a1b-06a6fe533356");
// role 'supervisor'
realm.addUserRole("waldo", "45071e9a-13ef-11ee-89dc-20677cd45840");
ctx.setRealm(realm);

for (String role : Arrays.asList("admin", "user", "unmapped")) {
SecurityCollection securityCollection = new SecurityCollection();
securityCollection.addPattern("/" + role);
SecurityConstraint constraint = new SecurityConstraint();
constraint.addAuthRole(role);
constraint.addCollection(securityCollection);
ctx.addConstraint(constraint);
ctx.addSecurityRole(role);
}

tomcat.start();

testRequest("foo:bar", "/admin", 200);
testRequest("waldo:fred", "/user", 200);
testRequest("waldo:fred", "/unmapped", 403);
testRequest("bar:baz", "/user", 401);
}

private void testRequest(String credentials, String path, int statusCode) throws IOException {
ByteChunk out = new ByteChunk();
Map<String, List<String>> reqHead = new HashMap<>();
List<String> head = new ArrayList<>();
head.add(HttpServletRequest.BASIC_AUTH + " " +
Base64.encodeBase64String(credentials.getBytes(StandardCharsets.ISO_8859_1)));
reqHead.put("Authorization", head);
int rc = getUrl("http://localhost:" + getPort() + path, out, reqHead, null);
Assert.assertEquals(statusCode, rc);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
app-roles.admin=de25f8f5-e534-4980-9351-e316384b1127
app-roles.user=13f6b886-cba8-4b5b-9a1b-06a6fe533356
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
admin=de25f8f5-e534-4980-9351-e316384b1127
user=13f6b886-cba8-4b5b-9a1b-06a6fe533356
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
app-roles.admin=de25f8f5-e534-4980-9351-e316384b1127
app-roles.user=13f6b886-cba8-4b5b-9a1b-06a6fe533356
2 changes: 2 additions & 0 deletions test/webapp-role-mapping/WEB-INF/role-mapping.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
admin=de25f8f5-e534-4980-9351-e316384b1127
user=13f6b886-cba8-4b5b-9a1b-06a6fe533356
1 change: 1 addition & 0 deletions test/webapp-role-mapping/admin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
admin
1 change: 1 addition & 0 deletions test/webapp-role-mapping/unmapped
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
unmapped
1 change: 1 addition & 0 deletions test/webapp-role-mapping/user
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
user
6 changes: 6 additions & 0 deletions webapps/docs/changelog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@
a listener which creates context naming information environment entries.
(michaelo)
</add>
<add>
<bug>66665</bug>: Add
<code>org.apache.catalina.core.PropertiesRoleMappingListener</code>,
a listener which populates the context's role mapping from a properties
file. (michaelo)
</add>
<fix>
Fix an edge case where intra-web application symlinks would be followed
if the web applications were deliberately crafted to allow it even when
Expand Down
Loading

0 comments on commit c8adc4c

Please sign in to comment.