diff --git a/dds-backoffice/README.md b/dds-backoffice/README.md new file mode 100644 index 0000000..3cba997 --- /dev/null +++ b/dds-backoffice/README.md @@ -0,0 +1,45 @@ +# DDS Permission Console + +`dds-backoffice`는 기존 VPD 백오피스와 코드·프로세스·포트를 분리한 Deep Data Security 전용 인스턴스입니다. + +- 기본 포트: `8083` +- 기본 화면: `/` 또는 `/dds` +- 기본 보호 객체: `ADMIN.V_DDS_CUSTOMERS_PG`, `ADMIN.V_DDS_CUSTOMERS_MY` +- 조회 방식: 선택한 DDS `END USER` 자격으로 직접 Oracle JDBC 연결 + +## VPD 인스턴스와의 차이 + +VPD 인스턴스는 공통 DB 계정으로 접속한 뒤 Bearer 사용자와 권한 테이블을 세션 컨텍스트에 저장하고 VPD 함수가 조건을 계산합니다. 이 앱은 그 경로를 재사용하지 않습니다. + +이 앱의 조회 경계는 다음과 같습니다. + +```text +END USER → DATA ROLE → DATA GRANT → V_DDS_CUSTOMERS_* → 조회 결과 +``` + +ORDS Handler가 Bearer 값을 `cb_dds_hr` 같은 문자열로 매핑하는 것만으로는 DDS 보안 컨텍스트가 전달되지 않습니다. 따라서 이 비교 인스턴스는 DDS `END USER` 직접 logon을 검증합니다. Bearer/ORDS 전달이 필요하면 지원 드라이버의 `EndUserSecurityContext`를 별도 설계해야 합니다. + +## 실행 + +먼저 기존 DDS SQL을 적용합니다. + +```bash +./run.sh dds-setup +``` + +그 다음 DDS 인스턴스를 실행합니다. + +```bash +cd dds-backoffice +export DDS_BACKOFFICE_DB_URL="${BACKOFFICE_DB_URL}" +export DDSUSER_MY_PASSWORD='...' +export DDSUSER_PG_PASSWORD='...' +export DDSUSER_BOTH_PASSWORD='...' +export DDSUSER_NONE_PASSWORD='...' +mvn -DskipTests package +java -jar target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar +``` + +관리자 로그인은 `DDS_BACKOFFICE_ADMIN_USER` / `DDS_BACKOFFICE_ADMIN_PASSWORD`를 사용합니다. DDS END USER 비밀번호는 서버 환경 변수로만 읽고 화면에 표시하거나 저장하지 않습니다. + +VM 배포는 `scripts/deploy-dds-backoffice-vm.sh`를 사용합니다. 스크립트는 기존 `/home/opc/apps/vpd-backoffice`의 wallet과 환경값을 읽어 `/home/opc/apps/dds-backoffice`를 별도 프로세스로 기동합니다. 기본 바인딩은 `127.0.0.1:8083`이며, 인터넷 공개는 HTTPS reverse proxy와 NSG/firewall 승인을 별도로 거쳐야 합니다. diff --git a/dds-backoffice/pom.xml b/dds-backoffice/pom.xml new file mode 100644 index 0000000..883b11e --- /dev/null +++ b/dds-backoffice/pom.xml @@ -0,0 +1,64 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.8 + + + + com.cloudhandson + dds-permission-backoffice + 0.1.0-SNAPSHOT + dds-permission-backoffice + Dedicated Deep Data Security permission comparison console + + + 21 + 23.6.0.24.10 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-security + + + com.oracle.database.jdbc + ojdbc11 + ${oracle.jdbc.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java new file mode 100644 index 0000000..faaa1b9 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/DdsBackofficeApplication.java @@ -0,0 +1,15 @@ +package com.cloudhandson.ddsbackoffice; + +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties(DdsProperties.class) +public class DdsBackofficeApplication { + + public static void main(String[] args) { + SpringApplication.run(DdsBackofficeApplication.class, args); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/AppConfig.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/AppConfig.java new file mode 100644 index 0000000..db974d5 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/AppConfig.java @@ -0,0 +1,26 @@ +package com.cloudhandson.ddsbackoffice.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(BackofficeProperties.class) +public class AppConfig { + + @Bean + DdsDatabaseDriver ddsDatabaseDriver() { + return new DdsDatabaseDriver(); + } + + static final class DdsDatabaseDriver { + + DdsDatabaseDriver() { + try { + Class.forName("oracle.jdbc.OracleDriver"); + } catch (ClassNotFoundException e) { + throw new IllegalStateException("Oracle JDBC 드라이버를 찾을 수 없습니다.", e); + } + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/BackofficeProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/BackofficeProperties.java new file mode 100644 index 0000000..3c672bb --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/BackofficeProperties.java @@ -0,0 +1,10 @@ +package com.cloudhandson.ddsbackoffice.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "backoffice") +public record BackofficeProperties(Security security) { + + public record Security(String adminUser, String adminPassword, boolean requireHttps) { + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java new file mode 100644 index 0000000..bcdc110 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/DdsProperties.java @@ -0,0 +1,63 @@ +package com.cloudhandson.ddsbackoffice.config; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "dds") +public record DdsProperties( + String dbUrl, + Duration queryTimeout, + String pgObject, + String myObject, + Map users +) { + + private static final Pattern OBJECT_NAME = Pattern.compile( + "[A-Za-z][A-Za-z0-9_$#]*(\\.[A-Za-z][A-Za-z0-9_$#]*)*" + ); + + public DdsProperties { + dbUrl = dbUrl == null ? "" : dbUrl.trim(); + queryTimeout = queryTimeout == null ? Duration.ofSeconds(10) : queryTimeout; + pgObject = normalizeObject(pgObject, "ADMIN.V_DDS_CUSTOMERS_PG"); + myObject = normalizeObject(myObject, "ADMIN.V_DDS_CUSTOMERS_MY"); + var normalizedUsers = new LinkedHashMap(); + if (users != null) { + users.forEach((key, value) -> { + if (key != null && value != null) { + normalizedUsers.put(key.trim().toLowerCase(), value); + } + }); + } + users = Map.copyOf(normalizedUsers); + } + + public String objectFor(String sourceKey) { + if ("PG".equalsIgnoreCase(sourceKey)) { + return pgObject; + } + if ("MY".equalsIgnoreCase(sourceKey)) { + return myObject; + } + throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다."); + } + + private static String normalizeObject(String value, String fallback) { + var candidate = value == null || value.isBlank() ? fallback : value.trim(); + if (!OBJECT_NAME.matcher(candidate).matches()) { + throw new IllegalArgumentException("DDS 보호 객체 이름은 스키마.객체 형식이어야 합니다."); + } + return candidate.toUpperCase(); + } + + public record User(String label, String description, String username, String password) { + + public boolean configured() { + return username != null && !username.isBlank() + && password != null && !password.isBlank(); + } + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java new file mode 100644 index 0000000..64084c4 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.cloudhandson.ddsbackoffice.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +public class SecurityConfig { + + @Bean + SecurityFilterChain securityFilterChain( + HttpSecurity http, + BackofficeProperties properties + ) throws Exception { + if (properties.security().requireHttps()) { + http.requiresChannel(channel -> channel.anyRequest().requiresSecure()); + } + + return http + .headers(headers -> headers.httpStrictTransportSecurity(hsts -> hsts + .includeSubDomains(true) + .maxAgeInSeconds(31_536_000))) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/css/**", "/js/**", "/health", "/login").permitAll() + .anyRequest().authenticated()) + .httpBasic(basic -> { + }) + .formLogin(login -> login + .loginPage("/login") + .permitAll()) + .logout(logout -> logout.logoutSuccessUrl("/login?logout")) + .build(); + } + + @Bean + UserDetailsService userDetailsService( + BackofficeProperties properties, + PasswordEncoder passwordEncoder + ) { + var security = properties.security(); + var user = User.withUsername(security.adminUser()) + .password(passwordEncoder.encode(security.adminPassword())) + .roles("ADMIN") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsCustomerRow.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsCustomerRow.java new file mode 100644 index 0000000..de1dd73 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsCustomerRow.java @@ -0,0 +1,12 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.time.LocalDateTime; + +public record DdsCustomerRow( + long customerId, + String fullName, + String email, + LocalDateTime signupDate, + String region +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsQueryResult.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsQueryResult.java new file mode 100644 index 0000000..2f4eeff --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsQueryResult.java @@ -0,0 +1,27 @@ +package com.cloudhandson.ddsbackoffice.domain; + +import java.util.List; + +public record DdsQueryResult( + String userKey, + String sourceKey, + String userLabel, + String sourceLabel, + String objectName, + boolean success, + String title, + String message, + String sessionUser, + String endUser, + Integer oracleCode, + List rows +) { + + public DdsQueryResult { + rows = rows == null ? List.of() : List.copyOf(rows); + } + + public boolean hasRows() { + return !rows.isEmpty(); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSourceOption.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSourceOption.java new file mode 100644 index 0000000..617966a --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsSourceOption.java @@ -0,0 +1,9 @@ +package com.cloudhandson.ddsbackoffice.domain; + +public record DdsSourceOption( + String key, + String label, + String objectName, + String description +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsUserOption.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsUserOption.java new file mode 100644 index 0000000..442675e --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/domain/DdsUserOption.java @@ -0,0 +1,10 @@ +package com.cloudhandson.ddsbackoffice.domain; + +public record DdsUserOption( + String key, + String label, + String username, + String description, + boolean configured +) { +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java new file mode 100644 index 0000000..36d1a5a --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/service/DdsQueryService.java @@ -0,0 +1,227 @@ +package com.cloudhandson.ddsbackoffice.service; + +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import com.cloudhandson.ddsbackoffice.domain.DdsCustomerRow; +import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult; +import com.cloudhandson.ddsbackoffice.domain.DdsSourceOption; +import com.cloudhandson.ddsbackoffice.domain.DdsUserOption; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Duration; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.regex.Pattern; +import org.springframework.stereotype.Service; + +@Service +public class DdsQueryService { + + private static final Pattern SAFE_SOURCE = Pattern.compile("PG|MY"); + private final DdsProperties properties; + + public DdsQueryService(DdsProperties properties) { + this.properties = properties; + } + + public List users() { + return properties.users().entrySet().stream() + .map(entry -> { + var user = entry.getValue(); + return new DdsUserOption( + entry.getKey(), + valueOrDefault(user.label(), entry.getKey()), + valueOrDefault(user.username(), "미설정"), + valueOrDefault(user.description(), "DDS 보안 사용자"), + user.configured() + ); + }) + .toList(); + } + + public List sources() { + return List.of( + new DdsSourceOption( + "PG", "PostgreSQL 원본", properties.pgObject(), + "DDS DATA GRANT가 허용한 PostgreSQL 데이터만 반환합니다." + ), + new DdsSourceOption( + "MY", "MySQL 원본", properties.myObject(), + "DDS DATA GRANT가 허용한 MySQL 데이터만 반환합니다." + ) + ); + } + + public DdsQueryResult query(String userKey, String sourceKey, String searchText, int requestedLimit) { + var normalizedUserKey = normalizeUserKey(userKey); + var normalizedSourceKey = normalizeSourceKey(sourceKey); + var user = properties.users().get(normalizedUserKey); + var source = sources().stream() + .filter(option -> option.key().equals(normalizedSourceKey)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다.")); + var userLabel = user == null ? normalizedUserKey : valueOrDefault(user.label(), normalizedUserKey); + + if (user == null) { + return failure(normalizedUserKey, normalizedSourceKey, userLabel, source, + "사용자를 찾을 수 없습니다.", "선택한 DDS END USER가 설정에 없습니다.", null); + } + if (properties.dbUrl().isBlank()) { + return failure(normalizedUserKey, normalizedSourceKey, userLabel, source, + "DB 접속 정보가 없습니다.", "DDS_BACKOFFICE_DB_URL 또는 BACKOFFICE_DB_URL을 설정하세요.", null); + } + if (!user.configured()) { + return failure(normalizedUserKey, normalizedSourceKey, userLabel, source, + "DDS 사용자 자격증명이 설정되지 않았습니다.", + "이 인스턴스의 환경 변수에 해당 END USER 비밀번호를 설정하세요.", null); + } + + var limit = Math.max(1, Math.min(requestedLimit, 100)); + var search = searchText == null ? "" : searchText.trim(); + try (Connection connection = DriverManager.getConnection( + properties.dbUrl(), user.username(), user.password())) { + connection.setReadOnly(true); + var timeoutSeconds = timeoutSeconds(properties.queryTimeout()); + var sessionUser = readSingleValue(connection, + "SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual", timeoutSeconds); + var endUser = readSingleValue(connection, + "SELECT ORA_END_USER_CONTEXT.username FROM dual", timeoutSeconds); + + var sql = "SELECT customer_id, full_name, email, signup_date, region FROM " + + source.objectName() + + (search.isBlank() ? "" : " WHERE UPPER(full_name) LIKE ?") + + " ORDER BY customer_id FETCH FIRST " + limit + " ROWS ONLY"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setQueryTimeout(timeoutSeconds); + if (!search.isBlank()) { + statement.setString(1, "%" + search.toUpperCase(Locale.ROOT) + "%"); + } + try (ResultSet resultSet = statement.executeQuery()) { + var rows = new java.util.ArrayList(); + while (resultSet.next()) { + Timestamp timestamp = resultSet.getTimestamp("signup_date"); + rows.add(new DdsCustomerRow( + resultSet.getLong("customer_id"), + resultSet.getString("full_name"), + resultSet.getString("email"), + timestamp == null ? null : timestamp.toLocalDateTime(), + resultSet.getString("region") + )); + } + return new DdsQueryResult( + normalizedUserKey, normalizedSourceKey, userLabel, source.label(), source.objectName(), + true, "DDS 조회가 완료되었습니다.", + rows.isEmpty() ? "조건에 맞는 행이 없습니다." : rows.size() + "개 행이 DDS 정책을 통과했습니다.", + sessionUser, endUser, null, rows + ); + } + } + } catch (SQLException e) { + return failure(normalizedUserKey, normalizedSourceKey, userLabel, source, + classifyTitle(e), classifyMessage(e), oracleCode(e)); + } + } + + private DdsQueryResult failure( + String userKey, + String sourceKey, + String userLabel, + DdsSourceOption source, + String title, + String message, + Integer oracleCode + ) { + return new DdsQueryResult( + userKey, sourceKey, userLabel, source.label(), source.objectName(), + false, title, message, null, null, oracleCode, List.of() + ); + } + + private String readSingleValue(Connection connection, String sql, int timeoutSeconds) + throws SQLException { + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setQueryTimeout(timeoutSeconds); + try (ResultSet resultSet = statement.executeQuery()) { + return resultSet.next() ? resultSet.getString(1) : null; + } + } + } + + private static String normalizeUserKey(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } + + private static String normalizeSourceKey(String value) { + var normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + if (!SAFE_SOURCE.matcher(normalized).matches()) { + throw new IllegalArgumentException("지원하지 않는 DDS 데이터 소스입니다."); + } + return normalized; + } + + private static int timeoutSeconds(Duration duration) { + return Math.max(1, (int) Math.ceil(duration.toMillis() / 1000.0)); + } + + private static String valueOrDefault(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static Integer oracleCode(SQLException exception) { + SQLException current = exception; + while (current != null) { + if (current.getErrorCode() != 0) { + return Math.abs(current.getErrorCode()); + } + current = current.getNextException(); + } + return null; + } + + private static String flattenedMessage(SQLException exception) { + var messages = new StringBuilder(); + SQLException current = exception; + while (current != null) { + if (messages.length() > 0) { + messages.append("; "); + } + messages.append(Objects.toString(current.getMessage(), "")); + current = current.getNextException(); + } + return messages.toString().replaceAll("\\s+", " ").trim(); + } + + private static String classifyTitle(SQLException exception) { + var message = flattenedMessage(exception).toUpperCase(Locale.ROOT); + if (message.contains("ORA-00942")) { + return "이 DDS 사용자에게 데이터가 허용되지 않았습니다."; + } + if (message.contains("ORA-01017")) { + return "DDS 사용자 인증에 실패했습니다."; + } + if (message.contains("ORA-12154") || message.contains("ORA-12514") + || message.contains("IO ERROR") || message.contains("CONNECTION")) { + return "DDS 데이터베이스에 연결할 수 없습니다."; + } + return "DDS 조회 중 데이터베이스 오류가 발생했습니다."; + } + + private static String classifyMessage(SQLException exception) { + var message = flattenedMessage(exception); + var upper = message.toUpperCase(Locale.ROOT); + if (upper.contains("ORA-00942")) { + return "해당 END USER에 연결된 DATA ROLE의 DATA GRANT가 없어 보호 객체가 보이지 않습니다. DDS의 default deny 결과입니다."; + } + if (upper.contains("ORA-01017")) { + return "서버에 등록된 DDS END USER 비밀번호와 일치하지 않습니다."; + } + if (upper.contains("ORA-12154") || upper.contains("ORA-12514")) { + return "TNS 별칭 또는 서비스 이름을 확인하세요."; + } + return "DB 접속 정보와 DDS DATA GRANT 설정을 확인하세요. 상세 코드는 운영 로그에서 확인합니다."; + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java new file mode 100644 index 0000000..2545a08 --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/DdsController.java @@ -0,0 +1,56 @@ +package com.cloudhandson.ddsbackoffice.web; + +import com.cloudhandson.ddsbackoffice.domain.DdsQueryResult; +import com.cloudhandson.ddsbackoffice.service.DdsQueryService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class DdsController { + + private final DdsQueryService service; + + public DdsController(DdsQueryService service) { + this.service = service; + } + + @GetMapping({"/", "/dds"}) + public String page(Model model) { + addOptions(model, "both", "PG", null); + return "dds"; + } + + @PostMapping("/dds/query") + public String query( + @RequestParam String userKey, + @RequestParam String sourceKey, + @RequestParam(required = false) String searchText, + @RequestParam(defaultValue = "20") int limit, + Model model + ) { + DdsQueryResult result; + try { + result = service.query(userKey, sourceKey, searchText, limit); + } catch (IllegalArgumentException e) { + result = new DdsQueryResult( + userKey, sourceKey, userKey, sourceKey, "-", false, + "입력값을 확인하세요.", e.getMessage(), null, null, null, java.util.List.of() + ); + } + addOptions(model, userKey, sourceKey, result); + return "dds"; + } + + private void addOptions(Model model, String userKey, String sourceKey, DdsQueryResult result) { + var users = service.users(); + model.addAttribute("users", users); + model.addAttribute("configuredUserCount", users.stream().filter(user -> user.configured()).count()); + model.addAttribute("sources", service.sources()); + model.addAttribute("selectedUser", userKey); + model.addAttribute("selectedSource", sourceKey); + model.addAttribute("queryResult", result); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/HealthController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/HealthController.java new file mode 100644 index 0000000..70bc12d --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/HealthController.java @@ -0,0 +1,14 @@ +package com.cloudhandson.ddsbackoffice.web; + +import java.util.Map; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthController { + + @GetMapping("/health") + public Map health() { + return Map.of("status", "UP", "track", "DDS"); + } +} diff --git a/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/LoginController.java b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/LoginController.java new file mode 100644 index 0000000..c3ae4bf --- /dev/null +++ b/dds-backoffice/src/main/java/com/cloudhandson/ddsbackoffice/web/LoginController.java @@ -0,0 +1,13 @@ +package com.cloudhandson.ddsbackoffice.web; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class LoginController { + + @GetMapping("/login") + public String login() { + return "login"; + } +} diff --git a/dds-backoffice/src/main/resources/application.yml b/dds-backoffice/src/main/resources/application.yml new file mode 100644 index 0000000..5a11050 --- /dev/null +++ b/dds-backoffice/src/main/resources/application.yml @@ -0,0 +1,50 @@ +spring: + application: + name: dds-permission-backoffice + thymeleaf: + cache: false + +server: + address: ${DDS_BACKOFFICE_BIND_ADDRESS:0.0.0.0} + port: ${DDS_BACKOFFICE_PORT:8083} + forward-headers-strategy: ${DDS_BACKOFFICE_FORWARD_HEADERS_STRATEGY:none} + servlet: + session: + cookie: + name: DDS_BACKOFFICE_SESSION + secure: ${DDS_BACKOFFICE_SESSION_COOKIE_SECURE:false} + http-only: true + same-site: lax + +backoffice: + security: + admin-user: ${DDS_BACKOFFICE_ADMIN_USER:${BACKOFFICE_ADMIN_USER:admin}} + admin-password: ${DDS_BACKOFFICE_ADMIN_PASSWORD:${BACKOFFICE_ADMIN_PASSWORD:admin}} + require-https: ${DDS_BACKOFFICE_REQUIRE_HTTPS:false} + +dds: + db-url: ${DDS_BACKOFFICE_DB_URL:${BACKOFFICE_DB_URL:jdbc:oracle:thin:@localhost:1521/FREEPDB1}} + query-timeout: ${DDS_BACKOFFICE_QUERY_TIMEOUT:10s} + pg-object: ${DDS_BACKOFFICE_PG_OBJECT:ADMIN.V_DDS_CUSTOMERS_PG} + my-object: ${DDS_BACKOFFICE_MY_OBJECT:ADMIN.V_DDS_CUSTOMERS_MY} + users: + my: + label: MY 전용 사용자 + description: MySQL 원본만 허용하는 DATA ROLE + username: ${DDS_BACKOFFICE_MY_USERNAME:ddsuser_my} + password: ${DDS_BACKOFFICE_MY_PASSWORD:${DDSUSER_MY_PASSWORD:}} + pg: + label: PG 전용 사용자 + description: PostgreSQL 원본만 허용하는 DATA ROLE + username: ${DDS_BACKOFFICE_PG_USERNAME:ddsuser_pg} + password: ${DDS_BACKOFFICE_PG_PASSWORD:${DDSUSER_PG_PASSWORD:}} + both: + label: 통합 사용자 + description: 두 원본을 모두 허용하는 DATA ROLE + username: ${DDS_BACKOFFICE_BOTH_USERNAME:ddsuser_both} + password: ${DDS_BACKOFFICE_BOTH_PASSWORD:${DDSUSER_BOTH_PASSWORD:}} + none: + label: 차단 사용자 + description: 접속만 가능하고 DATA GRANT가 없는 사용자 + username: ${DDS_BACKOFFICE_NONE_USERNAME:ddsuser_none} + password: ${DDS_BACKOFFICE_NONE_PASSWORD:${DDSUSER_NONE_PASSWORD:}} diff --git a/dds-backoffice/src/main/resources/static/css/dds.css b/dds-backoffice/src/main/resources/static/css/dds.css new file mode 100644 index 0000000..b53865c --- /dev/null +++ b/dds-backoffice/src/main/resources/static/css/dds.css @@ -0,0 +1,74 @@ +:root { + --dds-ink: #17213a; + --dds-muted: #68738a; + --dds-line: #dfe5ef; + --dds-surface: #ffffff; + --dds-bg: #f4f7fb; + --dds-accent: #3157d5; + --dds-accent-dark: #203a9d; + --dds-success: #11765a; + --dds-warning: #9a6512; +} + +* { box-sizing: border-box; } +body { margin: 0; background: var(--dds-bg); color: var(--dds-ink); font-family: Inter, "Noto Sans KR", system-ui, -apple-system, sans-serif; } +.dds-nav { background: #101a36; color: #fff; padding: 15px 0; box-shadow: 0 2px 12px rgba(16, 26, 54, .18); } +.brand { color: #fff; text-decoration: none; font-weight: 700; letter-spacing: -.02em; } +.track-badge { border: 1px solid rgba(255,255,255,.28); border-radius: 999px; color: #dbe4ff; font-size: .78rem; padding: 4px 10px; } +.hero { padding: 48px 0 26px; max-width: 920px; } +.hero h1 { font-size: clamp(2rem, 4vw, 3.2rem); letter-spacing: -.05em; line-height: 1.15; margin: 10px 0 15px; } +.hero .lead { color: var(--dds-muted); font-size: 1.1rem; line-height: 1.75; max-width: 820px; } +.eyebrow { color: var(--dds-accent); font-size: .72rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; } +.dds-nav .track-badge, .dds-nav .eyebrow { color: #dbe4ff; } +.explanation { background: #edf2ff; border: 1px solid #d4defe; border-radius: 12px; color: #34466e; margin-top: 22px; padding: 14px 17px; } +.explanation summary { cursor: pointer; font-weight: 700; } +.explanation p { line-height: 1.65; margin: 12px 0 0; } +.flow-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; margin: 5px 0 28px; } +.flow-card { align-items: flex-start; background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 14px; display: flex; gap: 14px; min-height: 142px; padding: 20px; } +.flow-number { align-items: center; background: #e8edff; border-radius: 50%; color: var(--dds-accent); display: inline-flex; flex: 0 0 32px; font-weight: 800; height: 32px; justify-content: center; } +.result-number { background: #e2f4ed; color: var(--dds-success); } +.flow-card h2 { font-size: 1rem; margin: 5px 0 7px; } +.flow-card p { color: var(--dds-muted); font-size: .9rem; line-height: 1.55; margin: 0; } +.panel { background: var(--dds-surface); border: 1px solid var(--dds-line); border-radius: 16px; margin: 18px 0; padding: 25px; box-shadow: 0 5px 18px rgba(31, 51, 93, .04); } +.panel-heading { align-items: flex-start; display: flex; justify-content: space-between; gap: 16px; } +.panel-heading h2 { font-size: 1.35rem; letter-spacing: -.03em; margin: 6px 0 6px; } +.panel-heading p { color: var(--dds-muted); margin: 0; } +.query-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; margin-top: 22px; } +.query-grid label { color: #34415c; font-size: .9rem; font-weight: 700; } +.query-grid .form-control, .query-grid .form-select { margin-top: 7px; } +.field-help { color: var(--dds-muted); display: block; font-size: .78rem; font-weight: 400; margin-top: 6px; } +.query-submit { align-items: end; display: flex; } +.query-submit .btn { min-height: 45px; width: 100%; } +.matrix-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 20px; } +.matrix-card { background: #f5f7ff; border: 1px solid #dce3ff; border-radius: 12px; display: flex; flex-direction: column; gap: 6px; min-height: 118px; padding: 16px; } +.matrix-card strong { color: var(--dds-accent-dark); } +.matrix-card span { font-size: .9rem; font-weight: 700; } +.matrix-card small { color: var(--dds-muted); line-height: 1.4; } +.matrix-card.muted { background: #f8f8f8; border-color: #e5e5e5; } +.matrix-card.muted strong { color: #5b6270; } +.result-heading { align-items: center; } +.result-heading p { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .8rem; } +.result-status { border-radius: 999px; color: var(--dds-warning); background: #fff4dc; font-size: .8rem; font-weight: 800; padding: 7px 12px; } +.result-status.success { color: var(--dds-success); background: #e3f6ed; } +.result-status.blocked { color: #a34242; background: #fde8e8; } +.technical-code { color: var(--dds-muted); display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .78rem; margin-top: 5px; } +.context-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 18px 0; } +.context-grid > div { background: #f7f9fc; border: 1px solid var(--dds-line); border-radius: 10px; padding: 12px 14px; } +.context-grid span { color: var(--dds-muted); display: block; font-size: .72rem; font-weight: 700; letter-spacing: .05em; } +.context-grid strong { display: block; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-top: 5px; } +.table { margin-top: 8px; } +.table thead th { color: var(--dds-muted); font-size: .75rem; letter-spacing: .05em; text-transform: uppercase; } +.empty-result { background: #f7f9fc; border-radius: 10px; color: var(--dds-muted); padding: 18px; text-align: center; } +.login-page { align-items: center; display: flex; justify-content: center; min-height: 100vh; padding: 22px; } +.login-card { background: #fff; border: 1px solid var(--dds-line); border-radius: 16px; box-shadow: 0 15px 45px rgba(31, 51, 93, .12); max-width: 420px; padding: 35px; width: 100%; } +.login-card h1 { font-size: 1.65rem; margin: 8px 0; } +.login-card p { color: var(--dds-muted); margin-bottom: 26px; } +@media (max-width: 800px) { + .flow-grid, .matrix-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .query-grid, .context-grid { grid-template-columns: 1fr; } +} +@media (max-width: 540px) { + .flow-grid, .matrix-grid { grid-template-columns: 1fr; } + .panel { padding: 19px; } + .hero { padding-top: 30px; } +} diff --git a/dds-backoffice/src/main/resources/templates/dds.html b/dds-backoffice/src/main/resources/templates/dds.html new file mode 100644 index 0000000..26342f8 --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/dds.html @@ -0,0 +1,151 @@ + + + + + + Deep Data Security 접근 관리 + + + + + + +
+
+ DEEP DATA SECURITY · DIRECT END USER +

