diff --git a/README.md b/README.md index 8ad243450..ce73499ba 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ docker run --network database_net \ -e DB_URL=jdbc:postgres:db:5324/dcs \ -e DB_USERNAME=dcs_app \ -e DB_PASSWORD=dcs_app_password \ - -e DB_CONNECTION_INIT="select 1" \ -e DB_VALIDATION_QUERY="select 1" \ -e DB_MAX_CONNECTIONS=10 \ -e DB_MAX_IDLE=5 \ @@ -128,7 +127,6 @@ docker run --network database_net \ -e DB_URL=jdbc:oracle:thin:@//cwmsdb:1521/CWMSTEST \ -e DB_USERNAME=ccp_app \ -e DB_PASSWORD=ccp \ - -e DB_CONNECTION_INIT="BEGIN cwms_ccp_vpd.set_ccp_session_ctx(null, null, 'SPK' ); END;" \ -e DB_VALIDATION_QUERY="select 1 from dual" \ -e DB_MAX_CONNECTIONS=10 \ -e DB_MAX_IDLE=5 \ diff --git a/docker-compose.yaml b/docker-compose.yaml index ccfbeddde..c11f9de1d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -44,7 +44,6 @@ services: - DB_DRIVER_CLASS=org.postgresql.Driver - DB_USERNAME=app - DB_PASSWORD=app_pass - - DB_CONNECTION_INIT=SELECT 1 - DB_VALIDATION_QUERY=SELECT 1 - DB_URL=jdbc:postgresql://db:5432/dcs - DB_MAX_CONNECTIONS=10 diff --git a/docker_files/tomcat/conf/context.xml b/docker_files/tomcat/conf/context.xml index 94c5a780c..7dda5326a 100644 --- a/docker_files/tomcat/conf/context.xml +++ b/docker_files/tomcat/conf/context.xml @@ -22,7 +22,6 @@ username="${DB_USERNAME}" password="${DB_PASSWORD}" driverClassName="${DB_DRIVER_CLASS}" - initSQL="${DB_CONNECTION_INIT}" logAbandoned="true" url="${DB_URL}" logValidationErrors="true" diff --git a/gradle.properties.example b/gradle.properties.example index 2f741ea11..1bd573fc2 100644 --- a/gradle.properties.example +++ b/gradle.properties.example @@ -3,7 +3,6 @@ #DCSTOOL_USERDIR= ###Point to existing Postgres OpenTS database -#postgresdb.url= #opendcs.db.username= #opendcs.db.password= diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37f1d1f58..bc0d6690e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] -opendcs = "7.5.1-RC17" +opendcs = "7.5.1-RC18" -servlet-api = "6.1.0" #Updating this further will require a change to the jakarta namespace. +servlet-api = "6.1.0" slf4j = { strictly = "2.0.17" } jersey = "4.0.0" @@ -40,6 +40,8 @@ opendcs-api = { module = "org.opendcs:opendcs-api", version.ref = "opendcs" } opendcs-schemas = { module = "org.opendcs:opendcs-schemas", version.ref = "opendcs" } opendcs-schemas-cwms = { module = "org.opendcs:opendcs-schemas-cwms-oracle", version.ref = "opendcs" } +jdbi = { module = "org.jdbi:jdbi3-core", version = "3.39.1" } + servlet-api = { module = "jakarta.servlet:jakarta.servlet-api", version.ref = "servlet-api" } rs-api = { module = "jakarta.ws.rs:jakarta.ws.rs-api", version.ref="rs-api" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/opendcs-integration-test/src/main/java/org/opendcs/odcsapi/fixtures/TomcatServer.java b/opendcs-integration-test/src/main/java/org/opendcs/odcsapi/fixtures/TomcatServer.java index 56bdd8c98..fc9a3fbd4 100644 --- a/opendcs-integration-test/src/main/java/org/opendcs/odcsapi/fixtures/TomcatServer.java +++ b/opendcs-integration-test/src/main/java/org/opendcs/odcsapi/fixtures/TomcatServer.java @@ -56,10 +56,8 @@ public final class TomcatServer implements AutoCloseable { private static final Logger log = OpenDcsLoggerFactory.getLogger(); - public static final String DB_OFFICE = "DB_OFFICE"; public static final String DB_DRIVER_CLASS = "DB_DRIVER_CLASS"; public static final String DB_DATASOURCE_CLASS = "DB_DATASOURCE_CLASS"; - public static final String DB_CONNECTION_INIT = "DB_CONNECTION_INIT"; public static final String DB_VALIDATION_QUERY = "DB_VALIDATION_QUERY"; public static final String DB_URL = "DB_URL"; public static final String DB_USERNAME = "DB_USERNAME"; @@ -196,10 +194,6 @@ private static void setupDbForBypass(DbType dbType) { System.setProperty(DB_DRIVER_CLASS, "oracle.jdbc.driver.OracleDriver"); System.setProperty(DB_DATASOURCE_CLASS, DataSourceFactory.class.getName()); - String dbOffice = System.getProperty("testcontainer.cwms.bypass.office.id"); - String initScript = String.format("BEGIN cwms_ccp_vpd.set_ccp_session_ctx(cwms_util.get_office_code('%s'), 2, '%s' ); END;", dbOffice, dbOffice); - System.setProperty(DB_OFFICE, dbOffice); - System.setProperty(DB_CONNECTION_INIT, initScript); System.setProperty(DB_VALIDATION_QUERY, "SELECT 1 FROM dual"); System.setProperty(DB_URL, System.getProperty("testcontainer.cwms.bypass.url")); System.setProperty(DB_USERNAME, System.getProperty("testcontainer.cwms.bypass.office.eroc") + "WEBTEST"); @@ -210,7 +204,6 @@ private static void setupDbForBypass(DbType dbType) String validationQuery = "SELECT 1"; System.setProperty(DB_DRIVER_CLASS, "org.postgresql.Driver"); System.setProperty(DB_DATASOURCE_CLASS, DataSourceFactory.class.getName()); - System.setProperty(DB_CONNECTION_INIT, validationQuery); System.setProperty(DB_VALIDATION_QUERY, validationQuery); System.setProperty(DB_URL, System.getProperty("testcontainer.opentsdb.bypass.url")); System.setProperty(DB_USERNAME, System.getProperty("testcontainer.opentsdb.bypass.username")); @@ -265,14 +258,13 @@ private static void startDbContainer(Configuration config, DbType dbType) throws for(var k: props.keySet()) { final String value = props.getProperty(k.toString(), null); - if (k != null && value != null) + if (value != null && !value.isBlank()) { stmt.setString(1, k.toString()); stmt.setString(2, value); stmt.executeUpdate(); } } - //stmt.executeBatch(); } catch (Throwable ex) { @@ -285,9 +277,6 @@ private static void startDbContainer(Configuration config, DbType dbType) throws { System.setProperty(DB_DRIVER_CLASS, "oracle.jdbc.driver.OracleDriver"); System.setProperty(DB_DATASOURCE_CLASS, "org.apache.tomcat.jdbc.pool.DataSourceFactory"); - String dbOffice = System.getProperty(DB_OFFICE); - String initScript = String.format("BEGIN cwms_ccp_vpd.set_ccp_session_ctx(cwms_util.get_office_code('%s'), 2, '%s' ); END;", dbOffice, dbOffice); - System.setProperty(DB_CONNECTION_INIT, initScript); System.setProperty(DB_VALIDATION_QUERY, "SELECT 1 FROM dual"); } else @@ -295,7 +284,6 @@ private static void startDbContainer(Configuration config, DbType dbType) throws String validationQuery = "SELECT 1"; System.setProperty(DB_DRIVER_CLASS, "org.postgresql.Driver"); System.setProperty(DB_DATASOURCE_CLASS, "org.apache.tomcat.jdbc.pool.DataSourceFactory"); - System.setProperty(DB_CONNECTION_INIT, validationQuery); System.setProperty(DB_VALIDATION_QUERY, validationQuery); } setupClientUser(dbType); @@ -308,32 +296,24 @@ private static void setupClientUser(DbType dbType) { // I have no idea why this is suddenly required but it was also affecting operations in // runtime test environments where the required entries weren't present. - String unlockUser = "begin cwms_sec.unlock_user(?,?); end;"; String userPermissions = "begin execute immediate 'grant web_user to ' || ?; end;"; - String dbOffice = System.getProperty(DB_OFFICE); - String setWebUserPermissions = "begin\n" + - " cwms_sec.add_user_to_group(?, 'CWMS User Admins',?) ;\n" + - " commit;\n" + - "end;"; + String userPermissions2 = "begin cwms_sec.ADD_USER_TO_GROUP(?, 'CWMS PD Users', 'HQ'); end;"; try(Connection connection = DriverManager.getConnection(System.getProperty(DB_URL), "CWMS_20", - System.getProperty(DB_PASSWORD)); - PreparedStatement unlockUserStmt = connection.prepareStatement(unlockUser); - PreparedStatement userPermissionsStmt = connection.prepareStatement(userPermissions); - PreparedStatement setWebUserPermissionsStmt = connection.prepareStatement(setWebUserPermissions)) + System.getProperty(DB_PASSWORD))) { - final String username = System.getProperty(DB_USERNAME); - unlockUserStmt.setString(1, username); - unlockUserStmt.setString(2, dbOffice); - unlockUserStmt.executeQuery(); - userPermissionsStmt.setString(1, username); - userPermissionsStmt.executeQuery(); - setWebUserPermissionsStmt.setString(1, username); - setWebUserPermissionsStmt.setString(2, dbOffice); - setWebUserPermissionsStmt.executeQuery(); + try(PreparedStatement stmt1 = connection.prepareStatement(userPermissions); + PreparedStatement stmt2 = connection.prepareStatement(userPermissions2)) + { + String username = System.getProperty(DB_USERNAME); + stmt1.setString(1, username); + stmt1.executeQuery(); + stmt2.setString(1, username); + stmt2.executeQuery(); + } } catch(SQLException ex) { - log.atDebug().setCause(ex).log("Error setting up client user"); + log.atDebug().setCause(ex).log("Error setting up web user"); } } } diff --git a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/fixtures/DatabaseSetupExtension.java b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/fixtures/DatabaseSetupExtension.java index db340ee2b..b63c3fc01 100644 --- a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/fixtures/DatabaseSetupExtension.java +++ b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/fixtures/DatabaseSetupExtension.java @@ -20,6 +20,8 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.Objects; + +import decodes.util.DecodesSettings; import jakarta.servlet.http.HttpServletResponse; import io.restassured.RestAssured; @@ -63,6 +65,18 @@ public static TomcatServer getCurrentTomcat() return currentTomcat; } + public static String getOrganization() + { + try + { + return getCurrentConfig().getOpenDcsDatabase().getSettings(DecodesSettings.class).orElseThrow().CwmsOfficeId; + } + catch(Throwable e) + { + throw new IllegalStateException(e); + } + } + @Override public void beforeEach(ExtensionContext context) throws Exception { @@ -107,7 +121,7 @@ private static void healthCheck() throws InterruptedException log.debug("Server is up!"); break; } - catch(Throwable ex) + catch(AssertionError ex) { log.atDebug().setCause(ex).log("Waiting for the server to start..."); Thread.sleep(100);//NOSONAR @@ -125,10 +139,11 @@ private void setupClientUser() { String userPermissions = "begin execute immediate 'grant web_user to " + System.getProperty("DB_USERNAME") + "'; end;"; String dbOffice = System.getProperty("DB_OFFICE"); - String setWebUserPermissions = "begin\n" + - " cwms_sec.add_user_to_group(?, 'CWMS User Admins',?) ;\n" + - " commit;\n" + - "end;"; + String setWebUserPermissions = """ + begin + cwms_sec.add_user_to_group(?, 'CWMS User Admins',?) ; + commit; + end;"""; try(Connection connection = DriverManager.getConnection(System.getProperty("DB_URL"), "CWMS_20", System.getProperty("DB_PASSWORD")); PreparedStatement stmt1 = connection.prepareStatement(userPermissions); diff --git a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/BaseIT.java b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/BaseIT.java index a28c6da2f..a83bcce7f 100644 --- a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/BaseIT.java +++ b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/BaseIT.java @@ -47,7 +47,6 @@ import org.opendcs.odcsapi.fixtures.DatabaseSetupExtension; import org.opendcs.odcsapi.fixtures.DbType; import org.opendcs.odcsapi.fixtures.TomcatServer; -import org.opendcs.odcsapi.hydrojson.DbInterface; import org.opendcs.odcsapi.res.ObjectMapperContextResolver; import org.opendcs.odcsapi.sec.OpenDcsApiRoles; import org.opendcs.odcsapi.sec.OpenDcsPrincipal; @@ -56,7 +55,7 @@ import static io.restassured.RestAssured.given; import static java.util.stream.Collectors.joining; import static org.hamcrest.Matchers.is; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.opendcs.odcsapi.util.ApiConstants.ORGANIZATION_HEADER; class BaseIT { @@ -147,7 +146,11 @@ void authenticate() .setMaxAge(-1) .setPath("/odcsapi") .build(); - authSpec = new RequestSpecBuilder().addCookie(cookie).build(); + String organization = DatabaseSetupExtension.getOrganization(); + authSpec = new RequestSpecBuilder() + .addCookie(cookie) + .addHeader(ORGANIZATION_HEADER, organization) + .build(); //Check while passing in cookie given() .log().ifValidationFails(LogDetail.ALL, true) diff --git a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/OrganizationResourcesIT.java b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/OrganizationResourcesIT.java new file mode 100644 index 000000000..8a0e50ee6 --- /dev/null +++ b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/res/it/OrganizationResourcesIT.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.res.it; + + +import io.restassured.filter.log.LogDetail; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.core.MediaType; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.TestTemplate; +import org.junit.jupiter.api.extension.ExtendWith; +import org.opendcs.odcsapi.fixtures.DatabaseContextProvider; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.is; + +@Tag("integration-cwms-only") +@ExtendWith(DatabaseContextProvider.class) +final class OrganizationResourcesIT extends BaseIT +{ + + @TestTemplate + void getAlgorithmRefs() + { + given() + .log().ifValidationFails(LogDetail.ALL, true) + .accept(MediaType.APPLICATION_JSON) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("organizations") + .then() + .log().ifValidationFails(LogDetail.ALL, true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("name", hasItem("SPK")) + ; + } +} diff --git a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/sec/AuthorizationTestIT.java b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/sec/AuthorizationTestIT.java index c7b106764..4dd5fedf0 100644 --- a/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/sec/AuthorizationTestIT.java +++ b/opendcs-integration-test/src/test/java/org/opendcs/odcsapi/sec/AuthorizationTestIT.java @@ -60,24 +60,14 @@ void unauthorizedAccessShouldReturn401() //ensures unauthorized session .filter(new SessionFilter()) .when(); - Response response; - switch(method) + Response response = switch(method) { - case GET: - response = spec.get(path); - break; - case POST: - response = spec.post(path); - break; - case PUT: - response = spec.put(path); - break; - case DELETE: - response = spec.delete(path); - break; - default: - throw new IllegalArgumentException("Unsupported method: " + method); - } + case GET -> spec.get(path); + case POST -> spec.post(path); + case PUT -> spec.put(path); + case DELETE -> spec.delete(path); + default -> throw new IllegalArgumentException("Unsupported method: " + method); + }; response.then() .log().ifValidationFails(LogDetail.ALL, true) .assertThat() @@ -94,6 +84,7 @@ private static Stream getEndpoints() return paths.entrySet().stream() .filter(e -> !e.getKey().equals("/credentials")) .filter(e -> !e.getKey().equals("/logout")) + .filter(e -> !e.getKey().equals("/organizations")) .flatMap(e -> e.getValue().readOperationsMap().entrySet().stream().map(opEntry -> { @@ -114,20 +105,8 @@ private static Stream getEndpoints() })); } - private static class Endpoint + private record Endpoint(String path, PathItem.HttpMethod method, String contentMediaType, String acceptMediaType) { - private final String path; - private final PathItem.HttpMethod method; - public final String contentMediaType; - public final String acceptMediaType; - - private Endpoint(String path, PathItem.HttpMethod method, String contentMediaType, String acceptMediaType) - { - this.path = path; - this.method = method; - this.contentMediaType = contentMediaType; - this.acceptMediaType = acceptMediaType; - } @Override public String toString() diff --git a/opendcs-integration-test/src/test/resources/tomcat/conf/context.xml b/opendcs-integration-test/src/test/resources/tomcat/conf/context.xml index 8e0b79016..55f5374d1 100644 --- a/opendcs-integration-test/src/test/resources/tomcat/conf/context.xml +++ b/opendcs-integration-test/src/test/resources/tomcat/conf/context.xml @@ -22,11 +22,11 @@ username="${DB_USERNAME}" password="${DB_PASSWORD}" driverClassName="${DB_DRIVER_CLASS}" - initSQL="${DB_CONNECTION_INIT}" logAbandoned="true" url="${DB_URL}" logValidationErrors="true" validationQuery="${DB_VALIDATION_QUERY}" + testOnBorrow="true" maxActive="10" maxIdle="4" minIdle="1"/> diff --git a/opendcs-rest-api/build.gradle b/opendcs-rest-api/build.gradle index 14f806515..c6bb64f5c 100644 --- a/opendcs-rest-api/build.gradle +++ b/opendcs-rest-api/build.gradle @@ -47,6 +47,7 @@ dependencies { exclude group: "org.eclipse.jetty", module: "jetty-annotations" } implementation(libs.opendcs.api) + implementation(libs.jdbi) swaggerDeps(libs.servlet.api) swaggerDeps(libs.rs.api) swaggerDeps(libs.swagger.jaxrs2) diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/beans/ApiOrganization.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/beans/ApiOrganization.java new file mode 100644 index 000000000..741a75cfd --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/beans/ApiOrganization.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.beans; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Represents an organization within the system") +public record ApiOrganization( + @Schema(description = "The short unique identifier name of the organization") String name, + @Schema(description = "A longer descriptive name of the organization") String description, + @Schema(description = "The parent organization's name, if any", nullable = true) String parent) +{ +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/ApiAuthorizationDAI.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/ApiAuthorizationDAI.java index afdfb811b..b90d5d58d 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/ApiAuthorizationDAI.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/ApiAuthorizationDAI.java @@ -17,10 +17,11 @@ import java.util.Set; +import org.opendcs.database.api.DataTransaction; import org.opendcs.database.api.OpenDcsDao; import org.opendcs.odcsapi.sec.OpenDcsApiRoles; -public interface ApiAuthorizationDAI extends OpenDcsDao, AutoCloseable +public interface ApiAuthorizationDAI extends OpenDcsDao { - Set getRoles(String username) throws DbException; + Set getRoles(DataTransaction transaction, String username, String organizationId) throws DbException; } diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OpenDcsDatabaseFactory.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OpenDcsDatabaseFactory.java index 78345ead8..f4cf564ca 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OpenDcsDatabaseFactory.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OpenDcsDatabaseFactory.java @@ -15,50 +15,67 @@ package org.opendcs.odcsapi.dao; -import java.sql.Connection; import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; import javax.sql.DataSource; -import decodes.cwms.CwmsDatabaseProvider; import decodes.db.DatabaseException; -import decodes.sql.OracleSequenceKeyGenerator; -import decodes.util.DecodesSettings; -import opendcs.opentsdb.OpenTsdbProvider; import org.opendcs.database.DatabaseService; import org.opendcs.database.api.OpenDcsDatabase; -import org.opendcs.odcsapi.hydrojson.DbInterface; -import org.opendcs.spi.database.DatabaseProvider; -import org.slf4j.Logger; -import org.opendcs.utils.logging.OpenDcsLoggerFactory; +import org.opendcs.odcsapi.dao.datasource.ConnectionPreparer; +import org.opendcs.odcsapi.dao.datasource.ConnectionPreparingDataSource; +import org.opendcs.odcsapi.dao.datasource.DelegatingConnectionPreparer; +import org.opendcs.odcsapi.dao.cwms.SessionOfficePreparer; public final class OpenDcsDatabaseFactory { - private static final Logger log = OpenDcsLoggerFactory.getLogger(); - private static OpenDcsDatabase database; + + /** + * The plan going forward is to add the organization as to a database context mechanism + * Right now the office id is set statefully in too many places to allow for reuse + * of the OpenDcsDatabase instance. + */ + private static final Map dbCache = new HashMap<>(); private OpenDcsDatabaseFactory() { throw new AssertionError("Utility class"); } - public static synchronized OpenDcsDatabase createDb(DataSource dataSource) + public static synchronized OpenDcsDatabase createDb(DataSource dataSource, String organization) + { + return dbCache.computeIfAbsent(organization, o -> newDatabase(dataSource, organization)); + } + + private static OpenDcsDatabase newDatabase(DataSource dataSource, String organization) { - if(database != null) + if(dataSource == null) { - return database; + throw new IllegalStateException("No data source defined in context.xml"); } try { - if(dataSource == null) + List preparers = List.of(new SessionOfficePreparer(organization)); + DataSource wrappedDataSource = new ConnectionPreparingDataSource( + new DelegatingConnectionPreparer(preparers), dataSource); + Properties properties = new Properties(); + if(organization != null) { - throw new IllegalStateException("No data source defined in context.xml"); + properties.put("CwmsOfficeId", organization); } - database = DatabaseService.getDatabaseFor(dataSource); + return DatabaseService.getDatabaseFor(wrappedDataSource, properties); } catch(DatabaseException ex) { + Throwable cause = ex.getCause(); + if(cause instanceof SQLException sqlException && sqlException.getErrorCode() == 28113) + { + throw new IllegalArgumentException("Error establishing organization id for request.", ex); + } throw new IllegalStateException("Error establishing database instance through data source.", ex); } - return database; } } diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OrganizationDao.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OrganizationDao.java new file mode 100644 index 000000000..3217fd378 --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/OrganizationDao.java @@ -0,0 +1,27 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.dao; + +import java.util.List; + +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDao; +import org.opendcs.odcsapi.beans.ApiOrganization; + +public interface OrganizationDao extends OpenDcsDao +{ + List retrieveOrganizationIds(DataTransaction tx, int limit, int offset) throws DbException; +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/CwmsOrganizationDao.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/CwmsOrganizationDao.java new file mode 100644 index 000000000..c88a8bbea --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/CwmsOrganizationDao.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.dao.cwms; + +import java.util.List; + +import org.jdbi.v3.core.Handle; +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDataException; +import org.opendcs.odcsapi.beans.ApiOrganization; +import org.opendcs.odcsapi.dao.DbException; +import org.opendcs.odcsapi.dao.OrganizationDao; + +public final class CwmsOrganizationDao implements OrganizationDao +{ + + @Override + public List retrieveOrganizationIds(DataTransaction tx, int limit, int offset) throws DbException + { + try + { + Handle handle = tx.connection(Handle.class).orElseThrow(); + String queryStr = "SELECT OFFICE_ID, LONG_NAME, REPORT_TO_OFFICE_ID FROM CWMS_V_OFFICE"; + if (limit > 0) + { + queryStr += " OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY"; + } + try(var query = handle.createQuery(queryStr)) + { + if (limit > 0) + { + query.bind("limit", limit); + query.bind("offset", offset); + } + return query.map((rs, ctx) -> new ApiOrganization(rs.getString(1), rs.getString(2), rs.getString(3))) + .list(); + } + } + catch(OpenDcsDataException ex) + { + throw new DbException("Unable to connect to the database.", ex); + } + } +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/SessionOfficePreparer.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/SessionOfficePreparer.java new file mode 100644 index 000000000..a06123c12 --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/cwms/SessionOfficePreparer.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.dao.cwms; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +import org.opendcs.odcsapi.dao.datasource.ConnectionPreparer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SessionOfficePreparer implements ConnectionPreparer +{ + private static final Logger logger = LoggerFactory.getLogger(SessionOfficePreparer.class); + + private final String office; + + public SessionOfficePreparer(String office) + { + this.office = office; + } + + @Override + public Connection prepare(Connection conn) throws SQLException + { + if (!"Oracle".equalsIgnoreCase(conn.getMetaData().getDatabaseProductName())) + { + return conn; + } + String sessionOffice = this.office; + if(sessionOffice == null || sessionOffice.isBlank()) + { + logger.atDebug().log("Office is null or empty."); + //Workaround for default loaded data on OpenDcsDatabase creation + sessionOffice = "HQ"; + } + String sql = """ + declare + l_exists NUMBER; + begin + select count(*) into l_exists from all_objects where object_name = 'CWMS_CCP_VPD' and object_type = 'PACKAGE'; + if l_exists > 0 then + execute immediate 'BEGIN cwms_ccp_vpd.set_ccp_session_ctx(cwms_util.get_office_code(:1), 2, :2 ); END;' using :1, :2; + end if; + end; + """; + try(PreparedStatement setApiUser = conn.prepareStatement(sql)) + { + setApiUser.setString(1, sessionOffice); + setApiUser.setString(2, sessionOffice); + setApiUser.execute(); + } + return conn; + } +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparer.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparer.java new file mode 100644 index 000000000..b2c0c168c --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparer.java @@ -0,0 +1,9 @@ +package org.opendcs.odcsapi.dao.datasource; + +import java.sql.Connection; +import java.sql.SQLException; + +public interface ConnectionPreparer +{ + Connection prepare(Connection connection) throws SQLException; +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparingDataSource.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparingDataSource.java new file mode 100644 index 000000000..1563a13a8 --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/ConnectionPreparingDataSource.java @@ -0,0 +1,61 @@ +package org.opendcs.odcsapi.dao.datasource; + +import java.sql.Connection; +import java.sql.SQLException; +import javax.sql.DataSource; + +public class ConnectionPreparingDataSource extends DelegatingDataSource +{ + + private ConnectionPreparer preparer; + + public ConnectionPreparingDataSource(ConnectionPreparer preparer, DataSource targetDataSource) + { + super(targetDataSource); + this.preparer = preparer; + } + + @Override + public Connection getConnection() throws SQLException + { + Connection connection = getDelegate().getConnection(); + + try + { + return getPreparer().prepare(connection); + } + catch(RuntimeException e) + { + try + { + // If there was some problem preparing the connection + // we close the connection in order to return it to + // the pool it probably came from. + connection.close(); + } + catch(SQLException ex) + { + e.addSuppressed(ex); + } + throw e; + } + } + + /** + * @return the preparer + */ + public ConnectionPreparer getPreparer() + { + return preparer; + } + + /** + * @param preparer the preparer to set + */ + public void setPreparer(ConnectionPreparer preparer) + { + this.preparer = preparer; + } + + +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingConnectionPreparer.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingConnectionPreparer.java new file mode 100644 index 000000000..e9f805830 --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingConnectionPreparer.java @@ -0,0 +1,37 @@ +package org.opendcs.odcsapi.dao.datasource; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class DelegatingConnectionPreparer implements ConnectionPreparer +{ + + private static final Logger logger = LoggerFactory.getLogger(DelegatingConnectionPreparer.class); + private final List delegates = new ArrayList<>(); + + public DelegatingConnectionPreparer(List preparers) + { + if(preparers != null) + { + delegates.addAll(preparers); + } + } + + @Override + public Connection prepare(Connection connection) throws SQLException + { + Connection retval = connection; + for(ConnectionPreparer delegate : delegates) + { + logger.atTrace().log(delegate.getClass().getName()); + retval = delegate.prepare(retval); + } + return retval; + } + +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingDataSource.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingDataSource.java new file mode 100644 index 000000000..f07c3becf --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/dao/datasource/DelegatingDataSource.java @@ -0,0 +1,110 @@ +package org.opendcs.odcsapi.dao.datasource; + +import java.io.PrintWriter; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.logging.Logger; +import javax.sql.DataSource; + + +/** + * This class is a wrapper around a DataSource that delegates all calls to the + * wrapped DataSource. It is intended to be extended by classes that need to + * override DataSource methods. + */ +public class DelegatingDataSource implements DataSource +{ + + private DataSource delegate; + + /** + * Create a new DelegatingDataSource. + * + * @param delegate the target DataSource + */ + public DelegatingDataSource(DataSource delegate) + { + setDelegate(delegate); + } + + + /** + * Set the target DataSource that this DataSource should delegate to. + */ + public void setDelegate(DataSource delegate) + { + this.delegate = delegate; + } + + /** + * Return the target DataSource that this DataSource should delegate to. + */ + + public DataSource getDelegate() + { + return this.delegate; + } + + + @Override + public Connection getConnection() throws SQLException + { + return getDelegate().getConnection(); + } + + @Override + public Connection getConnection(String username, String password) throws SQLException + { + return getDelegate().getConnection(username, password); + } + + @Override + public PrintWriter getLogWriter() throws SQLException + { + return getDelegate().getLogWriter(); + } + + @Override + public void setLogWriter(PrintWriter out) throws SQLException + { + getDelegate().setLogWriter(out); + } + + @Override + public int getLoginTimeout() throws SQLException + { + return getDelegate().getLoginTimeout(); + } + + @Override + public void setLoginTimeout(int seconds) throws SQLException + { + getDelegate().setLoginTimeout(seconds); + } + + + @Override + @SuppressWarnings("unchecked") + public T unwrap(Class iface) throws SQLException + { + if(iface.isInstance(this)) + { + return (T) this; + } + return getDelegate().unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException + { + return (iface.isInstance(this) || getDelegate().isWrapperFor(iface)); + } + + + @Override + public Logger getParentLogger() + { + return Logger.getLogger(Logger.GLOBAL_LOGGER_NAME); + } + +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/ContextPropertySetup.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/ContextPropertySetup.java index 61d9291d0..c2a9b68b1 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/ContextPropertySetup.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/ContextPropertySetup.java @@ -31,7 +31,6 @@ public void contextInitialized(ServletContextEvent sce) { ServletContext servletContext = sce.getServletContext(); //Move this information to the database. https://github.com/opendcs/rest_api/issues/191 - initProp(servletContext, "opendcs.rest.api.cwms.office", "CwmsOfficeId", "OPENDCS_DB_OFFICE"); initProp(servletContext, "opendcs.rest.api.authorization.type", "opendcs.rest.api.authorization.type", "OPENDCS_AUTHORIZATION_TYPE"); initProp(servletContext, "opendcs.rest.api.authorization.expiration.duration", "opendcs.rest.api.authorization.expiration.duration", "OPENDCS_AUTHORIZATION_DURATION"); initProp(servletContext, "opendcs.rest.api.authorization.jwt.jwkset.url", "opendcs.rest.api.authorization.jwt.jwkset.url", "OPENDCS_AUTHORIZATION_JWK_SET_URL"); diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/OpenDcsResource.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/OpenDcsResource.java index 15a075e33..f9b88ee8d 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/OpenDcsResource.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/OpenDcsResource.java @@ -15,8 +15,12 @@ package org.opendcs.odcsapi.res; +import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.ServletContext; import javax.sql.DataSource; + +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.container.ContainerRequestContext; import jakarta.ws.rs.core.Context; import decodes.db.Database; @@ -28,6 +32,7 @@ import org.opendcs.odcsapi.dao.OpenDcsDatabaseFactory; import static org.opendcs.odcsapi.res.DataSourceContextCreator.DATA_SOURCE_ATTRIBUTE_KEY; +import static org.opendcs.odcsapi.util.ApiConstants.ORGANIZATION_HEADER; @OpenAPIDefinition( info = @Info( @@ -41,16 +46,33 @@ public class OpenDcsResource { private static final String UNSUPPORTED_OPERATION_MESSAGE = "Endpoint is unsupported by the OpenDCS REST API."; + @HeaderParam(ORGANIZATION_HEADER) + @Parameter(description = "Organization ID for the request", required = true) + protected String organizationId; + + @Context + protected ContainerRequestContext request; + @Context protected ServletContext context; protected final synchronized OpenDcsDatabase createDb() + { + DataSource dataSource = getDataSource(); + return OpenDcsDatabaseFactory.createDb(dataSource, organizationId); + } + + protected final DataSource getDataSource() { DataSource dataSource = (DataSource) context.getAttribute(DATA_SOURCE_ATTRIBUTE_KEY); - return OpenDcsDatabaseFactory.createDb(dataSource); + if(dataSource == null) + { + throw new IllegalStateException("DataSource not found in ServletContext."); + } + return dataSource; } - protected DatabaseIO getLegacyDatabase() + protected final DatabaseIO getLegacyDatabase() { return createDb().getLegacyDatabase(Database.class) .map(Database::getDbIo) diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/PlatformResources.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/PlatformResources.java index d9240f506..b9146fb26 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/PlatformResources.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/res/PlatformResources.java @@ -113,7 +113,7 @@ public final class PlatformResources extends OpenDcsResource ) public Response getPlatformRefs(@Parameter(description = "Transport medium type", schema = @Schema(implementation = String.class, example = "goes")) - @QueryParam("tmtype") String tmtype) + @QueryParam("tmtype") String tmtype) throws DbException { DatabaseIO dbIo = getLegacyDatabase(); diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/AuthorizationCheck.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/AuthorizationCheck.java deleted file mode 100644 index b8de3a96e..000000000 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/AuthorizationCheck.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2025 OpenDCS Consortium and its Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.opendcs.odcsapi.sec; - -import jakarta.servlet.ServletContext; -import jakarta.servlet.http.HttpServletRequest; -import javax.sql.DataSource; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.SecurityContext; - -import decodes.cwms.CwmsTimeSeriesDb; -import decodes.tsdb.TimeSeriesDb; -import opendcs.opentsdb.OpenTsdb; -import org.opendcs.database.api.OpenDcsDatabase; -import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; -import org.opendcs.odcsapi.dao.OpenDcsDatabaseFactory; -import org.opendcs.odcsapi.sec.basicauth.OpenTsdbAuthorizationDAO; -import org.opendcs.odcsapi.sec.cwms.CwmsAuthorizationDAO; - -import static org.opendcs.odcsapi.res.DataSourceContextCreator.DATA_SOURCE_ATTRIBUTE_KEY; - -public abstract class AuthorizationCheck -{ - - /** - * Authorizes the current session returning the SecurityContext that will check user roles. - * - * @param requestContext context for the current session. - * @param httpServletRequest context for the current request. - */ - public abstract SecurityContext authorize(ContainerRequestContext requestContext, - HttpServletRequest httpServletRequest, ServletContext servletContext); - - public abstract boolean supports(String type, ContainerRequestContext requestContext, ServletContext servletContext); - - - protected final ApiAuthorizationDAI getAuthDao(ServletContext servletContext) - { - DataSource dataSource = (DataSource) servletContext.getAttribute(DATA_SOURCE_ATTRIBUTE_KEY); - OpenDcsDatabase db = OpenDcsDatabaseFactory.createDb(dataSource); - TimeSeriesDb timeSeriesDb = db.getLegacyDatabase(TimeSeriesDb.class) - .orElseThrow(() -> new UnsupportedOperationException("Endpoint is unsupported by the OpenDCS REST API.")); - //Need to figure out a better way to extend the toolkit API to be able to add dao's within the REST API - if(timeSeriesDb instanceof CwmsTimeSeriesDb) - { - return new CwmsAuthorizationDAO(timeSeriesDb); - } - else if(timeSeriesDb instanceof OpenTsdb) - { - return new OpenTsdbAuthorizationDAO(timeSeriesDb); - } - throw new UnsupportedOperationException("Endpoint is unsupported by the OpenDCS REST API."); - } -} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/OrganizationResource.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/OrganizationResource.java new file mode 100644 index 000000000..13e7c1cad --- /dev/null +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/OrganizationResource.java @@ -0,0 +1,101 @@ +/* + * Copyright 2025 OpenDCS Consortium and its Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.opendcs.odcsapi.sec; + +import java.util.List; + +import decodes.util.DecodesSettings; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import jakarta.annotation.security.RolesAllowed; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.EntityTag; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDataException; +import org.opendcs.database.api.OpenDcsDatabase; +import org.opendcs.odcsapi.beans.ApiOrganization; +import org.opendcs.odcsapi.beans.Status; +import org.opendcs.odcsapi.dao.DbException; +import org.opendcs.odcsapi.dao.cwms.CwmsOrganizationDao; +import org.opendcs.odcsapi.res.OpenDcsResource; +import org.opendcs.odcsapi.util.ApiConstants; + +@Path("/") +public final class OrganizationResource extends OpenDcsResource +{ + @GET + @Path("organizations") + @Produces(MediaType.APPLICATION_JSON) + @RolesAllowed({ApiConstants.ODCS_API_GUEST, ApiConstants.ODCS_API_USER, ApiConstants.ODCS_API_ADMIN}) + @Operation( + summary = "Request the list of valid organizations", + description = "Organizations are used by queries to filter to a subset of data that a user is authorized for.", + responses = { + @ApiResponse(responseCode = "200", description = "A list of organizations will be returned."), + @ApiResponse( + responseCode = "404", + description = "If no organizations are available.", + content = @Content(mediaType = MediaType.APPLICATION_JSON, + array = @ArraySchema(schema = @Schema(implementation = String.class))) + ) + }, + tags = {"REST - Authentication and Authorization"} + ) + public Response getOrganizations(@QueryParam("limit") @DefaultValue("-1") int limit, + @QueryParam("offset") @DefaultValue("0") int offset) throws DbException + { + OpenDcsDatabase db = createDb(); + if("cwms".equalsIgnoreCase(db.getSettings(DecodesSettings.class).orElseThrow().editDatabaseType)) + { + try(DataTransaction tx = db.newTransaction()) + { + + CwmsOrganizationDao cwmsOrganizationDao = new CwmsOrganizationDao(); + List organizations = cwmsOrganizationDao.retrieveOrganizationIds(tx, limit, offset); + //Using basic/faster hash instead of more complex hashing (SHA-256/Base64/CRC32). + //Not really worried about hash collisions, and the list is very static + String etagString = Integer.toHexString(organizations.hashCode()); + EntityTag etag = new EntityTag(etagString); + Response.ResponseBuilder precheck = request.getRequest().evaluatePreconditions(etag); + if (precheck != null) + { + return precheck.build(); + } + return Response.status(HttpServletResponse.SC_OK) + .entity(organizations) + .header("Cache-Control", "public, max-age=86400") + .build(); + } + catch(OpenDcsDataException ex) + { + throw new DbException("Error establishing connection to the database.", ex); + } + } + return Response.status(HttpServletResponse.SC_NOT_FOUND) + .entity(new Status("Implementation does not support organizations yet.")) + .build(); + } +} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/SecurityFilter.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/SecurityFilter.java index e560b5628..c3b55f07b 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/SecurityFilter.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/SecurityFilter.java @@ -35,7 +35,6 @@ import jakarta.ws.rs.ext.Provider; import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; -import org.opendcs.odcsapi.hydrojson.DbInterface; import org.slf4j.Logger; import org.opendcs.utils.logging.OpenDcsLoggerFactory; diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthCheck.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthCheck.java deleted file mode 100644 index 8167a5a4d..000000000 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthCheck.java +++ /dev/null @@ -1,84 +0,0 @@ -/* - * Copyright 2025 OpenDCS Consortium and its Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.opendcs.odcsapi.sec.basicauth; - -import java.util.Set; -import jakarta.servlet.ServletContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; -import javax.sql.DataSource; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.SecurityContext; - -import com.google.auto.service.AutoService; -import decodes.tsdb.TimeSeriesDb; -import org.opendcs.database.api.OpenDcsDatabase; -import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; -import org.opendcs.odcsapi.dao.OpenDcsDatabaseFactory; -import org.opendcs.odcsapi.sec.AuthorizationCheck; -import org.opendcs.odcsapi.sec.OpenDcsApiRoles; -import org.opendcs.odcsapi.sec.OpenDcsPrincipal; -import org.opendcs.odcsapi.sec.OpenDcsSecurityContext; - -import static org.opendcs.odcsapi.res.DataSourceContextCreator.DATA_SOURCE_ATTRIBUTE_KEY; - -@AutoService(AuthorizationCheck.class) -public final class BasicAuthCheck extends AuthorizationCheck -{ - - @Override - public OpenDcsSecurityContext authorize(ContainerRequestContext requestContext, HttpServletRequest httpServletRequest, ServletContext servletContext) - { - HttpSession session = httpServletRequest.getSession(false); - if(session == null) - { - throw new NotAuthorizedException("User has not yet established a session."); - } - Object attribute = session.getAttribute(OpenDcsPrincipal.USER_PRINCIPAL_SESSION_ATTRIBUTE); - if(!(attribute instanceof OpenDcsPrincipal)) - { - throw new NotAuthorizedException("User has not established an authenticated session."); - } - String username = ((OpenDcsPrincipal) attribute).getName(); - Set roles = getUserRoles(username, servletContext); - OpenDcsPrincipal principal = new OpenDcsPrincipal(username, roles); - return new OpenDcsSecurityContext(principal, - httpServletRequest.isSecure(), SecurityContext.BASIC_AUTH); - } - - @Override - public boolean supports(String type, ContainerRequestContext ignored, ServletContext servletContext) - { - DataSource dataSource = (DataSource) servletContext.getAttribute(DATA_SOURCE_ATTRIBUTE_KEY); - OpenDcsDatabase db = OpenDcsDatabaseFactory.createDb(dataSource); - TimeSeriesDb timeSeriesDb = db.getLegacyDatabase(TimeSeriesDb.class) - .orElseThrow(() -> new UnsupportedOperationException("Endpoint is unsupported by the OpenDCS REST API.")); - return "basic".equals(type) && timeSeriesDb.isOpenTSDB(); - } - - private Set getUserRoles(String username, ServletContext servletContext) - { - try(ApiAuthorizationDAI dao = getAuthDao(servletContext)) - { - return dao.getRoles(username); - } - catch(Exception ex) - { - throw new IllegalStateException("Unable to query the database for user authorization", ex); - } - } -} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthResource.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthResource.java index 61a06760a..7e3d4911d 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthResource.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/BasicAuthResource.java @@ -16,47 +16,45 @@ package org.opendcs.odcsapi.sec.basicauth; import java.sql.Connection; -import java.sql.DatabaseMetaData; -import java.sql.DriverManager; import java.sql.SQLException; import java.util.Base64; import java.util.Set; -import jakarta.annotation.security.RolesAllowed; +import javax.sql.DataSource; +import decodes.util.DecodesSettings; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.StringToClassMapItem; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponse; - +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.security.RolesAllowed; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import javax.sql.DataSource; import jakarta.ws.rs.BadRequestException; import jakarta.ws.rs.Consumes; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.Produces; -import jakarta.ws.rs.ServerErrorException; import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.HttpHeaders; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; - -import decodes.tsdb.TimeSeriesDb; +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDatabase; import org.opendcs.odcsapi.beans.Status; import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; import org.opendcs.odcsapi.errorhandling.WebAppException; import org.opendcs.odcsapi.res.OpenDcsResource; import org.opendcs.odcsapi.sec.OpenDcsApiRoles; import org.opendcs.odcsapi.sec.OpenDcsPrincipal; +import org.opendcs.odcsapi.sec.cwms.CwmsAuthorizationDAO; import org.opendcs.odcsapi.util.ApiConstants; -import org.slf4j.Logger; import org.opendcs.utils.logging.OpenDcsLoggerFactory; +import org.slf4j.Logger; import static org.opendcs.odcsapi.res.DataSourceContextCreator.DATA_SOURCE_ATTRIBUTE_KEY; @@ -69,7 +67,7 @@ public final class BasicAuthResource extends OpenDcsResource private static final String MODULE = "BasicAuthResource"; @Context - private HttpServletRequest request; + private HttpServletRequest httpServletRequest; @Context private HttpHeaders httpHeaders; @@ -80,14 +78,17 @@ public final class BasicAuthResource extends OpenDcsResource @RolesAllowed({ApiConstants.ODCS_API_GUEST}) @Operation( summary = "The ‘credentials’ POST method is used to obtain a new token", - description = "The user name and password provided must be a valid login for the underlying database. \n" - + "Also, that user must be assigned either of the roles OTSDB_ADMIN or OTSDB_MGR.\n" - + "--- \n\n\n" - + "Starting in **API Version 0.0.3**, authentication credentials (username and password) " - + "may be passed as shown above in the POST body. \n" - + "They may also be passed in a GET call to the 'credentials' method, " - + "(e.g. '*http://localhost:8080/odcsapi/credentials*') containing an HTTP Authentication Basic " - + "header in the form 'username:password'. \n\nThe returned data to the GET call will be empty.", + description = """ + The user name and password provided must be a valid login for the underlying database. + Also, that user must be assigned either of the roles OTSDB_ADMIN or OTSDB_MGR. + --- + Starting in **API Version 0.0.3**, authentication credentials (username and password) \ + may be passed as shown above in the POST body. + They may also be passed in a GET call to the 'credentials' method, \ + (e.g. '*http://localhost:8080/odcsapi/credentials*') containing an HTTP Authentication Basic \ + header in the form 'username:password'. + + The returned data to the GET call will be empty.""", requestBody = @RequestBody( description = "Login Credentials", required = true, @@ -133,12 +134,6 @@ public final class BasicAuthResource extends OpenDcsResource ) public Response postCredentials(Credentials credentials) throws WebAppException { - TimeSeriesDb db = getLegacyTimeseriesDB(); - if(!db.isOpenTSDB()) - { - throw new ServerErrorException("Basic Auth is not supported", Response.Status.NOT_IMPLEMENTED); - } - //If credentials are null, Authorization header will be checked. if(credentials != null) { @@ -148,14 +143,14 @@ public Response postCredentials(Credentials credentials) throws WebAppException String authorizationHeader = httpHeaders.getHeaderString(HttpHeaders.AUTHORIZATION); credentials = getCredentials(credentials, authorizationHeader); validateDbCredentials(credentials); - Set roles = getUserRoles(credentials.getUsername()); + Set roles = getUserRoles(credentials.getUsername(), organizationId); OpenDcsPrincipal principal = new OpenDcsPrincipal(credentials.getUsername(), roles); - HttpSession oldSession = request.getSession(false); + HttpSession oldSession = httpServletRequest.getSession(false); if(oldSession != null) { oldSession.invalidate(); } - HttpSession session = request.getSession(true); + HttpSession session = httpServletRequest.getSession(true); session.setAttribute(OpenDcsPrincipal.USER_PRINCIPAL_SESSION_ATTRIBUTE, principal); return Response.status(HttpServletResponse.SC_OK).entity(new Status("Authentication Successful.")) .build(); @@ -249,8 +244,8 @@ private void validateDbCredentials(Credentials creds) throws WebAppException Intentional unused connection. Makes a new db connection using passed credentials This validates the username & password and will throw SQLException if user/pw is not valid. */ - //noinspection EmptyTryBlock DataSource dataSource = (DataSource) context.getAttribute(DATA_SOURCE_ATTRIBUTE_KEY); + //noinspection EmptyTryBlock try (Connection ignored = dataSource.getConnection(creds.getUsername(), creds.getPassword())) {// NOSONAR @@ -263,11 +258,13 @@ private void validateDbCredentials(Credentials creds) throws WebAppException } } - private Set getUserRoles(String username) + private Set getUserRoles(String username, String organizationId) { - try(ApiAuthorizationDAI dao = getAuthDao()) + OpenDcsDatabase db = createDb(); + ApiAuthorizationDAI dao = getAuthDao(db); + try(DataTransaction tx = db.newTransaction()) { - return dao.getRoles(username); + return dao.getRoles(tx, username, organizationId); } catch(Exception ex) { @@ -275,13 +272,17 @@ private Set getUserRoles(String username) } } - private ApiAuthorizationDAI getAuthDao() + private ApiAuthorizationDAI getAuthDao(OpenDcsDatabase db) { - TimeSeriesDb timeSeriesDb = getLegacyTimeseriesDB(); + String databaseType = db.getSettings(DecodesSettings.class).orElseThrow().editDatabaseType; // Username+Password login only supported by OpenTSDB - if(timeSeriesDb.isOpenTSDB()) + if("opentsdb".equalsIgnoreCase(databaseType)) + { + return new OpenTsdbAuthorizationDAO(); + } + else if("cwms".equalsIgnoreCase(databaseType)) { - return new OpenTsdbAuthorizationDAO(timeSeriesDb); + return new CwmsAuthorizationDAO(); } throw new UnsupportedOperationException("Endpoint is unsupported by the OpenDCS REST API."); } diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/OpenTsdbAuthorizationDAO.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/OpenTsdbAuthorizationDAO.java index f9ad10cca..5f50783d5 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/OpenTsdbAuthorizationDAO.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/basicauth/OpenTsdbAuthorizationDAO.java @@ -15,31 +15,27 @@ package org.opendcs.odcsapi.sec.basicauth; +import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.EnumSet; import java.util.Set; -import opendcs.dao.DaoBase; -import opendcs.dao.DatabaseConnectionOwner; +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDataException; import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; import org.opendcs.odcsapi.dao.DbException; import org.opendcs.odcsapi.sec.OpenDcsApiRoles; import org.slf4j.Logger; import org.opendcs.utils.logging.OpenDcsLoggerFactory; -public final class OpenTsdbAuthorizationDAO extends DaoBase implements ApiAuthorizationDAI +public final class OpenTsdbAuthorizationDAO implements ApiAuthorizationDAI { private static final Logger log = OpenDcsLoggerFactory.getLogger(); - public OpenTsdbAuthorizationDAO(DatabaseConnectionOwner tsdb) - { - super(tsdb, "AuthorizationDAO"); - } - @Override - public Set getRoles(String username) throws DbException + public Set getRoles(DataTransaction transaction, String username, String unused) throws DbException { Set roles = EnumSet.noneOf(OpenDcsApiRoles.class); roles.add(OpenDcsApiRoles.ODCS_API_GUEST); @@ -47,37 +43,35 @@ public Set getRoles(String username) throws DbException String q = "select pm.roleid, pr.rolname from pg_auth_members pm, pg_roles pr" + " where pm.member = (select oid from pg_roles where upper(rolname) = upper(?))" + " and pm.roleid = pr.oid"; - try + + try(Connection c = transaction.connection(Connection.class).orElseThrow()) { - withConnection(c -> + try(PreparedStatement statement = c.prepareStatement(q)) { - try(PreparedStatement statement = c.prepareStatement(q)) + statement.setString(1, username); + try(ResultSet rs = statement.executeQuery()) { - statement.setString(1, username); - try(ResultSet rs = statement.executeQuery()) + while(rs.next()) { - while(rs.next()) + int roleid = rs.getInt(1); + String role = rs.getString(2); + log.info("User '{}' has role {}={}", username, roleid, role); + if("OTSDB_ADMIN".equalsIgnoreCase(role)) + { + roles.add(OpenDcsApiRoles.ODCS_API_ADMIN); + } + if("OTSDB_MGR".equalsIgnoreCase(role)) { - int roleid = rs.getInt(1); - String role = rs.getString(2); - log.info("User '{}' has role {}={}", username, roleid, role); - if("OTSDB_ADMIN".equalsIgnoreCase(role)) - { - roles.add(OpenDcsApiRoles.ODCS_API_ADMIN); - } - if("OTSDB_MGR".equalsIgnoreCase(role)) - { - roles.add(OpenDcsApiRoles.ODCS_API_USER); - } + roles.add(OpenDcsApiRoles.ODCS_API_USER); } } } - }); + } return roles; } - catch(SQLException ex) + catch(SQLException | OpenDcsDataException ex) { - throw new DbException(module, ex, "Unable to determine user roles in the database."); + throw new DbException("Unable to determine user roles in the database.", ex); } } diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/cwms/CwmsAuthorizationDAO.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/cwms/CwmsAuthorizationDAO.java index 333d19af4..85f2f69ba 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/cwms/CwmsAuthorizationDAO.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/cwms/CwmsAuthorizationDAO.java @@ -15,33 +15,28 @@ package org.opendcs.odcsapi.sec.cwms; +import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.EnumSet; import java.util.Set; -import opendcs.dao.DaoBase; -import opendcs.dao.DatabaseConnectionOwner; +import org.opendcs.database.api.DataTransaction; +import org.opendcs.database.api.OpenDcsDataException; import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; import org.opendcs.odcsapi.dao.DbException; -import org.opendcs.odcsapi.hydrojson.DbInterface; import org.opendcs.odcsapi.sec.OpenDcsApiRoles; -import org.slf4j.Logger; import org.opendcs.utils.logging.OpenDcsLoggerFactory; +import org.slf4j.Logger; -public final class CwmsAuthorizationDAO extends DaoBase implements ApiAuthorizationDAI +public final class CwmsAuthorizationDAO implements ApiAuthorizationDAI { private static final Logger log = OpenDcsLoggerFactory.getLogger(); - public CwmsAuthorizationDAO(DatabaseConnectionOwner tsdb) - { - super(tsdb, "AuthorizationDAO"); - } - - @Override - public Set getRoles(String username) throws DbException - { + @Override + public Set getRoles(DataTransaction tx, String username, String organizationId) throws DbException + { Set roles = EnumSet.noneOf(OpenDcsApiRoles.class); roles.add(OpenDcsApiRoles.ODCS_API_GUEST); String q = """ @@ -59,40 +54,34 @@ when instr(inputs.username, '.', -1) > 0 then end and avsu.is_member = 'T' """; - String cwmsOfficeId = DbInterface.decodesProperties.getProperty("CwmsOfficeId"); - try + try(Connection c = tx.connection(Connection.class).orElseThrow(); + PreparedStatement statement = c.prepareStatement(q)) { - withConnection(c -> - { - try(PreparedStatement statement = c.prepareStatement(q)) - { - statement.setString(1, username); - statement.setString(2, cwmsOfficeId); + statement.setString(1, username); + statement.setString(2, organizationId); - try(ResultSet rs = statement.executeQuery()) - { - while(rs.next()) - { - String role = rs.getString(1); - log.info("User '{}' has role {}", username, role); - if("CCP Mgr".equalsIgnoreCase(role)) - { - roles.add(OpenDcsApiRoles.ODCS_API_ADMIN); - } - if("CCP Proc".equalsIgnoreCase(role)) - { - roles.add(OpenDcsApiRoles.ODCS_API_USER); - } - } - } - } - }); + try(ResultSet rs = statement.executeQuery()) + { + while(rs.next()) + { + String role = rs.getString(1); + log.info("User '{}' has role {}", username, role); + if("CCP Mgr".equalsIgnoreCase(role)) + { + roles.add(OpenDcsApiRoles.ODCS_API_ADMIN); + } + if("CCP Proc".equalsIgnoreCase(role)) + { + roles.add(OpenDcsApiRoles.ODCS_API_USER); + } + } + } return roles; } - catch(SQLException ex) + catch(SQLException | OpenDcsDataException ex) { throw new DbException("Unable to determine user roles for user: " + username - + " and office: " + cwmsOfficeId, ex); + + " and office: " + organizationId, ex); } } } diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/openid/OidcAuthCheck.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/openid/OidcAuthCheck.java deleted file mode 100644 index b02a504d3..000000000 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/sec/openid/OidcAuthCheck.java +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright 2025 OpenDCS Consortium and its Contributors - * - * Licensed under the Apache License, Version 2.0 (the "License") - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.opendcs.odcsapi.sec.openid; - -import java.net.MalformedURLException; -import java.net.URL; -import java.text.ParseException; -import java.util.Set; -import jakarta.servlet.ServletContext; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.ws.rs.NotAuthorizedException; -import jakarta.ws.rs.ServerErrorException; -import jakarta.ws.rs.container.ContainerRequestContext; -import jakarta.ws.rs.core.Response; - -import com.google.auto.service.AutoService; -import com.nimbusds.jose.JOSEException; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.proc.BadJOSEException; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jwt.JWTClaimsSet; -import org.opendcs.odcsapi.dao.ApiAuthorizationDAI; -import org.opendcs.odcsapi.hydrojson.DbInterface; -import org.opendcs.odcsapi.sec.AuthorizationCheck; -import org.opendcs.odcsapi.sec.OpenDcsApiRoles; -import org.opendcs.odcsapi.sec.OpenDcsPrincipal; -import org.opendcs.odcsapi.sec.OpenDcsSecurityContext; -import org.slf4j.Logger; -import org.opendcs.utils.logging.OpenDcsLoggerFactory; - -@AutoService(AuthorizationCheck.class) -public final class OidcAuthCheck extends AuthorizationCheck -{ - - private static final Logger log = OpenDcsLoggerFactory.getLogger(); - static final String AUTHORIZATION_HEADER = "Authorization"; - static final String BEARER_PREFIX = "Bearer "; - private final JWKSource keySource; - - public OidcAuthCheck() - { - this.keySource = setupJwkSource(); - } - - //Used by unit tests - OidcAuthCheck(JWKSource keySource) - { - this.keySource = keySource; - } - - private static JWKSource setupJwkSource() - { - JWKSource keySource = null; - String property = "opendcs.rest.api.authorization.jwt.jwkset.url"; - try - { - String jwkSetUrl = DbInterface.decodesProperties.getProperty(property); - if(jwkSetUrl == null) - { - log.warn("Property: {} not set. OpenID Authorization is disabled.", property); - } - else - { - keySource = JWKSourceBuilder - .create(new URL(jwkSetUrl)) - .retrying(true) - .build(); - } - } - catch(MalformedURLException ex) - { - log.atWarn().setCause(ex).log("Property: {} is invalid. OpenID Authorization is disabled.", property); - } - return keySource; - } - - @Override - public OpenDcsSecurityContext authorize(ContainerRequestContext requestContext, HttpServletRequest httpServletRequest, ServletContext servletContext) - { - String authorizationHeader = requestContext.getHeaderString(AUTHORIZATION_HEADER); - try - { - String token = authorizationHeader.substring(BEARER_PREFIX.length()); - JWTClaimsSet claimsSet = JwtVerifier.getInstance().getClaimsSet(keySource, token); - String username = claimsSet.getStringClaim("preferred_username"); - OpenDcsPrincipal principal = createPrincipalFromSubject(username, servletContext); - return new OpenDcsSecurityContext(principal, httpServletRequest.isSecure(), BEARER_PREFIX); - } - catch(ParseException | JOSEException | BadJOSEException ex) - { - log.atWarn().setCause(ex).log("Token processing error."); - throw new NotAuthorizedException("Invalid JWT."); - } - } - - private OpenDcsPrincipal createPrincipalFromSubject(String subject, ServletContext servletContext) - { - try(ApiAuthorizationDAI authorizationDao = getAuthDao(servletContext)) - { - Set roles = authorizationDao.getRoles(subject); - return new OpenDcsPrincipal(subject, roles); - } - catch(Exception ex) - { - throw new ServerErrorException("Error accessing database to determine user roles", - Response.Status.INTERNAL_SERVER_ERROR, ex); - } - } - - @Override - public boolean supports(String type, ContainerRequestContext requestContext, ServletContext ignored) - { - String authorizationHeader = requestContext.getHeaderString(AUTHORIZATION_HEADER); - return keySource != null && "openid".equals(type) && authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX); - } -} diff --git a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/util/ApiConstants.java b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/util/ApiConstants.java index 20975ba2f..9eb2b7b24 100644 --- a/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/util/ApiConstants.java +++ b/opendcs-rest-api/src/main/java/org/opendcs/odcsapi/util/ApiConstants.java @@ -23,6 +23,7 @@ public final class ApiConstants public static final String ODCS_API_GUEST = "ODCS_API_GUEST"; public static final String ODCS_API_USER = "ODCS_API_USER"; public static final String ODCS_API_ADMIN = "ODCS_API_ADMIN"; + public static final String ORGANIZATION_HEADER = "X-ORGANIZATION-ID"; private ApiConstants() { diff --git a/opendcs-web-client/build.gradle b/opendcs-web-client/build.gradle index 33c982b2d..1e1b37248 100644 --- a/opendcs-web-client/build.gradle +++ b/opendcs-web-client/build.gradle @@ -86,6 +86,10 @@ dependencies { runtimeOnly("org.webjars.npm:datatables.net-select-bs5:1.4.0") { exclude group: 'org.webjars.npm', module: 'jquery' } + runtimeOnly("org.webjars.npm:select2-bootstrap-5-theme:1.3.0") { + exclude group: 'org.webjars.npm', module: 'jquery' + exclude group: 'org.webjars.npm', module: 'bootstrap' + } // Extras testImplementation enforcedPlatform(libs.junit.bom) @@ -122,6 +126,10 @@ publishing { } } +tasks.named('war') { + inputs.dir("$projectDir/src/main") +} + sonarqube { properties { property 'sonar.sources', 'src/main/java,src/main/webapp' diff --git a/opendcs-web-client/src/main/webapp/WEB-INF/app_pages/login.jsp b/opendcs-web-client/src/main/webapp/WEB-INF/app_pages/login.jsp index fb64b0a10..633afcf88 100644 --- a/opendcs-web-client/src/main/webapp/WEB-INF/app_pages/login.jsp +++ b/opendcs-web-client/src/main/webapp/WEB-INF/app_pages/login.jsp @@ -14,74 +14,61 @@ %> <%@include file="/WEB-INF/common/header.jspf" %> - + + + OpenDCS Login + - - <%@include file="/WEB-INF/common/top-bar.jspf" %> - - -
- - - - -
- - + <%@include file="/WEB-INF/common/top-bar.jspf" %> +
+
- - - - -
- +
-
- -

