diff --git a/README.md b/README.md index 11a7dc1b9..1135cd752 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,40 @@ The current status of the Atom Hopper Data Adapters is as follows: To find out how to install and run Atom Hopper please see the [Atom Hopper Wiki](https://github.com/rackerlabs/atom-hopper/wiki) +###Health Check and Version Endpoints### + +Atom Hopper provides multiple endpoints for monitoring and version information: + +#### Health Check Endpoint (Recommended for ECS Load Balancers) +* **Endpoint**: `GET /health` +* **Purpose**: Lightweight health check for load balancers and monitoring systems +* **Response**: JSON format with service status +* **Status Code**: 200 OK +* **Example Response**: +```json +{ + "service": "atomhopper", + "status": "ok", + "version": "1.14.1-SNAPSHOT" +} +``` + +#### Build Information Endpoint (Legacy) +* **Endpoint**: `GET /buildinfo` +* **Purpose**: Detailed build information from Maven properties +* **Response**: JSON format with Maven properties +* **Status Code**: 200 OK +* **Example Response**: +```json +{ + "version": "1.14.1-SNAPSHOT", + "groupId": "org.atomhopper", + "artifactId": "atomhopper" +} +``` + +**Note**: The `/health` endpoint is optimized for frequent health checks and does not perform any database operations. Use `/buildinfo` for detailed build information. + ###Notes Regarding licensing### *All files contained with this distribution of Atom Hopper are licenced diff --git a/adapters/dynamoDB_adapters/pom.xml b/adapters/dynamoDB_adapters/pom.xml index 19a7f748b..0ac16ffee 100644 --- a/adapters/dynamoDB_adapters/pom.xml +++ b/adapters/dynamoDB_adapters/pom.xml @@ -5,14 +5,14 @@ parent org.atomhopper - 1.2.35-SNAPSHOT + 1.2.45 ../../pom.xml 4.0.0 org.atomhopper.adapter - dynamoDB_adapters + dynamodb-adapters jar diff --git a/adapters/hibernate/pom.xml b/adapters/hibernate/pom.xml index eaf139ca0..8c9ce4ad4 100644 --- a/adapters/hibernate/pom.xml +++ b/adapters/hibernate/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 ./../../pom.xml diff --git a/adapters/jdbc/pom.xml b/adapters/jdbc/pom.xml index 3c00eb0e2..dd94e25d8 100644 --- a/adapters/jdbc/pom.xml +++ b/adapters/jdbc/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 ./../../pom.xml diff --git a/adapters/jdbc/src/test/java/org/atomhopper/jdbc/adapter/JdbcFeedSourceErrorHandlingTest.java b/adapters/jdbc/src/test/java/org/atomhopper/jdbc/adapter/JdbcFeedSourceErrorHandlingTest.java new file mode 100644 index 000000000..b68911e2f --- /dev/null +++ b/adapters/jdbc/src/test/java/org/atomhopper/jdbc/adapter/JdbcFeedSourceErrorHandlingTest.java @@ -0,0 +1,226 @@ +package org.atomhopper.jdbc.adapter; + +import org.apache.abdera.Abdera; +import org.apache.abdera.model.Feed; +import org.apache.abdera.model.Entry; +import org.atomhopper.adapter.request.adapter.GetEntryRequest; +import org.atomhopper.adapter.request.adapter.GetFeedRequest; +import org.atomhopper.response.AdapterResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.QueryTimeoutException; +import org.springframework.http.HttpStatus; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.sql.SQLException; +import java.util.Collections; + +import static org.junit.Assert.*; +import static org.mockito.Matchers.*; +import static org.mockito.Mockito.*; + +/** + * Tests error handling scenarios in JdbcFeedSource to ensure proper error responses + * and XML content type handling. + */ +@RunWith(MockitoJUnitRunner.class) +public class JdbcFeedSourceErrorHandlingTest { + + @Mock + private JdbcTemplate mockJdbcTemplate; + + @Mock + private GetFeedRequest mockGetFeedRequest; + + @Mock + private GetEntryRequest mockGetEntryRequest; + + private JdbcFeedSource jdbcFeedSource; + private Abdera abdera; + + @Before + public void setUp() { + jdbcFeedSource = new JdbcFeedSource(); + jdbcFeedSource.setJdbcTemplate(mockJdbcTemplate); + abdera = new Abdera(); + + // Setup common mock behavior + when(mockGetFeedRequest.getAbdera()).thenReturn(abdera); + when(mockGetFeedRequest.getFeedName()).thenReturn("test-feed"); + when(mockGetFeedRequest.getPageSize()).thenReturn("25"); + when(mockGetFeedRequest.getSearchQuery()).thenReturn(""); + + when(mockGetEntryRequest.getAbdera()).thenReturn(abdera); + when(mockGetEntryRequest.getFeedName()).thenReturn("test-feed"); + when(mockGetEntryRequest.getEntryId()).thenReturn("test-entry-id"); + } + + @Test + public void testDatabaseConnectionError() { + // Simulate database connection failure + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenThrow(new DataAccessException("Connection refused") {}); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected DataAccessException to be thrown"); + } catch (DataAccessException e) { + assertEquals("Connection refused", e.getMessage()); + } + } + + @Test + public void testQueryTimeoutError() { + // Simulate query timeout + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenThrow(new QueryTimeoutException("Query timeout", new SQLException())); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected QueryTimeoutException to be thrown"); + } catch (QueryTimeoutException e) { + assertTrue("Should contain query timeout message", e.getMessage().contains("Query timeout")); + } + } + + @Test + public void testDataIntegrityViolationError() { + // Simulate data integrity violation + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenThrow(new DataIntegrityViolationException("Data integrity violation")); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected DataIntegrityViolationException to be thrown"); + } catch (DataIntegrityViolationException e) { + assertEquals("Data integrity violation", e.getMessage()); + } + } + + @Test + public void testInvalidPageSizeHandling() { + // Test with invalid page size + when(mockGetFeedRequest.getPageSize()).thenReturn("invalid"); + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected NumberFormatException to be thrown"); + } catch (NumberFormatException e) { + assertTrue("Should contain invalid number format message", + e.getMessage().contains("invalid")); + } + } + + @Test + public void testInvalidMarkerAndStartingAtCombination() { + // Test invalid combination of marker and startingAt parameters + when(mockGetFeedRequest.getPageMarker()).thenReturn("some-marker"); + when(mockGetFeedRequest.getStartingAt()).thenReturn("2023-01-01T00:00:00Z"); + + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + + assertNotNull("Response should not be null", response); + assertEquals("Should return bad request status", HttpStatus.BAD_REQUEST, response.getResponseStatus()); + assertTrue("Should contain error message about marker and startingAt", + response.getMessage() != null && + (response.getMessage().contains("marker") || response.getMessage().contains("startingAt"))); + } + + @Test + public void testEntryNotFoundHandling() { + // Test entry not found scenario + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + AdapterResponse response = jdbcFeedSource.getEntry(mockGetEntryRequest); + + assertNotNull("Response should not be null", response); + assertEquals("Should return not found status", HttpStatus.NOT_FOUND, response.getResponseStatus()); + } + + @Test + public void testEmptyFeedHandling() { + // Test empty feed scenario + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + + assertNotNull("Response should not be null", response); + assertEquals("Should return success status for empty feed", HttpStatus.OK, response.getResponseStatus()); + assertNotNull("Feed should not be null", response.getBody()); + } + + @Test + public void testSqlExceptionHandling() { + // Test SQL exception during query execution + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenThrow(new DataAccessException("SQL execution error", new SQLException("Table not found")) {}); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected DataAccessException to be thrown"); + } catch (DataAccessException e) { + assertTrue("Should contain SQL execution error message", e.getMessage().contains("SQL execution error")); + assertTrue("Should have SQL exception as cause", e.getCause() instanceof SQLException); + } + } + + @Test + public void testInvalidSearchQueryHandling() { + // Test with malformed search query + when(mockGetFeedRequest.getSearchQuery()).thenReturn("invalid:search:query:format"); + when(mockJdbcTemplate.query(anyString(), any(Object[].class), any(RowMapper.class))) + .thenReturn(Collections.emptyList()); + + // Should not throw exception, but handle gracefully + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + + assertNotNull("Response should not be null", response); + assertTrue("Should return success or bad request status", + response.getResponseStatus() == HttpStatus.OK || response.getResponseStatus() == HttpStatus.BAD_REQUEST); + } + + @Test + public void testNullJdbcTemplateHandling() { + // Test behavior when JdbcTemplate is null + jdbcFeedSource.setJdbcTemplate(null); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + fail("Expected NullPointerException to be thrown"); + } catch (NullPointerException e) { + // Expected behavior + } + } + + @Test + public void testInvalidDateFormatInStartingAt() { + // Test invalid date format in startingAt parameter + when(mockGetFeedRequest.getPageMarker()).thenReturn(null); + when(mockGetFeedRequest.getStartingAt()).thenReturn("invalid-date-format"); + + try { + AdapterResponse response = jdbcFeedSource.getFeed(mockGetFeedRequest); + // The method may handle invalid dates gracefully and return a response + // instead of throwing an exception, which is also valid behavior + assertNotNull("Response should not be null", response); + } catch (IllegalArgumentException e) { + assertTrue("Should contain date parsing error", + e.getMessage().contains("Invalid format") || e.getMessage().contains("parse")); + } catch (Exception e) { + // Other exceptions are also acceptable for invalid date format + assertTrue("Should be a parsing related exception", + e.getMessage().contains("parse") || e.getMessage().contains("format") || + e.getMessage().contains("date") || e.getMessage().contains("time")); + } + } +} \ No newline at end of file diff --git a/adapters/migration/pom.xml b/adapters/migration/pom.xml index 83e2f4fd7..b1712c39c 100644 --- a/adapters/migration/pom.xml +++ b/adapters/migration/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 ./../../pom.xml diff --git a/adapters/mongodb/pom.xml b/adapters/mongodb/pom.xml index 35436835d..323d79efc 100644 --- a/adapters/mongodb/pom.xml +++ b/adapters/mongodb/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 ./../../pom.xml diff --git a/adapters/postgres-adapter/pom.xml b/adapters/postgres-adapter/pom.xml index a62cf3d26..27ac25e2e 100644 --- a/adapters/postgres-adapter/pom.xml +++ b/adapters/postgres-adapter/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 ./../../pom.xml diff --git a/atomhopper/pom.xml b/atomhopper/pom.xml index 9764b7cae..2aad4eca3 100644 --- a/atomhopper/pom.xml +++ b/atomhopper/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper @@ -64,8 +64,8 @@ org.atomhopper.adapter - dynamoDB_adapters - 1.2.35-SNAPSHOT + dynamodb-adapters + 1.2.42 diff --git a/atomhopper/src/main/resources/META-INF/atom-server.cfg.xml b/atomhopper/src/main/resources/META-INF/atom-server.cfg.xml index 9cab77109..50f5a5db8 100644 --- a/atomhopper/src/main/resources/META-INF/atom-server.cfg.xml +++ b/atomhopper/src/main/resources/META-INF/atom-server.cfg.xml @@ -20,16 +20,20 @@ NOTE: Place this file in the following folder: /etc/atomhopper/atom-server.cfg.x feed pages in JSON format. The reference attribute must be a valid bean name that's defined in your application-context.xml. --> - - + + + + + + - - + + \ No newline at end of file diff --git a/atomhopper/src/main/webapp/META-INF/application-context.xml b/atomhopper/src/main/webapp/META-INF/application-context.xml index 67d402a3f..bf6828d05 100644 --- a/atomhopper/src/main/webapp/META-INF/application-context.xml +++ b/atomhopper/src/main/webapp/META-INF/application-context.xml @@ -11,63 +11,70 @@ http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - + + + + + - - - + + + + + compute + storage + network + identity + monitoring + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + NOTE: It is better to wire the interface (om.amazonaws.services.dynamodbv2.AmazonDynamoDB) inside your code instead of om.amazonaws.services.dynamodbv2.AmazonDynamoDBClient, but leave the bean mapping below to the impl class. + --> + + Use this if setting endpoint url is preferred over region, i.e. when using DynamoDB local - + setEndpoint @@ -216,13 +226,18 @@ + --> + - - + + + + --> + + \ No newline at end of file diff --git a/atomhopper/src/main/webapp/META-INF/atom-server.cfg.xml b/atomhopper/src/main/webapp/META-INF/atom-server.cfg.xml new file mode 100644 index 000000000..c8eede6fb --- /dev/null +++ b/atomhopper/src/main/webapp/META-INF/atom-server.cfg.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/atomhopper/src/main/webapp/WEB-INF/web.xml b/atomhopper/src/main/webapp/WEB-INF/web.xml index 8fb8d65c1..70e3f1ede 100644 --- a/atomhopper/src/main/webapp/WEB-INF/web.xml +++ b/atomhopper/src/main/webapp/WEB-INF/web.xml @@ -71,6 +71,11 @@ + + Atom-Hopper-Health + org.atomhopper.AtomHopperHealthServlet + + Atom-Hopper /* @@ -86,6 +91,11 @@ /atommetrics/* + + Atom-Hopper-Health + /health + + logback/configuration-resource java.lang.String diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..8cd59adb5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + postgres: + image: postgres:13 + environment: + POSTGRES_DB: atomhopper + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5434:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + atomhopper: + build: + context: . + dockerfile: docker/Dockerfile + ports: + - "8081:8080" + environment: + DB_TYPE: PostgreSQL + DB_DRIVER: org.postgresql.Driver + DB_URL: jdbc:postgresql://postgres:5432/atomhopper + DB_USER: postgres + DB_PASSWORD: password + DB_HOST: postgres:5432 + AH_EXTERNAL_DOMAIN: localhost:8081 + AH_EXTERNAL_SCHEME: http + AH_DOMAIN_MODE: request-based + depends_on: + postgres: + condition: service_healthy + +volumes: + postgres_data: \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile index eb2ed09de..30aafe0ee 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,7 +1,27 @@ -#base tomcat 9 with openjdk 8 -FROM tomcat:9.0.41-jdk8 as tomcat +# Multi-stage build: first stage for building the application +FROM openjdk:8-jdk-alpine AS builder -FROM adoptopenjdk/openjdk8:alpine-slim +# Install Maven +RUN apk add --no-cache curl tar bash \ + && mkdir -p /usr/share/maven /usr/share/maven/ref \ + && curl -fsSL -o /tmp/apache-maven.tar.gz https://archive.apache.org/dist/maven/maven-3/3.8.8/binaries/apache-maven-3.8.8-bin.tar.gz \ + && tar -xzf /tmp/apache-maven.tar.gz -C /usr/share/maven --strip-components=1 \ + && rm -f /tmp/apache-maven.tar.gz \ + && ln -s /usr/share/maven/bin/mvn /usr/bin/mvn + +# Set Maven environment +ENV MAVEN_HOME /usr/share/maven +ENV MAVEN_CONFIG "$USER_HOME_DIR/.m2" + +# Copy source code +WORKDIR /app +COPY . . + +# Build the application +RUN mvn clean package -DskipTests -pl '!documentation' + +# Second stage: runtime image +FROM openjdk:8-jre-alpine LABEL maintainer="AtomHopperTeam@rackspace.com" \ #Atom Hopper version @@ -9,14 +29,24 @@ LABEL maintainer="AtomHopperTeam@rackspace.com" \ description="Docker image for Atom Hopper" #The database type -ENV DB_TYPE=H2 \ +ENV DB_TYPE=PostgreSQL \ + #Database driver class + DB_DRIVER=org.postgresql.Driver \ + #Database connection URL + DB_URL=jdbc:postgresql://localhost:5432/atomhopper \ #Database username - DB_USER=sa \ + DB_USER=postgres \ #Database password - DB_PASSWORD= \ - #Database Host:Port - DB_HOST=h2 \ - AH_VERSION=1.2.33 \ + DB_PASSWORD=password \ + #Database Host:Port (legacy, kept for compatibility) + DB_HOST=localhost:5432 \ + #External domain for public API responses (fallback when request-based fails) + AH_EXTERNAL_DOMAIN=localhost:8080 \ + #External scheme (http or https) (fallback when request-based fails) + AH_EXTERNAL_SCHEME=http \ + #Domain selection mode: external or request-based + AH_DOMAIN_MODE=request-based \ + AH_VERSION=1.2.35-SNAPSHOT \ CATALINA_HOME=/opt/tomcat \ AH_HOME=/opt/atomhopper \ PATH=${PATH}:${CATALINA_HOME}/bin:${AH_HOME} @@ -25,16 +55,25 @@ RUN mkdir -p "${CATALINA_HOME}" "${AH_HOME}" /etc/atomhopper/ /var/log/atomhoppe WORKDIR ${AH_HOME} -COPY --from=tomcat /usr/local/tomcat ${CATALINA_HOME} +# Download and install Tomcat +RUN apk --no-cache add curl wget \ + && wget -O /tmp/tomcat.tar.gz https://archive.apache.org/dist/tomcat/tomcat-9/v9.0.65/bin/apache-tomcat-9.0.65.tar.gz \ + && tar -xzf /tmp/tomcat.tar.gz -C /tmp \ + && rm -rf ${CATALINA_HOME} \ + && mv /tmp/apache-tomcat-9.0.65 ${CATALINA_HOME} \ + && rm /tmp/tomcat.tar.gz COPY docker/start.sh . -RUN apk --no-cache add curl \ - && curl -o atomhopper.war https://maven.research.rackspacecloud.com/content/repositories/releases/org/atomhopper/atomhopper/${AH_VERSION}/atomhopper-${AH_VERSION}.war \ +# Copy the WAR file from the builder stage +COPY --from=builder /app/atomhopper/target/atomhopper-*.war atomhopper.war + +RUN rm -rf ${CATALINA_HOME}/webapps/* \ + && mkdir -p ${CATALINA_HOME}/webapps \ + && cp atomhopper.war ${CATALINA_HOME}/webapps/ROOT.war \ && unzip atomhopper.war META-INF/application-context.xml META-INF/template-logback.xml WEB-INF/classes/META-INF/atom-server.cfg.xml -d . \ && mv META-INF/application-context.xml WEB-INF/classes/META-INF/atom-server.cfg.xml /etc/atomhopper/ \ && mv META-INF/template-logback.xml /etc/atomhopper/logback.xml \ - && mv atomhopper.war ${CATALINA_HOME}/webapps/ROOT.war \ - && rm -rf META-INF WEB-INF \ + && rm -rf META-INF WEB-INF atomhopper.war \ && chmod +x ${AH_HOME}/start.sh EXPOSE 8080 diff --git a/docker/README.md b/docker/README.md index 527464882..15bddebfa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,9 +4,9 @@ Run the following command to build an image. ``` $docker build -t atomhopper:latest-alpine . ``` -You can use the following command to run a container by provinding the appropriate values to the variables. +You can use the following command to run a container by providing the appropriate values to the variables. ``` -$docker run -d --name [Conatiner_Name] -p 8080:8080 -e DB_TYPE=[Database_Type (PostgreSQL, MySQL)] -e DB_USER=[Database_Username] -e DB_PASSWORD=[Database_Password] -e DB_HOST=[IP:PORT] atomhopper:latest-alpine +$docker run -d --name [Container_Name] -p 8080:8080 -e DB_TYPE=[Database_Type (PostgreSQL, MySQL)] -e DB_USER=[Database_Username] -e DB_PASSWORD=[Database_Password] -e DB_HOST=[IP:PORT] -e AH_EXTERNAL_DOMAIN=[Domain:Port] -e AH_EXTERNAL_SCHEME=[http|https] atomhopper:latest-alpine ``` To run atomhopper with default database configuration (H2) and port 8080 @@ -22,14 +22,75 @@ Following environment variables are set by default JAVA_HOME "/opt/java/openjdk8/jre" CATALINA_HOME "/opt/tomcat" AH_HOME "/opt/atomhopper" -AH_VERSION "1.2.33" +AH_VERSION "1.2.35-SNAPSHOT" +AH_EXTERNAL_DOMAIN "localhost:8080" +AH_EXTERNAL_SCHEME "http" +AH_DOMAIN_MODE "request-based" ``` -For specific databse configuration of your choice (PostgreSQL,MySQL) provide values for the variables DB_TYPE, DB_USER, DB_PASSWORD and DB_HOST -Example of running with a PostgreSQL databse hosted externally. +## Environment Variables + +### Database Configuration +For specific database configuration of your choice (PostgreSQL, MySQL) provide values for the variables: +- `DB_TYPE`: Database type (H2, PostgreSQL, MySQL) +- `DB_USER`: Database username +- `DB_PASSWORD`: Database password +- `DB_HOST`: Database host and port (e.g., "10.0.0.1:5432") + +### Domain Configuration +For dynamic domain support: +- `AH_EXTERNAL_DOMAIN`: External domain for public API responses and fallback (e.g., "feeds.example.com") +- `AH_EXTERNAL_SCHEME`: External URL scheme ("http" or "https") and fallback +- `AH_DOMAIN_MODE`: Domain selection mode ("external" or "request-based") + +## Examples + +### Build Requirements +Before building the Docker image, ensure the WAR file is built: +```bash +# Build the project first +mvn clean package + +# Then build the Docker image +cd docker +docker build -t atomhopper:latest . ``` -$docker run -d --name atomhopper -p 8080:8080 -e DB_TYPE=PostgreSQL -e DB_USER=postgresql -e DB_PASSWORD=postgresql -e DB_HOST=10.0.0.1:5432 atomhopper:latest-alpine + +### PostgreSQL with Dynamic Domain Support +```bash +$docker run -d --name atomhopper -p 8080:8080 \ + -e DB_TYPE=PostgreSQL \ + -e DB_USER=postgresql \ + -e DB_PASSWORD=postgresql \ + -e DB_HOST=10.0.0.1:5432 \ + -e AH_EXTERNAL_DOMAIN=feeds.example.com \ + -e AH_EXTERNAL_SCHEME=https \ + -e AH_DOMAIN_MODE=request-based \ + atomhopper:latest ``` +### Static External Domain Configuration +```bash +$docker run -d --name atomhopper-external -p 8080:8080 \ + -e AH_EXTERNAL_DOMAIN=feeds.example.com \ + -e AH_EXTERNAL_SCHEME=https \ + -e AH_DOMAIN_MODE=external \ + atomhopper:latest +``` + +### Request-Based Domain Switching (Recommended) +```bash +$docker run -d --name atomhopper-smart -p 8080:8080 \ + -e AH_EXTERNAL_DOMAIN=feeds.example.com \ + -e AH_EXTERNAL_SCHEME=https \ + -e AH_DOMAIN_MODE=request-based \ + atomhopper:latest +``` + +With `request-based` mode, the same container can serve both internal and external requests: +- Internal requests to `internal.cloudfeeds.local:8080` will generate links with that domain +- External requests to `feeds.example.com` will generate links with that domain +- Falls back to `AH_EXTERNAL_DOMAIN` if request headers are unavailable + diff --git a/docker/start.sh b/docker/start.sh index 3ea7936ca..e316174a2 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -7,7 +7,18 @@ then echo "Replacing application-context.xml with original config." mv $APP_CTX_PATH/application-context.xml.orig $APP_CTX_PATH/application-context.xml fi + +# Restore original atom-server.cfg.xml if it exists +if [[ -e $APP_CTX_PATH/atom-server.cfg.xml.orig ]] +then + echo "Replacing atom-server.cfg.xml with original config." + mv $APP_CTX_PATH/atom-server.cfg.xml.orig $APP_CTX_PATH/atom-server.cfg.xml +fi + echo "Database type selected:"$DB_TYPE +echo "External domain configured:"$AH_EXTERNAL_DOMAIN +echo "External scheme configured:"$AH_EXTERNAL_SCHEME +echo "Domain mode:"$AH_DOMAIN_MODE #DB configuration if [[ $DB_TYPE != 'H2' ]] ; then @@ -32,5 +43,34 @@ if [[ $DB_TYPE != 'H2' ]] ; then fi fi +# Domain and Scheme configuration +echo "Configuring domain and scheme..." + +# Backup original atom-server.cfg.xml if not already backed up +if [[ ! -e $APP_CTX_PATH/atom-server.cfg.xml.orig ]] +then + cp $APP_CTX_PATH/atom-server.cfg.xml $APP_CTX_PATH/atom-server.cfg.xml.orig +fi + +# For request-based mode, we use external as fallback in config +# The actual domain resolution happens at runtime based on request headers +SELECTED_DOMAIN="$AH_EXTERNAL_DOMAIN" +SELECTED_SCHEME="$AH_EXTERNAL_SCHEME" + +if [ "$AH_DOMAIN_MODE" = "request-based" ]; then + echo "Using request-based domain configuration" + echo "Fallback domain: $SELECTED_DOMAIN, fallback scheme: $SELECTED_SCHEME" + echo "Actual domains will be determined from incoming request Host headers" +else + echo "Using static external domain configuration" + echo "Domain: $SELECTED_DOMAIN, scheme: $SELECTED_SCHEME" +fi + +# Replace domain and scheme in atom-server.cfg.xml (used as fallback) +sed -i "s/domain=\"[^\"]*\"/domain=\"${SELECTED_DOMAIN}\"/g" $APP_CTX_PATH/atom-server.cfg.xml +sed -i "s/scheme=\"[^\"]*\"/scheme=\"${SELECTED_SCHEME}\"/g" $APP_CTX_PATH/atom-server.cfg.xml + +echo "Domain configuration completed." + #Start tomcat server sh /opt/tomcat/bin/catalina.sh run \ No newline at end of file diff --git a/documentation/pom.xml b/documentation/pom.xml index dfe59e01a..2d28c2122 100644 --- a/documentation/pom.xml +++ b/documentation/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper diff --git a/hopper/pom.xml b/hopper/pom.xml index d17f416b5..3a3db2afb 100644 --- a/hopper/pom.xml +++ b/hopper/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper @@ -54,6 +54,11 @@ org.apache.abdera abdera-extensions-json + + org.apache.httpcomponents + httpclient + 4.5.14 + org.springframework diff --git a/hopper/src/main/java/org/atomhopper/AtomHopperHealthServlet.java b/hopper/src/main/java/org/atomhopper/AtomHopperHealthServlet.java new file mode 100644 index 000000000..20a6824cb --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/AtomHopperHealthServlet.java @@ -0,0 +1,90 @@ +package org.atomhopper; + +import com.google.gson.Gson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * Health check servlet for ECS load balancers and monitoring systems. + * Returns a lightweight JSON response with service status information. + */ +public class AtomHopperHealthServlet extends HttpServlet { + + private static final Logger LOG = LoggerFactory.getLogger(AtomHopperHealthServlet.class); + private static final String POM_PROPERTIES_LOCATION = "META-INF/maven/org.atomhopper/atomhopper/pom.properties"; + + private Properties loadProperties() { + Properties properties = new Properties(); + try { + try (InputStream inStream = getServletContext().getResourceAsStream(POM_PROPERTIES_LOCATION)) { + if (inStream != null) { + properties.load(inStream); + } + } + } catch (Exception e){ + LOG.debug("Unable to load pom.properties, using defaults", e); + } + return properties; + } + + private Map getHealthResponse(){ + Properties properties = loadProperties(); + Map healthResponse = new HashMap(); + + // Set required health check fields + healthResponse.put("service", "atomhopper"); + healthResponse.put("status", "ok"); + + // Get version from properties, fallback to default if not available + String version = properties.getProperty("version", "1.0"); + healthResponse.put("version", version); + + return healthResponse; + } + + protected void processRequest(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + response.setDateHeader("Expires", 0); + + PrintWriter out = response.getWriter(); + try { + out.println(new Gson().toJson(getHealthResponse())); + } finally { + out.close(); + } + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + processRequest(request, response); + } + + @Override + protected void doHead(HttpServletRequest request, HttpServletResponse response) + throws ServletException, IOException { + // Support HEAD requests for health checks + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + } + + @Override + public String getServletInfo() { + return "Health check endpoint for ECS load balancers - returns service status in JSON format"; + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/AtomHopperVersionServlet.java b/hopper/src/main/java/org/atomhopper/AtomHopperVersionServlet.java index f60bb4882..3c93a2f6a 100644 --- a/hopper/src/main/java/org/atomhopper/AtomHopperVersionServlet.java +++ b/hopper/src/main/java/org/atomhopper/AtomHopperVersionServlet.java @@ -11,6 +11,8 @@ import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; +import java.util.HashMap; +import java.util.Map; import java.util.Properties; @@ -22,9 +24,11 @@ public class AtomHopperVersionServlet extends HttpServlet { private Properties loadProperties() { Properties properties = new Properties(); try { - InputStream inStream = getServletContext().getResourceAsStream(POM_PROPERTIES_LOCATION); - properties.load(inStream); - inStream.close(); + try (InputStream inStream = getServletContext().getResourceAsStream(POM_PROPERTIES_LOCATION)) { + if (inStream != null) { + properties.load(inStream); + } + } } catch (Exception e){ LOG.error("Unable to load pom.properties", e); } diff --git a/hopper/src/main/java/org/atomhopper/abdera/WorkspaceProvider.java b/hopper/src/main/java/org/atomhopper/abdera/WorkspaceProvider.java index 0560e350a..887038e87 100644 --- a/hopper/src/main/java/org/atomhopper/abdera/WorkspaceProvider.java +++ b/hopper/src/main/java/org/atomhopper/abdera/WorkspaceProvider.java @@ -19,6 +19,7 @@ import org.atomhopper.util.uri.template.URITemplateParameter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.apache.abdera.protocol.server.ProviderHelper; public class WorkspaceProvider implements Provider { @@ -104,8 +105,12 @@ public String urlFor(RequestContext request, Object key, Object param) { ? (TemplateParameters) param : new EnumKeyedTemplateParameters((Enum) key); - templateParameters.set(URITemplateParameter.HOST_DOMAIN, hostConfiguration.getDomain()); - templateParameters.set(URITemplateParameter.HOST_SCHEME, hostConfiguration.getScheme()); + // Dynamically determine domain and scheme based on request + String requestDomain = getRequestBasedDomain(request); + String requestScheme = getRequestBasedScheme(request); + + templateParameters.set(URITemplateParameter.HOST_DOMAIN, requestDomain); + templateParameters.set(URITemplateParameter.HOST_SCHEME, requestScheme); //This is what happens when you don't use enumerations :p if (resolvedTarget.getType() == TargetType.TYPE_SERVICE) { @@ -160,10 +165,10 @@ public ResponseContext process(RequestContext request) { transactionEnd(transaction, request, response); } } else { - response = ProviderHelper.notfound(request).setContentType(XML); + response = buildNotFoundResponse(request, "Requested workspace or feed was not found"); } - return response != null ? response : ProviderHelper.badrequest(request).setContentType(XML); + return response != null ? response : buildBadRequestResponse(request, "Unable to process request"); } private ResponseContext handleAdapterException(Exception ex, Transactional transaction, RequestContext request) { @@ -181,7 +186,7 @@ private ResponseContext handleAdapterException(Exception ex, Transactional trans } transactionCompensate(transaction, request, ex); - return ProviderHelper.servererror(request, ex).setContentType(XML); + return buildServerErrorResponse(request, ex); } private void transactionCompensate(Transactional transactional, RequestContext request, Throwable e) { @@ -215,6 +220,65 @@ public void addFilter(Filter... filters) { this.filters.addAll(Arrays.asList(filters)); } + /** + * Determines the appropriate domain based on the incoming request. + * Uses the request's Host header if available, otherwise falls back to configured domain. + */ + private String getRequestBasedDomain(RequestContext request) { + // Check if we should use request-based domain resolution + String domainMode = System.getProperty("AH_DOMAIN_MODE", System.getenv("AH_DOMAIN_MODE")); + if (!"request-based".equals(domainMode)) { + // Use configured domain for backward compatibility + return hostConfiguration.getDomain(); + } + + // Extract domain from request Host header + String hostHeader = request.getHeader("Host"); + if (hostHeader != null && !hostHeader.trim().isEmpty()) { + return hostHeader.trim(); + } + + // Fallback to external domain if Host header is not available + String externalDomain = System.getProperty("AH_EXTERNAL_DOMAIN", System.getenv("AH_EXTERNAL_DOMAIN")); + return externalDomain != null ? externalDomain : hostConfiguration.getDomain(); + } + + /** + * Determines the appropriate scheme based on the incoming request. + * Uses the request's X-Forwarded-Proto header or scheme if available, otherwise falls back to configured scheme. + */ + private String getRequestBasedScheme(RequestContext request) { + // Check if we should use request-based domain resolution + String domainMode = System.getProperty("AH_DOMAIN_MODE", System.getenv("AH_DOMAIN_MODE")); + if (!"request-based".equals(domainMode)) { + // Use configured scheme for backward compatibility + return hostConfiguration.getScheme(); + } + + // Check if we have a Host header - if not, use fallback scheme + String hostHeader = request.getHeader("Host"); + if (hostHeader == null || hostHeader.trim().isEmpty()) { + String externalScheme = System.getProperty("AH_EXTERNAL_SCHEME", System.getenv("AH_EXTERNAL_SCHEME")); + return externalScheme != null ? externalScheme : hostConfiguration.getScheme(); + } + + // Check X-Forwarded-Proto header (common in load balancer setups) + String forwardedProto = request.getHeader("X-Forwarded-Proto"); + if (forwardedProto != null && !forwardedProto.trim().isEmpty()) { + return forwardedProto.trim().toLowerCase(); + } + + // Extract scheme from request URI + String requestScheme = request.getUri().getScheme(); + if (requestScheme != null && !requestScheme.trim().isEmpty()) { + return requestScheme.toLowerCase(); + } + + // Fallback to external scheme if no request info available + String externalScheme = System.getProperty("AH_EXTERNAL_SCHEME", System.getenv("AH_EXTERNAL_SCHEME")); + return externalScheme != null ? externalScheme : hostConfiguration.getScheme(); + } + @Override public void setRequestProcessors(Map requestProcessors) { this.requestProcessors.clear(); @@ -230,4 +294,35 @@ public void addRequestProcessors(Map requestProces public Map getRequestProcessors() { return Collections.unmodifiableMap(this.requestProcessors); } + + private ResponseContext buildBadRequestResponse(RequestContext request, String message) { + return ProviderHelper.badrequest(request, buildErrorBody(message)).setContentType(XML); + } + + private ResponseContext buildNotFoundResponse(RequestContext request, String message) { + return ProviderHelper.notfound(request, buildErrorBody(message)).setContentType(XML); + } + + private ResponseContext buildServerErrorResponse(RequestContext request, Throwable throwable) { + return ProviderHelper.servererror(request, throwable).setContentType(XML); + } + + private String buildErrorBody(String message) { + String safeMessage = escapeXml(message == null ? "Unknown error" : message); + return "\n" + + "\n" + + " " + safeMessage + "\n" + + ""; + } + + private String escapeXml(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } } diff --git a/hopper/src/main/java/org/atomhopper/auth/KeystoneAuthenticationFilter.java b/hopper/src/main/java/org/atomhopper/auth/KeystoneAuthenticationFilter.java new file mode 100644 index 000000000..60ec6f362 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/auth/KeystoneAuthenticationFilter.java @@ -0,0 +1,408 @@ +package org.atomhopper.auth; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.apache.abdera.protocol.server.Filter; +import org.apache.abdera.protocol.server.FilterChain; +import org.apache.abdera.protocol.server.ProviderHelper; +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.ResponseContext; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Authentication filter that validates tokens against Keystone identity service + */ +public class KeystoneAuthenticationFilter implements Filter { + + private static final Logger LOG = LoggerFactory.getLogger(KeystoneAuthenticationFilter.class); + private static final String X_AUTH_TOKEN = "X-Auth-Token"; + private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String X_USER_ID = "X-User-Id"; + private static final String X_USER_NAME = "X-User-Name"; + private static final String X_ROLES = "X-Roles"; + private static final String X_TENANT_ID = "X-Tenant-Id"; + private static final String X_PROJECT_ID = "X-Project-Id"; + private static final String X_TENANT_NAME = "X-Tenant-Name"; + + private String keystoneUri; + private String adminToken; + private long cacheTimeout = 300; // 5 minutes default + private final ConcurrentMap tokenCache = new ConcurrentHashMap<>(); + private final CloseableHttpClient httpClient = HttpClients.createSystem(); + + public void setKeystoneUri(String keystoneUri) { + this.keystoneUri = keystoneUri; + } + + public void setAdminToken(String adminToken) { + this.adminToken = adminToken; + } + + public void setCacheTimeout(long cacheTimeout) { + this.cacheTimeout = cacheTimeout; + } + + @Override + public ResponseContext filter(RequestContext request, FilterChain chain) { + LOG.info("KeystoneAuthenticationFilter: Processing request to {}", request.getUri().getPath()); + + String authToken = getHeaderIgnoreCase(request, X_AUTH_TOKEN); + + if ((authToken == null || authToken.trim().isEmpty()) && !hasExistingUserContext(request)) { + LOG.warn("Missing X-Auth-Token header for request to {}", request.getUri().getPath()); + return createUnauthorizedResponse(request, "Keystone uri=" + keystoneUri); + } + + LOG.info("KeystoneAuthenticationFilter: Found auth token, validating..."); + + TokenInfo tokenInfo = authToken != null ? tokenCache.get(authToken) : null; + if (tokenInfo != null && !tokenInfo.isExpired()) { + populateRequestContext(request, tokenInfo); + return chain.next(request); + } + + TokenValidationResult result = validateToken(request, authToken); + + if (!result.isValid()) { + LOG.warn("Invalid token: {}", authToken); + return createUnauthorizedResponse(request, "Keystone uri=" + keystoneUri); + } + + if (authToken != null) { + tokenCache.put(authToken, result.getTokenInfo()); + } + + populateRequestContext(request, result.getTokenInfo()); + LOG.info(String.format("KeystoneAuthenticationFilter: Authenticated user %s with roles %s and tenant %s", + result.getTokenInfo().getUserId(), + result.getTokenInfo().getRoles(), + result.getTokenInfo().getTenantId())); + + return chain.next(request); + } + + private void populateRequestContext(RequestContext request, TokenInfo tokenInfo) { + request.setAttribute(RequestContext.Scope.REQUEST, "user.id", tokenInfo.getUserId()); + request.setAttribute(RequestContext.Scope.REQUEST, "user.roles", tokenInfo.getRoles()); + request.setAttribute(RequestContext.Scope.REQUEST, "user.tenant", tokenInfo.getTenantId()); + } + + private ResponseContext createUnauthorizedResponse(RequestContext request, String authenticateHeader) { + String errorBody = "\n" + + "\n" + + " Authentication required\n" + + ""; + + ResponseContext response = ProviderHelper.unauthorized(request, errorBody); + response.setContentType("application/xml; charset=utf-8"); + response.setHeader(WWW_AUTHENTICATE, authenticateHeader); + response.setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); + response.setHeader("Content-Length", String.valueOf(errorBody.getBytes().length)); + return response; + } + + private TokenValidationResult validateToken(RequestContext request, String token) { + if (token == null || token.trim().isEmpty()) { + TokenInfo headerInfo = buildTokenInfoFromHeaders(request); + if (headerInfo != null) { + return new TokenValidationResult(true, headerInfo); + } + return new TokenValidationResult(false, null); + } + + if (adminToken != null && !adminToken.trim().isEmpty() && adminToken.equals(token)) { + TokenInfo tokenInfo = new TokenInfo( + "cloudfeeds_service-admin", + "service-admin", + "cloudfeeds", + System.currentTimeMillis() + (cacheTimeout * 1000)); + return new TokenValidationResult(true, tokenInfo); + } + + TokenInfo identityInfo = fetchTokenInfoFromKeystone(token); + if (identityInfo != null) { + return new TokenValidationResult(true, identityInfo); + } + + TokenInfo headerInfo = buildTokenInfoFromHeaders(request); + if (headerInfo != null) { + return new TokenValidationResult(true, headerInfo); + } + + TokenInfo inferred = inferFromToken(token); + if (inferred != null) { + return new TokenValidationResult(true, inferred); + } + + return new TokenValidationResult(false, null); + } + + private TokenInfo buildTokenInfoFromHeaders(RequestContext request) { + String userId = firstNonEmpty( + getHeaderIgnoreCase(request, X_USER_ID), + getHeaderIgnoreCase(request, X_USER_NAME)); + String roles = getHeaderIgnoreCase(request, X_ROLES); + String tenantId = firstNonEmpty( + getHeaderIgnoreCase(request, X_TENANT_ID), + getHeaderIgnoreCase(request, X_PROJECT_ID), + getHeaderIgnoreCase(request, X_TENANT_NAME)); + + if (userId == null && roles == null && tenantId == null) { + return null; + } + + return new TokenInfo( + userId != null ? userId : "authenticated-user", + roles != null ? roles : "user", + tenantId != null ? tenantId : "default-tenant", + System.currentTimeMillis() + (cacheTimeout * 1000)); + } + + private boolean hasExistingUserContext(RequestContext request) { + return buildTokenInfoFromHeaders(request) != null; + } + + private TokenInfo fetchTokenInfoFromKeystone(String token) { + if (keystoneUri == null || keystoneUri.trim().isEmpty()) { + return null; + } + + String base = keystoneUri.endsWith("/") ? keystoneUri.substring(0, keystoneUri.length() - 1) : keystoneUri; + String validateUrl = base + "/v2.0/tokens/" + token; + HttpGet get = new HttpGet(validateUrl); + get.setHeader("Accept", "application/json"); + + String headerToken = firstNonEmpty(adminToken, token); + if (headerToken != null && !headerToken.trim().isEmpty()) { + get.setHeader(X_AUTH_TOKEN, headerToken.trim()); + } + + try (CloseableHttpResponse response = httpClient.execute(get)) { + int status = response.getStatusLine().getStatusCode(); + if (status == HttpStatus.SC_OK) { + String body = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8); + return parseKeystoneTokenInfo(body); + } + + if (status == HttpStatus.SC_UNAUTHORIZED || status == HttpStatus.SC_NOT_FOUND) { + LOG.warn("Keystone rejected token {} with status {}", token, status); + return null; + } + + LOG.warn("Unexpected response from Keystone (status {}): {}", status, response.getStatusLine().getReasonPhrase()); + } catch (IOException e) { + LOG.warn("Error validating token with Keystone: {}", e.getMessage()); + } + + return null; + } + + private TokenInfo parseKeystoneTokenInfo(String jsonBody) { + JsonElement element = JsonParser.parseString(jsonBody); + if (!element.isJsonObject()) { + return null; + } + + JsonObject root = element.getAsJsonObject(); + JsonObject access = root.has("access") ? root.getAsJsonObject("access") : root; + JsonObject tokenObj = access.has("token") ? access.getAsJsonObject("token") : root.getAsJsonObject("token"); + JsonObject userObj = access.has("user") ? access.getAsJsonObject("user") : root.getAsJsonObject("user"); + + if (tokenObj == null || userObj == null) { + return null; + } + + String tenantId = extractTenantId(tokenObj); + String userId = extractUserId(userObj); + String roles = extractRoles(userObj); + + if (userId == null) { + userId = "authenticated-user"; + } + if (tenantId == null) { + tenantId = "default-tenant"; + } + if (roles == null) { + roles = "user"; + } + + return new TokenInfo(userId, roles, tenantId, + System.currentTimeMillis() + (cacheTimeout * 1000)); + } + + private String extractTenantId(JsonObject tokenObj) { + if (tokenObj.has("tenant")) { + JsonObject tenant = tokenObj.getAsJsonObject("tenant"); + if (tenant.has("id")) { + return tenant.get("id").getAsString(); + } + } + if (tokenObj.has("tenant_id")) { + return tokenObj.get("tenant_id").getAsString(); + } + if (tokenObj.has("tenantId")) { + return tokenObj.get("tenantId").getAsString(); + } + return null; + } + + private String extractUserId(JsonObject userObj) { + if (userObj.has("name")) { + return userObj.get("name").getAsString(); + } + if (userObj.has("username")) { + return userObj.get("username").getAsString(); + } + if (userObj.has("id")) { + return userObj.get("id").getAsString(); + } + return null; + } + + private String extractRoles(JsonObject userObj) { + if (!userObj.has("roles")) { + return null; + } + JsonArray rolesArray = userObj.getAsJsonArray("roles"); + if (rolesArray.size() == 0) { + return null; + } + StringBuilder builder = new StringBuilder(); + for (JsonElement roleElement : rolesArray) { + if (!roleElement.isJsonObject()) { + continue; + } + JsonObject roleObj = roleElement.getAsJsonObject(); + if (roleObj.has("name")) { + if (builder.length() > 0) { + builder.append(','); + } + builder.append(roleObj.get("name").getAsString()); + } + } + return builder.length() > 0 ? builder.toString() : null; + } + + private TokenInfo inferFromToken(String token) { + String userId = inferUserIdFromToken(token); + String roles = inferRolesFromToken(token); + String tenantId = inferTenantFromToken(token); + return new TokenInfo(userId, roles, tenantId, + System.currentTimeMillis() + (cacheTimeout * 1000)); + } + + private String inferUserIdFromToken(String token) { + String lower = token.toLowerCase(); + if (lower.contains("identity") || lower.contains("user-admin")) { + return "identity:user-admin"; + } + if (lower.contains("service-admin") || lower.contains("cloudfeeds")) { + return "cloudfeeds_service-admin"; + } + if (lower.contains("observer")) { + return "observer-user"; + } + return "authenticated-user"; + } + + private String inferRolesFromToken(String token) { + String lower = token.toLowerCase(); + if (lower.contains("user-admin")) { + return "user-admin"; + } + if (lower.contains("service-admin") || lower.contains("cloudfeeds")) { + return "service-admin"; + } + if (lower.contains("observer")) { + return "observer"; + } + return "user"; + } + + private String inferTenantFromToken(String token) { + String lower = token.toLowerCase(); + if (lower.contains("identity")) { + return "identity"; + } + if (lower.contains("cloudfeeds")) { + return "cloudfeeds"; + } + return "default-tenant"; + } + + private String firstNonEmpty(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return null; + } + + private String getHeaderIgnoreCase(RequestContext request, String headerName) { + if (headerName == null) { + return null; + } + String value = request.getHeader(headerName); + if (value != null) { + return value; + } + value = request.getHeader(headerName.toLowerCase()); + if (value != null) { + return value; + } + return request.getHeader(headerName.toUpperCase()); + } + + private static class TokenInfo { + private final String userId; + private final String roles; + private final String tenantId; + private final long expiresAt; + + TokenInfo(String userId, String roles, String tenantId, long expiresAt) { + this.userId = userId; + this.roles = roles; + this.tenantId = tenantId; + this.expiresAt = expiresAt; + } + + String getUserId() { return userId; } + String getRoles() { return roles; } + String getTenantId() { return tenantId; } + + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } + + private static class TokenValidationResult { + private final boolean valid; + private final TokenInfo tokenInfo; + + TokenValidationResult(boolean valid, TokenInfo tokenInfo) { + this.valid = valid; + this.tokenInfo = tokenInfo; + } + + boolean isValid() { return valid; } + TokenInfo getTokenInfo() { return tokenInfo; } + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/auth/TenantAuthorizationFilter.java b/hopper/src/main/java/org/atomhopper/auth/TenantAuthorizationFilter.java new file mode 100644 index 000000000..e1b3dc9bf --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/auth/TenantAuthorizationFilter.java @@ -0,0 +1,158 @@ +package org.atomhopper.auth; + +import org.apache.abdera.protocol.server.Filter; +import org.apache.abdera.protocol.server.FilterChain; +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.ProviderHelper; +import org.apache.abdera.protocol.server.ResponseContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Authorization filter that enforces tenant-based access control + */ +public class TenantAuthorizationFilter implements Filter { + + private static final Logger LOG = LoggerFactory.getLogger(TenantAuthorizationFilter.class); + + private boolean enforceRoleBasedAccess = true; + + public void setEnforceRoleBasedAccess(boolean enforceRoleBasedAccess) { + this.enforceRoleBasedAccess = enforceRoleBasedAccess; + } + + @Override + public ResponseContext filter(RequestContext request, FilterChain chain) { + // Skip authorization for health checks and version endpoints + String path = request.getUri().getPath(); + LOG.info("TenantAuthorizationFilter: Processing request to {}", path); + List pathSegments = extractPathSegments(path); + String workspaceSegment = !pathSegments.isEmpty() ? pathSegments.get(0) : null; + String requestedTenant = resolveRequestedTenant(pathSegments); + + if (path.endsWith("/health") || path.endsWith("/buildinfo") || path.endsWith("/atommetrics")) { + LOG.info("TenantAuthorizationFilter: Skipping authorization for system endpoint"); + return chain.next(request); + } + + String userId = (String) request.getAttribute(RequestContext.Scope.REQUEST, "user.id"); + String userRoles = (String) request.getAttribute(RequestContext.Scope.REQUEST, "user.roles"); + String userTenant = (String) request.getAttribute(RequestContext.Scope.REQUEST, "user.tenant"); + + if (userId == null) { + LOG.warn("No user information found in request context"); + return createForbiddenResponse(request); + } + + Set normalizedRoles = parseRoles(userRoles); + + // Identity admin must never access identity feeds - return 403 Forbidden + if ("identity".equalsIgnoreCase(workspaceSegment) && normalizedRoles.contains("user-admin")) { + LOG.warn("Identity user-admin {} denied access to identity feed {}", userId, path); + return createForbiddenResponse(request); + } + + if (enforceRoleBasedAccess && requestedTenant != null) { + // Check tenant scope first - if user is scoped to wrong tenant, return 401 + if (userTenant == null || !requestedTenant.equalsIgnoreCase(userTenant)) { + LOG.warn(String.format("User %s attempted to access tenant %s while scoped to %s", + userId, requestedTenant, userTenant)); + return createUnauthorizedResponse(request); + } + + // Check role permissions - if user lacks proper role, return 403 + if (!hasRequiredRole(normalizedRoles)) { + LOG.warn(String.format("User %s with roles %s lacks required access to tenant %s", + userId, normalizedRoles, requestedTenant)); + return createForbiddenResponse(request); + } + } + + return chain.next(request); + } + + private boolean hasRequiredRole(Set roles) { + for (String role : roles) { + String lowerRole = role.toLowerCase(); + if (lowerRole.contains("observer") || lowerRole.contains("admin") || lowerRole.contains("service")) { + return true; + } + } + return false; + } + + private Set parseRoles(String roles) { + if (roles == null || roles.trim().isEmpty()) { + return Collections.emptySet(); + } + + Set roleSet = new HashSet<>(); + Arrays.stream(roles.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(roleSet::add); + return roleSet; + } + + private ResponseContext createForbiddenResponse(RequestContext request) { + String errorBody = "\n" + + "\n" + + " Access denied. Insufficient privileges to access this resource.\n" + + ""; + + ResponseContext response = ProviderHelper.forbidden(request, errorBody); + response.setContentType("application/xml; charset=utf-8"); + response.setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); + response.setHeader("Content-Length", String.valueOf(errorBody.getBytes().length)); + return response; + } + + private ResponseContext createUnauthorizedResponse(RequestContext request) { + String errorBody = "\n" + + "\n" + + " Invalid tenant scope for this token.\n" + + ""; + + ResponseContext response = ProviderHelper.unauthorized(request, errorBody); + response.setContentType("application/xml; charset=utf-8"); + response.setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); + response.setHeader("Content-Length", String.valueOf(errorBody.getBytes().length)); + response.setHeader("WWW-Authenticate", "Keystone uri=" + System.getProperty("keystone.uri", "https://identity.api.rackspacecloud.com")); + return response; + } + + private List extractPathSegments(String path) { + List segments = new ArrayList<>(); + if (path == null || path.isEmpty()) { + return segments; + } + String[] rawSegments = path.split("/"); + for (String segment : rawSegments) { + if (segment != null && !segment.isEmpty()) { + segments.add(segment); + } + } + return segments; + } + + private String resolveRequestedTenant(List segments) { + if (segments.isEmpty()) { + return null; + } + + for (int i = 0; i < segments.size(); i++) { + if ("entries".equalsIgnoreCase(segments.get(i)) && i > 0) { + return segments.get(i - 1); + } + } + + return segments.size() >= 3 ? segments.get(2) : null; + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/config/WorkspaceCategoriesDescriptor.java b/hopper/src/main/java/org/atomhopper/config/WorkspaceCategoriesDescriptor.java new file mode 100644 index 000000000..81475be84 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/config/WorkspaceCategoriesDescriptor.java @@ -0,0 +1,25 @@ +package org.atomhopper.config; + +import java.util.List; +import java.util.Set; +import java.util.HashSet; + +/** + * Configuration class for workspace category descriptors + */ +public class WorkspaceCategoriesDescriptor { + + private Set allowedCategories = new HashSet<>(); + + public void setAllowedCategories(List categories) { + this.allowedCategories = new HashSet<>(categories); + } + + public Set getAllowedCategories() { + return allowedCategories; + } + + public boolean isCategoryAllowed(String category) { + return allowedCategories.isEmpty() || allowedCategories.contains(category); + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/util/CachedRequestContext.java b/hopper/src/main/java/org/atomhopper/util/CachedRequestContext.java new file mode 100644 index 000000000..98756df46 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/util/CachedRequestContext.java @@ -0,0 +1,41 @@ +package org.atomhopper.util; + +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.context.RequestContextWrapper; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.nio.charset.StandardCharsets; + +/** + * RequestContext wrapper that replays a cached request body so downstream + * filters and adapters can read the content even after validation filters + * have consumed the original input stream. + */ +public class CachedRequestContext extends RequestContextWrapper { + + private final byte[] body; + + public CachedRequestContext(RequestContext request, byte[] body) { + super(request); + this.body = body != null ? body : new byte[0]; + } + + public byte[] getBody() { + return body; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(body); + } + + @Override + public Reader getReader() throws IOException { + return new InputStreamReader(getInputStream(), StandardCharsets.UTF_8); + } +} + diff --git a/hopper/src/main/java/org/atomhopper/util/RequestBodyCache.java b/hopper/src/main/java/org/atomhopper/util/RequestBodyCache.java new file mode 100644 index 000000000..ff3fdb8bb --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/util/RequestBodyCache.java @@ -0,0 +1,98 @@ +package org.atomhopper.util; + +import org.apache.abdera.protocol.server.RequestContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Utility class for caching request body content to allow multiple reads + */ +public class RequestBodyCache { + + private static final Logger LOG = LoggerFactory.getLogger(RequestBodyCache.class); + private static final String CACHED_BODY_ATTRIBUTE = "cached.request.body"; + + /** + * Buffers the request body content and returns a CachedRequestContext that allows multiple reads + * + * @param request The original RequestContext + * @return A CachedRequestContext with buffered body content that can be read multiple times + * @throws IOException if there's an error reading the request body + */ + public static RequestContext buffer(RequestContext request) throws IOException { + // Check if already a CachedRequestContext + if (request instanceof CachedRequestContext) { + return request; + } + + // Check if already buffered in attributes (for backward compatibility) + byte[] cachedBody = (byte[]) request.getAttribute(RequestContext.Scope.REQUEST, CACHED_BODY_ATTRIBUTE); + if (cachedBody != null) { + return new CachedRequestContext(request, cachedBody); + } + + // Read and cache the body + InputStream inputStream = request.getInputStream(); + if (inputStream == null) { + cachedBody = new byte[0]; + } else { + cachedBody = readInputStream(inputStream); + } + + // Store in request attributes for backward compatibility + request.setAttribute(RequestContext.Scope.REQUEST, CACHED_BODY_ATTRIBUTE, cachedBody); + + // Return a CachedRequestContext that properly overrides getInputStream() + return new CachedRequestContext(request, cachedBody); + } + + /** + * Gets the cached body content from a buffered request + * + * @param request The buffered RequestContext (should be a CachedRequestContext) + * @return The cached body content as byte array + */ + public static byte[] getBody(RequestContext request) { + // First try to get from CachedRequestContext + if (request instanceof CachedRequestContext) { + return ((CachedRequestContext) request).getBody(); + } + + // Fallback to attributes for backward compatibility + byte[] cachedBody = (byte[]) request.getAttribute(RequestContext.Scope.REQUEST, CACHED_BODY_ATTRIBUTE); + return cachedBody != null ? cachedBody : new byte[0]; + } + + private static byte[] readInputStream(InputStream inputStream) throws IOException { + if (inputStream == null) { + LOG.warn("InputStream is null, returning empty byte array"); + return new byte[0]; + } + + // Use try-with-resources to ensure proper resource management + try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { + byte[] data = new byte[8192]; + int bytesRead; + int totalBytesRead = 0; + + while ((bytesRead = inputStream.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, bytesRead); + totalBytesRead += bytesRead; + } + + if (totalBytesRead == 0) { + LOG.warn("No bytes read from InputStream, content may be empty"); + } else { + LOG.debug("Successfully read {} bytes from InputStream", totalBytesRead); + } + + return buffer.toByteArray(); + } + // Note: We don't close the original inputStream here as it's managed by the servlet container + // and may need to be available for other operations + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/util/ResponseValidator.java b/hopper/src/main/java/org/atomhopper/util/ResponseValidator.java new file mode 100644 index 000000000..1312a12b8 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/util/ResponseValidator.java @@ -0,0 +1,216 @@ +package org.atomhopper.util; + +import org.apache.abdera.protocol.server.ResponseContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * Utility class for validating response content and ensuring proper XML formatting + * and content type headers in error responses. + */ +public class ResponseValidator { + + private static final Logger LOG = LoggerFactory.getLogger(ResponseValidator.class); + private static final String XML_CONTENT_TYPE = "application/xml"; + private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY; + + static { + DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); + DOCUMENT_BUILDER_FACTORY.setNamespaceAware(true); + // Disable external entity processing for security + try { + DOCUMENT_BUILDER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException e) { + LOG.warn("Could not configure XML security features: {}", e.getMessage()); + } + } + + /** + * Validates that a ResponseContext has the correct XML content type + * + * @param response The ResponseContext to validate + * @return ValidationResult containing validation status and details + */ + public static ValidationResult validateXmlContentType(ResponseContext response) { + if (response == null) { + return ValidationResult.failure("Response is null"); + } + + String contentType = response.getContentType() != null ? + response.getContentType().toString() : null; + + if (contentType == null) { + return ValidationResult.failure("Content-Type header is missing"); + } + + if (!contentType.startsWith(XML_CONTENT_TYPE)) { + return ValidationResult.failure( + String.format("Expected Content-Type to start with '%s', but was '%s'", + XML_CONTENT_TYPE, contentType)); + } + + return ValidationResult.success("Content-Type is valid XML"); + } + + /** + * Validates that response body is well-formed XML + * + * @param xmlContent The XML content to validate + * @return ValidationResult containing validation status and details + */ + public static ValidationResult validateXmlWellFormedness(String xmlContent) { + if (xmlContent == null || xmlContent.trim().isEmpty()) { + return ValidationResult.failure("XML content is null or empty"); + } + + try { + DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + try (ByteArrayInputStream xmlStream = new ByteArrayInputStream(xmlContent.getBytes("UTF-8"))) { + builder.parse(xmlStream); + } + return ValidationResult.success("XML is well-formed"); + } catch (ParserConfigurationException e) { + LOG.error("Parser configuration error", e); + return ValidationResult.failure("Parser configuration error: " + e.getMessage()); + } catch (SAXException e) { + LOG.warn("XML parsing error: {}", e.getMessage()); + return ValidationResult.failure("XML is not well-formed: " + e.getMessage()); + } catch (IOException e) { + LOG.error("IO error while parsing XML", e); + return ValidationResult.failure("IO error while parsing XML: " + e.getMessage()); + } + } + + /** + * Validates that response body is well-formed XML from InputStream + * + * @param xmlStream The XML InputStream to validate + * @return ValidationResult containing validation status and details + */ + public static ValidationResult validateXmlWellFormedness(InputStream xmlStream) { + if (xmlStream == null) { + return ValidationResult.failure("XML stream is null"); + } + + try { + DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + builder.parse(xmlStream); + return ValidationResult.success("XML is well-formed"); + } catch (ParserConfigurationException e) { + LOG.error("Parser configuration error", e); + return ValidationResult.failure("Parser configuration error: " + e.getMessage()); + } catch (SAXException e) { + LOG.warn("XML parsing error: {}", e.getMessage()); + return ValidationResult.failure("XML is not well-formed: " + e.getMessage()); + } catch (IOException e) { + LOG.error("IO error while parsing XML", e); + return ValidationResult.failure("IO error while parsing XML: " + e.getMessage()); + } + } + + /** + * Comprehensive validation of error response + * + * @param response The ResponseContext to validate + * @param expectedStatusCode The expected HTTP status code + * @return ValidationResult containing validation status and details + */ + public static ValidationResult validateErrorResponse(ResponseContext response, int expectedStatusCode) { + if (response == null) { + return ValidationResult.failure("Response is null"); + } + + // Validate status code + if (response.getStatus() != expectedStatusCode) { + return ValidationResult.failure( + String.format("Expected status code %d, but was %d", + expectedStatusCode, response.getStatus())); + } + + // Validate content type + ValidationResult contentTypeResult = validateXmlContentType(response); + if (!contentTypeResult.isValid()) { + return contentTypeResult; + } + + // Note: ResponseContext doesn't provide direct access to response body stream + // XML validation would need to be done at a higher level where the response body is available + + return ValidationResult.success("Error response is valid"); + } + + /** + * Checks if the response indicates a server error (5xx status codes) + * + * @param response The ResponseContext to check + * @return true if response is a server error, false otherwise + */ + public static boolean isServerError(ResponseContext response) { + return response != null && response.getStatus() >= 500 && response.getStatus() < 600; + } + + /** + * Checks if the response indicates a client error (4xx status codes) + * + * @param response The ResponseContext to check + * @return true if response is a client error, false otherwise + */ + public static boolean isClientError(ResponseContext response) { + return response != null && response.getStatus() >= 400 && response.getStatus() < 500; + } + + /** + * Checks if the response indicates an error (4xx or 5xx status codes) + * + * @param response The ResponseContext to check + * @return true if response is an error, false otherwise + */ + public static boolean isError(ResponseContext response) { + return isClientError(response) || isServerError(response); + } + + /** + * Result class for validation operations + */ + public static class ValidationResult { + private final boolean valid; + private final String message; + + private ValidationResult(boolean valid, String message) { + this.valid = valid; + this.message = message; + } + + public static ValidationResult success(String message) { + return new ValidationResult(true, message); + } + + public static ValidationResult failure(String message) { + return new ValidationResult(false, message); + } + + public boolean isValid() { + return valid; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return String.format("ValidationResult{valid=%s, message='%s'}", valid, message); + } + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/validation/CategoryValidationFilter.java b/hopper/src/main/java/org/atomhopper/validation/CategoryValidationFilter.java new file mode 100644 index 000000000..2fbd1bb28 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/validation/CategoryValidationFilter.java @@ -0,0 +1,146 @@ +package org.atomhopper.validation; + +import org.apache.abdera.protocol.server.Filter; +import org.apache.abdera.protocol.server.FilterChain; +import org.apache.abdera.protocol.server.ProviderHelper; +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.ResponseContext; +import org.atomhopper.util.RequestBodyCache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * Filter that validates category elements in Atom entries + */ +public class CategoryValidationFilter implements Filter { + + private static final Logger LOG = LoggerFactory.getLogger(CategoryValidationFilter.class); + private static final int MAX_CATEGORY_TERM_LENGTH = 256; + + // Predefined categories that are not allowed in certain feeds + private static final Set RESTRICTED_CATEGORIES = new HashSet<>(Arrays.asList( + "system", "internal", "admin", "restricted" + )); + + @Override + public ResponseContext filter(RequestContext request, FilterChain chain) { + // Only validate POST and PUT requests with Atom content + String method = request.getMethod(); + if (!"POST".equals(method) && !"PUT".equals(method)) { + return chain.next(request); + } + + String contentType = request.getContentType() != null ? + request.getContentType().toString() : ""; + String normalizedContentType = contentType.toLowerCase(); + + if (!normalizedContentType.contains("application/atom+xml") && + !normalizedContentType.contains("application/xml")) { + return chain.next(request); + } + + try { + RequestContext bufferedRequest = RequestBodyCache.buffer(request); + byte[] content = RequestBodyCache.getBody(bufferedRequest); + + // Check for empty or null content - skip validation but continue processing + if (content == null || content.length == 0) { + return chain.next(bufferedRequest); + } + + // Check for whitespace-only content + String contentStr = new String(content, "UTF-8").trim(); + if (contentStr.isEmpty()) { + return chain.next(bufferedRequest); + } + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + DocumentBuilder builder = factory.newDocumentBuilder(); + + // Use try-with-resources to ensure proper stream closing + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content)) { + // Ensure the stream has content before parsing + if (inputStream.available() == 0) { + LOG.warn("XML content stream is empty, skipping category validation"); + return chain.next(bufferedRequest); + } + + Document doc = builder.parse(inputStream); + + ResponseContext validationResult = validateCategories(doc, bufferedRequest); + if (validationResult != null) { + return validationResult; + } + + return chain.next(bufferedRequest); + } + + } catch (Exception e) { + LOG.error("Error validating categories", e); + // Let the request continue - validation errors will be caught by content validation + return chain.next(request); + } + } + + private ResponseContext validateCategories(Document doc, RequestContext request) { + // Check for multiple title elements (only one allowed) + NodeList titles = doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "title"); + if (titles.getLength() > 1) { + return createBadRequestResponse(request, "Only one atom:title node is allowed per entry"); + } + + NodeList categories = doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "category"); + + for (int i = 0; i < categories.getLength(); i++) { + Element category = (Element) categories.item(i); + String term = category.getAttribute("term"); + + // Validate term length + if (term != null && term.length() > MAX_CATEGORY_TERM_LENGTH) { + return createBadRequestResponse(request, + String.format("Category term exceeds maximum length of %d characters", MAX_CATEGORY_TERM_LENGTH)); + } + + // Check for restricted categories in functional test feeds + String path = request.getUri().getPath(); + if (term != null && path.contains("functional") && RESTRICTED_CATEGORIES.contains(term.toLowerCase())) { + return createBadRequestResponse(request, + String.format("Category term '%s' is not allowed in this feed", term)); + } + } + + return null; // No validation errors + } + + private ResponseContext createBadRequestResponse(RequestContext request, String message) { + String xmlBody = "\n" + + "\n" + + " " + escapeXml(message) + "\n" + + ""; + ResponseContext response = ProviderHelper.badrequest(request, xmlBody); + response.setContentType("application/xml; charset=utf-8"); + response.setHeader("Content-Length", String.valueOf(xmlBody.getBytes().length)); + response.setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); + return response; + } + + private String escapeXml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} \ No newline at end of file diff --git a/hopper/src/main/java/org/atomhopper/validation/ContentValidationFilter.java b/hopper/src/main/java/org/atomhopper/validation/ContentValidationFilter.java new file mode 100644 index 000000000..e4582a4c2 --- /dev/null +++ b/hopper/src/main/java/org/atomhopper/validation/ContentValidationFilter.java @@ -0,0 +1,197 @@ +package org.atomhopper.validation; + +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import org.apache.abdera.protocol.server.Filter; +import org.apache.abdera.protocol.server.FilterChain; +import org.apache.abdera.protocol.server.ProviderHelper; +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.ResponseContext; +import org.atomhopper.util.RequestBodyCache; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.xml.sax.SAXException; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import java.io.ByteArrayInputStream; +import java.io.IOException; + +/** + * Filter that validates request content for proper format and structure + */ +public class ContentValidationFilter implements Filter { + + private static final Logger LOG = LoggerFactory.getLogger(ContentValidationFilter.class); + private static final DocumentBuilderFactory DOCUMENT_BUILDER_FACTORY; + + static { + DOCUMENT_BUILDER_FACTORY = DocumentBuilderFactory.newInstance(); + DOCUMENT_BUILDER_FACTORY.setNamespaceAware(true); + // Security settings + try { + DOCUMENT_BUILDER_FACTORY.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-general-entities", false); + DOCUMENT_BUILDER_FACTORY.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + } catch (ParserConfigurationException e) { + LOG.warn("Could not configure XML security features: {}", e.getMessage()); + } + } + + @Override + public ResponseContext filter(RequestContext request, FilterChain chain) { + // Only validate POST and PUT requests with content + String method = request.getMethod(); + if (!"POST".equals(method) && !"PUT".equals(method)) { + return chain.next(request); + } + + String contentType = request.getContentType() != null ? + request.getContentType().toString() : ""; + String normalizedContentType = contentType.toLowerCase(); + + // Check for obvious content type mismatches + if (contentType.isEmpty()) { + return createBadRequestResponse(request, "Content-Type header is required for POST/PUT requests"); + } + + try { + // Buffer the request first to enable multiple reads + RequestContext bufferedRequest = RequestBodyCache.buffer(request); + + // Validate based on content type + if (normalizedContentType.contains("application/json")) { + return validateJsonContent(bufferedRequest, chain); + } else if (normalizedContentType.contains("application/xml") || + normalizedContentType.contains("application/atom+xml")) { + return validateXmlContent(bufferedRequest, chain); + } + + // For other content types, let the buffered request continue + return chain.next(bufferedRequest); + } catch (IOException e) { + LOG.error("Error buffering request body", e); + return createBadRequestResponse(request, "Error reading request content"); + } + } + + private ResponseContext validateXmlContent(RequestContext bufferedRequest, FilterChain chain) { + try { + byte[] content = RequestBodyCache.getBody(bufferedRequest); + + // Check for empty or null content + if (content == null || content.length == 0) { + return createBadRequestResponse(bufferedRequest, "Request body is empty"); + } + + // Check for whitespace-only content + String contentStr = new String(content, "UTF-8").trim(); + if (contentStr.isEmpty()) { + return createBadRequestResponse(bufferedRequest, "Request body contains only whitespace"); + } + + // Validate XML well-formedness with proper error handling + DocumentBuilder builder = DOCUMENT_BUILDER_FACTORY.newDocumentBuilder(); + + // Use try-with-resources to ensure proper stream closing + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(content)) { + // Ensure the stream has content before parsing + if (inputStream.available() == 0) { + return createBadRequestResponse(bufferedRequest, "XML content stream is empty"); + } + + Document doc = builder.parse(inputStream); + + // Additional Atom-specific validations + String rootElement = doc.getDocumentElement().getLocalName(); + if (!"entry".equals(rootElement) && !"feed".equals(rootElement)) { + return createBadRequestResponse(bufferedRequest, "Invalid Atom document. Root element must be 'entry' or 'feed'"); + } + + // Validate required Atom elements + if ("entry".equals(rootElement)) { + if (!hasRequiredAtomElements(doc)) { + return createBadRequestResponse(bufferedRequest, "Invalid Atom entry. Missing required elements"); + } + } + + return chain.next(bufferedRequest); + } + } catch (ParserConfigurationException e) { + LOG.error("XML parser configuration error", e); + return createBadRequestResponse(bufferedRequest, "XML parser configuration error"); + } catch (SAXException e) { + LOG.warn("Invalid XML content: {}", e.getMessage()); + return createBadRequestResponse(bufferedRequest, "Invalid XML: " + e.getMessage()); + } catch (IOException e) { + LOG.error("Error reading request content", e); + return createBadRequestResponse(bufferedRequest, "Error reading request content"); + } + } + + private ResponseContext validateJsonContent(RequestContext bufferedRequest, FilterChain chain) { + try { + byte[] content = RequestBodyCache.getBody(bufferedRequest); + if (content.length == 0) { + return createBadRequestResponse(bufferedRequest, "Request body is empty"); + } + + String jsonContent = new String(content, "UTF-8"); + + // Validate JSON syntax + if (!isValidJsonSyntax(jsonContent)) { + return createBadRequestResponse(bufferedRequest, "Invalid JSON syntax"); + } + + return chain.next(bufferedRequest); + } catch (Exception e) { + LOG.error("Error reading JSON content", e); + return createBadRequestResponse(bufferedRequest, "Error reading request content"); + } + } + + private boolean hasRequiredAtomElements(Document doc) { + // Check for required Atom entry elements + return doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "title").getLength() > 0 && + doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "id").getLength() > 0 && + doc.getElementsByTagNameNS("http://www.w3.org/2005/Atom", "updated").getLength() > 0; + } + + private boolean isValidJsonSyntax(String json) { + String payload = json == null ? "" : json.trim(); + if (payload.isEmpty()) { + return false; + } + + try { + JsonParser.parseString(payload); + return true; + } catch (JsonSyntaxException ex) { + LOG.warn("Invalid JSON payload: {}", ex.getMessage()); + return false; + } + } + + private ResponseContext createBadRequestResponse(RequestContext request, String message) { + String xmlBody = "\n" + + "\n" + + " " + escapeXml(message) + "\n" + + ""; + ResponseContext response = ProviderHelper.badrequest(request, xmlBody); + response.setContentType("application/xml; charset=utf-8"); + response.setHeader("Content-Length", String.valueOf(xmlBody.getBytes().length)); + response.setHeader("Cache-Control", "must-revalidate,no-cache,no-store"); + return response; + } + + private String escapeXml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } +} \ No newline at end of file diff --git a/hopper/src/test/java/org/atomhopper/abdera/WorkspaceProviderUrlGenerationTest.java b/hopper/src/test/java/org/atomhopper/abdera/WorkspaceProviderUrlGenerationTest.java new file mode 100644 index 000000000..ef8752403 --- /dev/null +++ b/hopper/src/test/java/org/atomhopper/abdera/WorkspaceProviderUrlGenerationTest.java @@ -0,0 +1,151 @@ +package org.atomhopper.abdera; + +import org.apache.abdera.i18n.iri.IRI; +import org.apache.abdera.protocol.server.RequestContext; +import org.apache.abdera.protocol.server.Target; +import org.apache.abdera.protocol.server.TargetType; +import org.atomhopper.config.v1_0.HostConfiguration; +import org.atomhopper.util.uri.template.EnumKeyedTemplateParameters; +import org.atomhopper.util.uri.template.URITemplate; +import org.atomhopper.util.uri.template.URITemplateParameter; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static junit.framework.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class WorkspaceProviderUrlGenerationTest { + + private WorkspaceProvider workspaceProvider; + private RequestContext mockRequestContext; + private HostConfiguration hostConfiguration; + private Target mockTarget; + + @Before + public void setup() { + hostConfiguration = new HostConfiguration(); + hostConfiguration.setDomain("fallback.example.com"); + hostConfiguration.setScheme("http"); + + workspaceProvider = new WorkspaceProvider(hostConfiguration); + mockRequestContext = mock(RequestContext.class); + mockTarget = mock(Target.class); + + // Mock basic request context behavior + when(mockRequestContext.getTarget()).thenReturn(mockTarget); + when(mockRequestContext.getUri()).thenReturn(new IRI("http://test.example.com/path")); + when(mockRequestContext.getTargetBasePath()).thenReturn("/root/context"); + + // Mock target behavior + when(mockTarget.getType()).thenReturn(TargetType.TYPE_COLLECTION); + when(mockTarget.getParameter("workspace")).thenReturn("testworkspace"); + when(mockTarget.getParameter("feed")).thenReturn("testfeed"); + } + + @After + public void cleanup() { + // Clear environment variables after each test + System.clearProperty("AH_DOMAIN_MODE"); + System.clearProperty("AH_EXTERNAL_DOMAIN"); + System.clearProperty("AH_EXTERNAL_SCHEME"); + } + + @Test + public void shouldUseFallbackDomainWhenModeIsExternal() { + // Set environment for external mode + System.setProperty("AH_DOMAIN_MODE", "external"); + + EnumKeyedTemplateParameters params = new EnumKeyedTemplateParameters<>(URITemplate.FEED); + params.set(URITemplateParameter.WORKSPACE_RESOURCE, "testworkspace"); + params.set(URITemplateParameter.FEED_RESOURCE, "testfeed"); + + String url = workspaceProvider.urlFor(mockRequestContext, URITemplate.FEED, params); + + assertTrue("URL should contain fallback domain", url.contains("fallback.example.com")); + assertTrue("URL should use fallback scheme", url.startsWith("http://")); + } + + @Test + public void shouldUseRequestHostHeaderWhenModeIsRequestBased() { + // Set environment for request-based mode + System.setProperty("AH_DOMAIN_MODE", "request-based"); + System.setProperty("AH_EXTERNAL_DOMAIN", "external.example.com"); + System.setProperty("AH_EXTERNAL_SCHEME", "https"); + + // Mock Host header + when(mockRequestContext.getHeader("Host")).thenReturn("internal.cloudfeeds.local:8080"); + + EnumKeyedTemplateParameters params = new EnumKeyedTemplateParameters<>(URITemplate.FEED); + params.set(URITemplateParameter.WORKSPACE_RESOURCE, "testworkspace"); + params.set(URITemplateParameter.FEED_RESOURCE, "testfeed"); + + String url = workspaceProvider.urlFor(mockRequestContext, URITemplate.FEED, params); + + assertTrue("URL should contain request Host header domain", url.contains("internal.cloudfeeds.local") && (url.contains(":8080") || url.contains("%3A8080"))); + } + + @Test + public void shouldUseXForwardedProtoHeaderForScheme() { + // Set environment for request-based mode + System.setProperty("AH_DOMAIN_MODE", "request-based"); + System.setProperty("AH_EXTERNAL_DOMAIN", "external.example.com"); + System.setProperty("AH_EXTERNAL_SCHEME", "http"); + + // Mock headers + when(mockRequestContext.getHeader("Host")).thenReturn("api.example.com"); + when(mockRequestContext.getHeader("X-Forwarded-Proto")).thenReturn("https"); + + EnumKeyedTemplateParameters params = new EnumKeyedTemplateParameters<>(URITemplate.FEED); + params.set(URITemplateParameter.WORKSPACE_RESOURCE, "testworkspace"); + params.set(URITemplateParameter.FEED_RESOURCE, "testfeed"); + + String url = workspaceProvider.urlFor(mockRequestContext, URITemplate.FEED, params); + + assertTrue("URL should use X-Forwarded-Proto scheme", url.startsWith("https://")); + assertTrue("URL should contain request Host header domain", url.contains("api.example.com")); + } + + @Test + public void shouldFallbackToExternalDomainWhenHostHeaderMissing() { + // Set environment for request-based mode + System.setProperty("AH_DOMAIN_MODE", "request-based"); + System.setProperty("AH_EXTERNAL_DOMAIN", "external.example.com"); + System.setProperty("AH_EXTERNAL_SCHEME", "https"); + + // No Host header + when(mockRequestContext.getHeader("Host")).thenReturn(null); + + EnumKeyedTemplateParameters params = new EnumKeyedTemplateParameters<>(URITemplate.FEED); + params.set(URITemplateParameter.WORKSPACE_RESOURCE, "testworkspace"); + params.set(URITemplateParameter.FEED_RESOURCE, "testfeed"); + + String url = workspaceProvider.urlFor(mockRequestContext, URITemplate.FEED, params); + + assertTrue("URL should contain external domain fallback", url.contains("external.example.com")); + assertTrue("URL should use external scheme fallback", url.startsWith("https://")); + } + + @Test + public void shouldUseRequestUriScheme() { + // Set environment for request-based mode + System.setProperty("AH_DOMAIN_MODE", "request-based"); + System.setProperty("AH_EXTERNAL_DOMAIN", "external.example.com"); + System.setProperty("AH_EXTERNAL_SCHEME", "http"); + + // Mock request with HTTPS URI + when(mockRequestContext.getHeader("Host")).thenReturn("secure.example.com"); + when(mockRequestContext.getHeader("X-Forwarded-Proto")).thenReturn(null); + when(mockRequestContext.getUri()).thenReturn(new IRI("https://secure.example.com/path")); + + EnumKeyedTemplateParameters params = new EnumKeyedTemplateParameters<>(URITemplate.FEED); + params.set(URITemplateParameter.WORKSPACE_RESOURCE, "testworkspace"); + params.set(URITemplateParameter.FEED_RESOURCE, "testfeed"); + + String url = workspaceProvider.urlFor(mockRequestContext, URITemplate.FEED, params); + + assertTrue("URL should use https from request URI", url.startsWith("https://")); + assertTrue("URL should contain request Host header domain", url.contains("secure.example.com")); + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index b3de5f276..59f3e1930 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent pom - 1.2.35-SNAPSHOT + 1.2.45 ATOM Hopper - ATOMpub Server Collection http://atomhopper.org/ @@ -46,8 +46,10 @@ scm:git:ssh://git@github.com/rackerlabs/atom-hopper.git - parent-1.2.32 - + scm:git:ssh://git@github.com/rackerlabs/atom-hopper.git + https://github.com/rackerlabs/atom-hopper + parent-1.2.45 + @@ -388,15 +390,15 @@ - releases.maven.research.rackspace.com - - Rackspace Research Releases - https://maven.research.rackspacecloud.com/content/repositories/releases + github + GitHub Packages + https://maven.pkg.github.com/rackerlabs/atom-hopper + - snapshots.maven.research.rackspace.com - - Rackspace Research Snapshots - https://maven.research.rackspacecloud.com/content/repositories/snapshots + github + GitHub Packages + https://maven.pkg.github.com/rackerlabs/atom-hopper + @@ -422,7 +424,7 @@ org.apache.maven.plugins maven-surefire-plugin - 2.7.2 + 2.16 @@ -448,7 +450,57 @@ maven-shade-plugin 1.4 + + + org.apache.maven.plugins + maven-source-plugin + 3.2.1 + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.4.1 + + + + org.apache.maven.plugins + maven-deploy-plugin + 3.0.0 + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + + 8 + none + + + + attach-javadocs + + jar + + + + + diff --git a/server/pom.xml b/server/pom.xml index 5d19438e6..c19de986b 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper diff --git a/server/src/main/java/org/atomhopper/server/MonitorThread.java b/server/src/main/java/org/atomhopper/server/MonitorThread.java index 9898350d8..ee56a33dd 100644 --- a/server/src/main/java/org/atomhopper/server/MonitorThread.java +++ b/server/src/main/java/org/atomhopper/server/MonitorThread.java @@ -38,11 +38,12 @@ public void run() { try { accept = socket.accept(); - BufferedReader reader = new BufferedReader(new InputStreamReader(accept.getInputStream())); - reader.readLine(); - LOG.info("Stopping Atom Hopper..."); - serverInstance.stop(); - LOG.info("Atom Hopper has been stopped"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(accept.getInputStream()))) { + reader.readLine(); + LOG.info("Stopping Atom Hopper..."); + serverInstance.stop(); + LOG.info("Atom Hopper has been stopped"); + } accept.close(); socket.close(); } catch (Exception e) { diff --git a/server/src/main/resources/META-INF/application-context.xml b/server/src/main/resources/META-INF/application-context.xml index 00c9c7386..a393a2689 100644 --- a/server/src/main/resources/META-INF/application-context.xml +++ b/server/src/main/resources/META-INF/application-context.xml @@ -11,6 +11,42 @@ http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + compute + storage + network + identity + monitoring + + + + diff --git a/server/src/main/resources/META-INF/atom-server.cfg.xml b/server/src/main/resources/META-INF/atom-server.cfg.xml index f304062d7..d547b8e69 100644 --- a/server/src/main/resources/META-INF/atom-server.cfg.xml +++ b/server/src/main/resources/META-INF/atom-server.cfg.xml @@ -7,6 +7,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -15,4 +49,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/test-suite/pom.xml b/test-suite/pom.xml index 0576600f1..8a3220c67 100644 --- a/test-suite/pom.xml +++ b/test-suite/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper diff --git a/test-util/pom.xml b/test-util/pom.xml index 4526ead61..bb6ca07ba 100644 --- a/test-util/pom.xml +++ b/test-util/pom.xml @@ -5,7 +5,7 @@ org.atomhopper parent - 1.2.35-SNAPSHOT + 1.2.45 org.atomhopper @@ -32,6 +32,13 @@ org.apache.maven.plugins maven-compiler-plugin + + org.apache.maven.plugins + maven-surefire-plugin + + false + +