선언형 데이터 권한을 실제 조회로 확인합니다.

+

VPD 트랙과 분리된 DDS 전용 인스턴스입니다. 선택한 END USER로 직접 접속해 Oracle의 DATA ROLE과 DATA GRANT가 적용된 결과를 보여줍니다.

+
+ 이 화면의 보안 경계 +

이 인스턴스는 VPD 권한 테이블을 읽어 조건을 계산하지 않습니다. END USER → DATA ROLE → DATA GRANT → 보호 VIEW가 전부 Oracle DDS에 선언되어 있고, 조회 결과는 그 선언의 적용 결과입니다.

+

Bearer 키를 ORDS Handler에서 사용자 이름으로 바꾸는 것만으로는 DDS 보안 컨텍스트가 만들어지지 않습니다. 그래서 이 비교 트랙은 직접 END USER logon을 검증합니다.

+
+
+ +
+
+ 1 +
IDENTITY

END USER

실제 DDS 보안 사용자로 DB에 접속합니다.

+
+
+ 2 +
POLICY

DATA ROLE / GRANT

원본별 허용 범위가 선언형으로 연결됩니다.

+
+
+ 3 +
3EVIDENCE

조회 결과

허용된 VIEW와 행만 반환됩니다.

+
+
+ +
+ DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 DDSUSER_*_PASSWORD 또는 DDS_BACKOFFICE_*_PASSWORD 환경 변수를 설정해야 조회할 수 있습니다. +
+ +
+
+
+ 1 · SECURITY SUBJECT +