Login

- -
- User Icon -
- <% if (Objects.equals(authType, "sso") && authBasePath != null) { %> - - <% } else { %> -

- -

-

- -

- - <% } %> - - -
+
+

Login

+
+ User Icon +
+ <% if(!Objects.equals(authType, "sso") || authBasePath == null) + { %> +

+ +

+

+ +

+ <% } %> +

+ +

+ + +
+
+ <%@include file="/WEB-INF/common/footer.jspf" %>
- - - <%@include file="/WEB-INF/common/footer.jspf" %> - -
- - -
- - <%@include file="/WEB-INF/common/scripts.jspf" %> - - - - +
+ <%@include file="/WEB-INF/common/scripts.jspf" %> + + <% if (Objects.equals(authType, "sso") && authBasePath != null) { %> \ No newline at end of file diff --git a/opendcs-web-client/src/main/webapp/WEB-INF/common/scripts.jspf b/opendcs-web-client/src/main/webapp/WEB-INF/common/scripts.jspf index ce553c358..f3ff656a7 100644 --- a/opendcs-web-client/src/main/webapp/WEB-INF/common/scripts.jspf +++ b/opendcs-web-client/src/main/webapp/WEB-INF/common/scripts.jspf @@ -41,6 +41,7 @@ <%@include file="/WEB-INF/common/modals/waiting.jspf" %> <%@include file="/WEB-INF/common/modals/notification.jspf" %> <%@include file="/WEB-INF/common/modals/yesno.jspf" %> +<%@include file="/WEB-INF/common/modals/organizations.jspf" %> + <%-- /main navbar --%> \ No newline at end of file diff --git a/opendcs-web-client/src/main/webapp/resources/css/login-form.css b/opendcs-web-client/src/main/webapp/resources/css/login-form.css index 8db59920f..62ad72366 100644 --- a/opendcs-web-client/src/main/webapp/resources/css/login-form.css +++ b/opendcs-web-client/src/main/webapp/resources/css/login-form.css @@ -1,190 +1,198 @@ /* - * Copyright 2023 OpenDCS Consortium - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. + * Login form styling tuned for Bootstrap 5 themes + * - Uses Bootstrap CSS variables when possible + * - Keeps original layout + animations */ +/* LINKS & HEADINGS INSIDE THE LOGIN FORM */ -/* BASIC */ -/* -html { - background-color: #56baed; -} -*/ - -a { - color: #92badd; - display:inline-block; +#formContent a { + color: var(--bs-link-color, #0d6efd); text-decoration: none; font-weight: 400; } -h2 { +#formContent a:hover { + color: var(--bs-link-hover-color, #0a58ca); + text-decoration: underline; +} + +#formContent h2 { text-align: center; - font-size: 16px; + font-size: 1rem; font-weight: 600; text-transform: uppercase; - display:inline-block; - margin: 40px 8px 10px 8px; - color: #cccccc; + display: inline-block; + margin: 2.5rem 0 .75rem; + color: var(--bs-secondary-color, #6c757d); } -/* STRUCTURE */ +/* “Tab” style headings */ +#formContent h2.inactive { + color: var(--bs-secondary-color, #6c757d); +} + +#formContent h2.active { + color: var(--bs-body-color, #212529); + border-bottom: 2px solid var(--bs-primary, #0d6efd); +} + +/* LAYOUT / STRUCTURE */ + .wrapper { display: flex; align-items: center; - flex-direction: column; justify-content: center; + flex-direction: column; width: 100%; - min-height: 100%; - padding: 20px; + min-height: 100vh; + padding: 1.5rem; + background-color: transparent; } #formContent { - -webkit-border-radius: 10px 10px 10px 10px; - border-radius: 10px 10px 10px 10px; - background: #fff; - padding: 30px; - width: 90%; + background-color: var(--bs-body-bg, #fff); + border-radius: var(--bs-border-radius-lg, 0.5rem); + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); + width: 100%; max-width: 450px; position: relative; - padding: 0px; - -webkit-box-shadow: 0 30px 60px 0 rgba(0,0,0,0.3); - box-shadow: 0 30px 60px 0 rgba(0,0,0,0.3); + padding: 2rem 2rem 1rem; text-align: center; - padding-bottom: 10px; + margin: 0 auto; } #formFooter { - background-color: #f6f6f6; - border-top: 1px solid #dce8f1; - padding: 25px; + background-color: var(--bs-secondary-bg, #f8f9fa); + border-top: 1px solid var(--bs-border-color, #dee2e6); + padding: 1rem; text-align: center; - -webkit-border-radius: 0 0 10px 10px; - border-radius: 0 0 10px 10px; + border-radius: 0 0 var(--bs-border-radius-lg, 0.5rem) var(--bs-border-radius-lg, 0.5rem); + font-size: .875rem; } -/* TABS */ +/* FORM FIELDS + * Assumes your inputs/selects either: + * - have class="form-control" / "form-select" + * - or just rely on these type-based rules + */ -h2.inactive { - color: #cccccc; +/* Shared style: username, password, and raw organization look close to Bootstrap. + */ -/* FORM TYPOGRAPHY*/ - -input[type=button], input[type=submit], input[type=reset] { - background-color: #56baed; - border: none; - color: white; - padding: 15px 80px; - text-align: center; - text-decoration: none; +input[type="button"], +input[type="submit"], +input[type="reset"] { display: inline-block; + width: 100%; + margin: 1rem 0 1.5rem; + padding: .6rem 1.5rem; + font-size: .875rem; + font-weight: 600; text-transform: uppercase; - font-size: 13px; - -webkit-box-shadow: 0 10px 30px 0 rgba(95,186,233,0.4); - box-shadow: 0 10px 30px 0 rgba(95,186,233,0.4); - -webkit-border-radius: 5px 5px 5px 5px; - border-radius: 5px 5px 5px 5px; - margin: 5px 20px 40px 20px; - -webkit-transition: all 0.3s ease-in-out; - -moz-transition: all 0.3s ease-in-out; - -ms-transition: all 0.3s ease-in-out; - -o-transition: all 0.3s ease-in-out; - transition: all 0.3s ease-in-out; + text-align: center; + border-radius: var(--bs-border-radius-pill, var(--bs-border-radius, .375rem)); + border: 1px solid var(--bs-primary, #0d6efd); + color: #fff; + background-color: var(--bs-primary, #0d6efd); + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15); + cursor: pointer; + transition: background-color .15s ease-in-out, border-color .15s ease-in-out, box-shadow .15s ease-in-out, transform .1s ease-in-out; } -input[type=button]:hover, input[type=submit]:hover, input[type=reset]:hover { - background-color: #39ace7; +input[type="button"]:hover, +input[type="submit"]:hover, +input[type="reset"]:hover { + background-color: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .25); + filter: brightness(0.95); } -input[type=button]:active, input[type=submit]:active, input[type=reset]:active { - -moz-transform: scale(0.95); - -webkit-transform: scale(0.95); - -o-transform: scale(0.95); - -ms-transform: scale(0.95); - transform: scale(0.95); +input[type="button"]:active, +input[type="submit"]:active, +input[type="reset"]:active { + transform: scale(0.97); + box-shadow: 0 .25rem .5rem rgba(0, 0, 0, .2); } -input[type=text] { - background-color: #f6f6f6; - border: none; - color: #0d0d0d; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - margin: 5px; - width: 85%; - border: 2px solid #f6f6f6; - -webkit-transition: all 0.5s ease-in-out; - -moz-transition: all 0.5s ease-in-out; - -ms-transition: all 0.5s ease-in-out; - -o-transition: all 0.5s ease-in-out; - transition: all 0.5s ease-in-out; - -webkit-border-radius: 5px 5px 5px 5px; - border-radius: 5px 5px 5px 5px; -} +/* SELECT2 INTEGRATION (organization dropdown) */ -input[type=text]:focus { - background-color: #fff; - border-bottom: 2px solid #5fbae9; +#id_organization + .select2-container { + width: 100% !important; + margin-bottom: 1rem; } -input[type=text]:placeholder { - color: #cccccc; +.select2-container--default .select2-selection--single { + background-color: var(--bs-form-control-bg, #fff); + border: 1px solid var(--bs-border-color, #ced4da); + border-radius: var(--bs-border-radius, .375rem); + min-height: calc(2.25rem + 2px); + display: flex; + align-items: center; + padding: .375rem .75rem; + box-shadow: none; } -input[type=password] { - background-color: #f6f6f6; - border: none; - color: #0d0d0d; - padding: 15px 32px; - text-align: center; - text-decoration: none; - display: inline-block; - font-size: 16px; - margin: 5px; - width: 85%; - border: 2px solid #f6f6f6; - -webkit-transition: all 0.5s ease-in-out; - -moz-transition: all 0.5s ease-in-out; - -ms-transition: all 0.5s ease-in-out; - -o-transition: all 0.5s ease-in-out; - transition: all 0.5s ease-in-out; - -webkit-border-radius: 5px 5px 5px 5px; - border-radius: 5px 5px 5px 5px; +.select2-container--default .select2-selection--single .select2-selection__rendered { + color: var(--bs-body-color, #212529); + padding: 0; } -input[type=password]:focus { - background-color: #fff; - border-bottom: 2px solid #5fbae9; +.select2-container--default .select2-selection--single .select2-selection__arrow { + height: 100%; + right: .75rem; } -input[type=password]:placeholder { - color: #cccccc; +/* Focus state for Select2 to match Bootstrap */ +.select2-container--default.select2-container--focus .select2-selection--single { + border-color: var(--bs-primary, #0d6efd); + outline: 0; + box-shadow: 0 0 0 .25rem rgba(var(--bs-primary-rgb, 13, 110, 253), .25); } /* ANIMATIONS */ -/* Simple CSS3 Fade-in-down Animation */ .fadeInDown { -webkit-animation-name: fadeInDown; animation-name: fadeInDown; @@ -220,78 +228,53 @@ input[type=password]:placeholder { } } -/* Simple CSS3 Fade-in Animation */ +/* Simple fade-in */ + @-webkit-keyframes fadeIn { from { opacity:0; } to { opacity:1; } } @-moz-keyframes fadeIn { from { opacity:0; } to { opacity:1; } } @keyframes fadeIn { from { opacity:0; } to { opacity:1; } } .fadeIn { - opacity:0; - -webkit-animation:fadeIn ease-in 1; - -moz-animation:fadeIn ease-in 1; - animation:fadeIn ease-in 1; - - -webkit-animation-fill-mode:forwards; - -moz-animation-fill-mode:forwards; - animation-fill-mode:forwards; - - -webkit-animation-duration:1s; - -moz-animation-duration:1s; - animation-duration:1s; -} - -.fadeIn.first { - -webkit-animation-delay: 0.4s; - -moz-animation-delay: 0.4s; - animation-delay: 0.4s; + opacity: 0; + -webkit-animation: fadeIn ease-in 1; + -moz-animation: fadeIn ease-in 1; + animation: fadeIn ease-in 1; + -webkit-animation-fill-mode: forwards; + -moz-animation-fill-mode: forwards; + animation-fill-mode: forwards; + -webkit-animation-duration: 1s; + -moz-animation-duration: 1s; + animation-duration: 1s; } -.fadeIn.second { - -webkit-animation-delay: 0.6s; - -moz-animation-delay: 0.6s; - animation-delay: 0.6s; -} +.fadeIn.first { -webkit-animation-delay: 0.4s; -moz-animation-delay: 0.4s; animation-delay: 0.4s; } +.fadeIn.second { -webkit-animation-delay: 0.6s; -moz-animation-delay: 0.6s; animation-delay: 0.6s; } +.fadeIn.third { -webkit-animation-delay: 0.8s; -moz-animation-delay: 0.8s; animation-delay: 0.8s; } +.fadeIn.fourth { -webkit-animation-delay: 1s; -moz-animation-delay: 1s; animation-delay: 1s; } -.fadeIn.third { - -webkit-animation-delay: 0.8s; - -moz-animation-delay: 0.8s; - animation-delay: 0.8s; -} +/* Underline hover effect for links inside form */ -.fadeIn.fourth { - -webkit-animation-delay: 1s; - -moz-animation-delay: 1s; - animation-delay: 1s; -} - -/* Simple CSS3 Fade-in Animation */ .underlineHover:after { display: block; left: 0; - bottom: -10px; + bottom: -0.625rem; width: 0; height: 2px; - background-color: #56baed; + background-color: var(--bs-primary, #0d6efd); content: ""; transition: width 0.2s; } .underlineHover:hover { - color: #0d0d0d; + color: var(--bs-body-color, #212529); } -.underlineHover:hover:after{ +.underlineHover:hover:after { width: 100%; } - - -/* OTHERS */ - -*:focus { - outline: none; -} +/* ICON */ #icon { - width:60%; + width: 60%; } diff --git a/opendcs-web-client/src/main/webapp/resources/css/opendcs-shim.css b/opendcs-web-client/src/main/webapp/resources/css/opendcs-shim.css index 43dc916d9..9f1292f53 100644 --- a/opendcs-web-client/src/main/webapp/resources/css/opendcs-shim.css +++ b/opendcs-web-client/src/main/webapp/resources/css/opendcs-shim.css @@ -1,10 +1,26 @@ /* === OpenDCS shim v6 (cleaned) ============================================ */ :root { - --navbar-height: 56px; - --sidebar-width: 260px; - --sidebar-bg: #343a40; - --sidebar-card-bg: #f8f9fa; - --sidebar-border: rgba(0,0,0,.05); + --primary: #0d6efd; + --primary-dark: #0751c4; + --primary-soft: #e6f0ff; + --sidebar-bg: #064a9c; + --topbar-bg: #0d6efd; + --page-bg: #f5f7fb; + --card-bg: #ffffff; + --border-soft: #e1e5ee; + --text-main: #1e293b; + --text-muted: #6b7280; +} + +body.dark-mode { + --page-bg: #020617; + --card-bg: #020617; + --sidebar-bg: #020617; + --topbar-bg: #020617; + --text-main: #e5e7eb; + --text-muted: #9ca3af; + --border-soft: #1f2937; + --primary-soft: rgba(37, 99, 235, 0.15); } /* Offset the page for a fixed top navbar */ @@ -369,3 +385,19 @@ body.navbar-top .modal-dialog-scrollable .modal-content { background: var(--sidebar-sub-bg); border-left: 1px solid var(--sidebar-border); } + +.table thead th { + background-color: var(--primary-soft); + font-size: 0.75rem; + letter-spacing: 0.07em; + color: var(--text-main); +} + +.page-sidebar { + width: 230px; + background: linear-gradient( + 180deg, + var(--bs-primary) 0%, + color-mix(in srgb, var(--bs-primary) 80%, #000 20%) 100% + ); +} \ No newline at end of file diff --git a/opendcs-web-client/src/main/webapp/resources/js/computations.js b/opendcs-web-client/src/main/webapp/resources/js/computations.js index 69425ae4d..8be652f4d 100644 --- a/opendcs-web-client/src/main/webapp/resources/js/computations.js +++ b/opendcs-web-client/src/main/webapp/resources/js/computations.js @@ -504,115 +504,6 @@ document.addEventListener('DOMContentLoaded', function() { }); - params = {} - $.ajax({ - url: `${window.API_URL}/apprefs`, - type: "GET", - data: params, - success: function(response) { - var processList = response; - var optionAttributes = { - "value": "", - "id": "", - "comment": "", - "last_modified": "" - }; - var newOption = $("