조회 주체와 데이터 원본 선택

+

선택한 DDS END USER의 권한으로 직접 연결합니다. 비밀번호는 화면에 표시하거나 저장하지 않습니다.

+
+
+
+ + + + + +
+
+
+ +
+
+
+ 2 · EXPECTED MATRIX +

원본별 권한 매트릭스

+

VPD와 같은 4가지 업무 사례를 DDS의 선언형 권한으로 재현합니다.

+
+
+
+
ddsuser_myMY 원본 허용PG 객체는 ORA-00942
+
ddsuser_pgPG 원본 허용MY 객체는 ORA-00942
+
ddsuser_bothPG + MY 허용두 DATA GRANT 모두 적용
+
ddsuser_none접속만 허용데이터 권한 없음 · default deny
+
+
+ +
+
+
+ 3 · EVIDENCE +

조회 결과

+

ADMIN.V_DDS_CUSTOMERS_PG

+
+ 상태 +
+
+ 결과 + 메시지 + +
+
+
SESSION_USER-
+
ORA_END_USER_CONTEXT-
+
반환 행0
+
+
+ + + + + + + + + + + +
ID이름이메일가입일지역
1Alicealice@example.com2026-01-01APAC
+
+
조회 조건을 통과한 행이 없습니다.
+
+ 판정 근거와 운영 메모 +

성공한 경우 연결 시점의 SESSION_USERORA_END_USER_CONTEXT를 함께 확인할 수 있습니다. 차단된 경우 DDS가 보호 객체 자체를 숨겨 ORA-00942를 반환하는 것이 정상입니다.

+

현재 앱은 결과만 읽습니다. 권한 변경은 CREATE DATA ROLE, GRANT DATA ROLE, CREATE DATA GRANT 승인 절차를 거쳐 SQL로 관리합니다.

+
+
+
+ + diff --git a/dds-backoffice/src/main/resources/templates/login.html b/dds-backoffice/src/main/resources/templates/login.html new file mode 100644 index 0000000..66886dc --- /dev/null +++ b/dds-backoffice/src/main/resources/templates/login.html @@ -0,0 +1,27 @@ + + + + + + DDS Permission Console 로그인 + + + + +
+ DEEP DATA SECURITY +

DDS Permission Console

+

VPD와 분리된 DDS 전용 인스턴스입니다.

+
관리자 인증에 실패했습니다.
+
로그아웃되었습니다.
+
+ + + + + + +
+
+ + diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java new file mode 100644 index 0000000..3e10840 --- /dev/null +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/config/DdsPropertiesTest.java @@ -0,0 +1,35 @@ +package com.cloudhandson.ddsbackoffice.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DdsPropertiesTest { + + @Test + void normalizesObjectsAndUserKeys() { + var properties = new DdsProperties( + "jdbc:test", + Duration.ofSeconds(5), + "admin.v_dds_customers_pg", + "admin.v_dds_customers_my", + Map.of("MY", new DdsProperties.User("MY", "source", "ddsuser_my", "secret")) + ); + + assertEquals("ADMIN.V_DDS_CUSTOMERS_PG", properties.objectFor("pg")); + assertEquals("ADMIN.V_DDS_CUSTOMERS_MY", properties.objectFor("MY")); + assertTrue(properties.users().containsKey("my")); + assertTrue(properties.users().get("my").configured()); + } + + @Test + void rejectsUnsafeObjectNames() { + assertThrows(IllegalArgumentException.class, () -> new DdsProperties( + "jdbc:test", Duration.ofSeconds(5), "ADMIN.VIEW;DROP", "ADMIN.VIEW", Map.of() + )); + } +} diff --git a/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsQueryServiceTest.java b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsQueryServiceTest.java new file mode 100644 index 0000000..070b14d --- /dev/null +++ b/dds-backoffice/src/test/java/com/cloudhandson/ddsbackoffice/service/DdsQueryServiceTest.java @@ -0,0 +1,37 @@ +package com.cloudhandson.ddsbackoffice.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.cloudhandson.ddsbackoffice.config.DdsProperties; +import java.time.Duration; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DdsQueryServiceTest { + + @Test + void reportsMissingDatabaseConfigurationWithoutOpeningAConnection() { + var properties = new DdsProperties( + "", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY", + Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret")) + ); + + var result = new DdsQueryService(properties).query("my", "PG", "", 20); + + assertFalse(result.success()); + assertTrue(result.title().contains("DB 접속")); + } + + @Test + void rejectsUnknownSourceBeforeOpeningAConnection() { + var properties = new DdsProperties( + "jdbc:test", Duration.ofSeconds(5), "ADMIN.V_DDS_CUSTOMERS_PG", "ADMIN.V_DDS_CUSTOMERS_MY", + Map.of("my", new DdsProperties.User("MY", "source", "ddsuser_my", "secret")) + ); + + assertThrows(IllegalArgumentException.class, + () -> new DdsQueryService(properties).query("my", "RDS", "", 20)); + } +} diff --git a/scripts/deploy-dds-backoffice-local.sh b/scripts/deploy-dds-backoffice-local.sh new file mode 100755 index 0000000..81409d4 --- /dev/null +++ b/scripts/deploy-dds-backoffice-local.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# Deploy the dedicated DDS backoffice beside the existing VPD instance. +set -Eeuo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_DIR="${DDS_BACKOFFICE_APP_DIR:-/home/opc/apps/dds-backoffice}" +PORT="${DDS_BACKOFFICE_PORT:-8083}" + +cd "$ROOT/dds-backoffice" +mvn -DskipTests package +JAR="$ROOT/dds-backoffice/target/dds-permission-backoffice-0.1.0-SNAPSHOT.jar" +[[ -f "$JAR" ]] || { echo "missing jar: $JAR" >&2; exit 1; } + +install -d -o opc -g opc "$APP_DIR" + +cat > "$APP_DIR/start.sh" <<'START' +#!/usr/bin/env bash +set -Eeuo pipefail +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$APP_DIR" +set -a +if [[ -f /home/opc/apps/vpd-backoffice/.env ]]; then + . /home/opc/apps/vpd-backoffice/.env +fi +if [[ -f "$APP_DIR/.env" ]]; then + . "$APP_DIR/.env" +fi +set +a + +export DDS_BACKOFFICE_PORT="${DDS_BACKOFFICE_PORT:-8083}" +export DDS_BACKOFFICE_BIND_ADDRESS="${DDS_BACKOFFICE_BIND_ADDRESS:-127.0.0.1}" +export DDS_BACKOFFICE_DB_URL="${DDS_BACKOFFICE_DB_URL:-${BACKOFFICE_DB_URL:-}}" +export DDS_BACKOFFICE_PG_PASSWORD="${DDS_BACKOFFICE_PG_PASSWORD:-${DDSUSER_PG_PASSWORD:-}}" +export DDS_BACKOFFICE_MY_PASSWORD="${DDS_BACKOFFICE_MY_PASSWORD:-${DDSUSER_MY_PASSWORD:-}}" +export DDS_BACKOFFICE_BOTH_PASSWORD="${DDS_BACKOFFICE_BOTH_PASSWORD:-${DDSUSER_BOTH_PASSWORD:-}}" +export DDS_BACKOFFICE_NONE_PASSWORD="${DDS_BACKOFFICE_NONE_PASSWORD:-${DDSUSER_NONE_PASSWORD:-}}" + +PID_FILE="$APP_DIR/app.pid" +LOG_FILE="$APP_DIR/app.log" +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then + echo "already running pid=$(cat "$PID_FILE")" + exit 0 +fi + +setsid nohup java -jar "$APP_DIR/app.jar" > "$LOG_FILE" 2>&1 < /dev/null & +echo $! > "$PID_FILE" +echo "started pid=$(cat "$PID_FILE") log=$LOG_FILE port=$DDS_BACKOFFICE_PORT" +START + +cat > "$APP_DIR/stop.sh" <<'STOP' +#!/usr/bin/env bash +set -Eeuo pipefail +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PID_FILE="$APP_DIR/app.pid" +if [[ ! -f "$PID_FILE" ]]; then + echo "not running: pid file not found" + exit 0 +fi +PID="$(cat "$PID_FILE")" +if kill -0 "$PID" >/dev/null 2>&1; then + kill "$PID" + for _ in {1..20}; do + kill -0 "$PID" >/dev/null 2>&1 || break + sleep 1 + done +fi +rm -f "$PID_FILE" +echo "stopped" +STOP + +cat > "$APP_DIR/status.sh" <<'STATUS' +#!/usr/bin/env bash +set -Eeuo pipefail +APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PID_FILE="$APP_DIR/app.pid" +if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" >/dev/null 2>&1; then + echo "running pid=$(cat "$PID_FILE")" +else + echo "stopped" +fi +STATUS + +chmod +x "$APP_DIR/start.sh" "$APP_DIR/stop.sh" "$APP_DIR/status.sh" +if [[ ! -f "$APP_DIR/.env" ]]; then + cat > "$APP_DIR/.env" <