feat: add dedicated DDS permission backoffice instance

This commit is contained in:
devmrko
2026-06-29 21:19:48 +09:00
parent 6cf75dc42b
commit c4968a0313
22 changed files with 1128 additions and 0 deletions

45
dds-backoffice/README.md Normal file
View File

@@ -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 승인을 별도로 거쳐야 합니다.

64
dds-backoffice/pom.xml Normal file
View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.8</version>
<relativePath/>
</parent>
<groupId>com.cloudhandson</groupId>
<artifactId>dds-permission-backoffice</artifactId>
<version>0.1.0-SNAPSHOT</version>
<name>dds-permission-backoffice</name>
<description>Dedicated Deep Data Security permission comparison console</description>
<properties>
<java.version>21</java.version>
<oracle.jdbc.version>23.6.0.24.10</oracle.jdbc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.jdbc</groupId>
<artifactId>ojdbc11</artifactId>
<version>${oracle.jdbc.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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) {
}
}

View File

@@ -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<String, User> 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<String, User>();
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();
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
) {
}

View File

@@ -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<DdsCustomerRow> rows
) {
public DdsQueryResult {
rows = rows == null ? List.of() : List.copyOf(rows);
}
public boolean hasRows() {
return !rows.isEmpty();
}
}

View File

@@ -0,0 +1,9 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsSourceOption(
String key,
String label,
String objectName,
String description
) {
}

View File

@@ -0,0 +1,10 @@
package com.cloudhandson.ddsbackoffice.domain;
public record DdsUserOption(
String key,
String label,
String username,
String description,
boolean configured
) {
}

View File

@@ -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<DdsUserOption> 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<DdsSourceOption> 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<DdsCustomerRow>();
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 설정을 확인하세요. 상세 코드는 운영 로그에서 확인합니다.";
}
}

View File

@@ -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);
}
}

View File

@@ -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<String, String> health() {
return Map.of("status", "UP", "track", "DDS");
}
}

View File

@@ -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";
}
}

View File

@@ -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:}}

View File

@@ -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; }
}

View File

@@ -0,0 +1,151 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Deep Data Security 접근 관리</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/dds.css" rel="stylesheet">
</head>
<body>
<nav class="dds-nav">
<div class="container d-flex align-items-center gap-3">
<a class="brand" href="/">DDS Permission Console</a>
<span class="track-badge">별도 인스턴스 · :8083</span>
<form method="post" action="/logout" class="ms-auto">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<button class="btn btn-sm btn-outline-light" type="submit">로그아웃</button>
</form>
</div>
</nav>
<main class="container py-4">
<header class="hero">
<span class="eyebrow">DEEP DATA SECURITY · DIRECT END USER</span>
<h1>선언형 데이터 권한을 실제 조회로 확인합니다.</h1>
<p class="lead">VPD 트랙과 분리된 DDS 전용 인스턴스입니다. 선택한 END USER로 직접 접속해 Oracle의 DATA ROLE과 DATA GRANT가 적용된 결과를 보여줍니다.</p>
<details class="explanation">
<summary>이 화면의 보안 경계</summary>
<p>이 인스턴스는 VPD 권한 테이블을 읽어 조건을 계산하지 않습니다. <code>END USER → DATA ROLE → DATA GRANT → 보호 VIEW</code>가 전부 Oracle DDS에 선언되어 있고, 조회 결과는 그 선언의 적용 결과입니다.</p>
<p class="mb-0">Bearer 키를 ORDS Handler에서 사용자 이름으로 바꾸는 것만으로는 DDS 보안 컨텍스트가 만들어지지 않습니다. 그래서 이 비교 트랙은 직접 END USER logon을 검증합니다.</p>
</details>
</header>
<section class="flow-grid" aria-label="DDS 접근 흐름">
<article class="flow-card">
<span class="flow-number">1</span>
<div><span class="eyebrow">IDENTITY</span><h2>END USER</h2><p>실제 DDS 보안 사용자로 DB에 접속합니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">2</span>
<div><span class="eyebrow">POLICY</span><h2>DATA ROLE / GRANT</h2><p>원본별 허용 범위가 선언형으로 연결됩니다.</p></div>
</article>
<article class="flow-card">
<span class="flow-number">3</span>
<div><span class="flow-number result-number">3</span><span class="eyebrow">EVIDENCE</span><h2>조회 결과</h2><p>허용된 VIEW와 행만 반환됩니다.</p></div>
</article>
</section>
<div class="alert alert-warning" th:if="${configuredUserCount == 0}">
DDS 사용자 비밀번호가 설정되지 않았습니다. VM에서는 <code>DDSUSER_*_PASSWORD</code> 또는 <code>DDS_BACKOFFICE_*_PASSWORD</code> 환경 변수를 설정해야 조회할 수 있습니다.
</div>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">1 · SECURITY SUBJECT</span>
<h2>조회 주체와 데이터 원본 선택</h2>
<p>선택한 DDS END USER의 권한으로 직접 연결합니다. 비밀번호는 화면에 표시하거나 저장하지 않습니다.</p>
</div>
</div>
<form method="post" action="/dds/query" class="query-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label>
DDS END USER
<select class="form-select" name="userKey" required>
<option th:each="user : ${users}" th:value="${user.key()}" th:selected="${user.key() == selectedUser}"
th:text="${user.label() + ' · ' + user.username()}"></option>
</select>
<span class="field-help" th:each="user : ${users}" th:if="${user.key() == selectedUser}" th:text="${user.description()}"></span>
</label>
<label>
보호 데이터 원본
<select class="form-select" name="sourceKey" required>
<option th:each="source : ${sources}" th:value="${source.key()}" th:selected="${source.key() == selectedSource}"
th:text="${source.label()}"></option>
</select>
<span class="field-help" th:each="source : ${sources}" th:if="${source.key() == selectedSource}" th:text="${source.objectName()}"></span>
</label>
<label>
이름 검색 (선택)
<input class="form-control" name="searchText" placeholder="예: Alice">
<span class="field-help">검색 조건도 보호 VIEW 안에서만 평가됩니다.</span>
</label>
<label>
최대 행 수
<input class="form-control" name="limit" type="number" min="1" max="100" value="20">
</label>
<div class="query-submit"><button class="btn btn-primary" type="submit">DDS 권한으로 조회</button></div>
</form>
</section>
<section class="panel">
<div class="panel-heading">
<div>
<span class="eyebrow">2 · EXPECTED MATRIX</span>
<h2>원본별 권한 매트릭스</h2>
<p>VPD와 같은 4가지 업무 사례를 DDS의 선언형 권한으로 재현합니다.</p>
</div>
</div>
<div class="matrix-grid">
<div class="matrix-card"><strong>ddsuser_my</strong><span>MY 원본 허용</span><small>PG 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>ddsuser_pg</strong><span>PG 원본 허용</span><small>MY 객체는 ORA-00942</small></div>
<div class="matrix-card"><strong>ddsuser_both</strong><span>PG + MY 허용</span><small>두 DATA GRANT 모두 적용</small></div>
<div class="matrix-card muted"><strong>ddsuser_none</strong><span>접속만 허용</span><small>데이터 권한 없음 · default deny</small></div>
</div>
</section>
<section class="panel" th:if="${queryResult != null}">
<div class="panel-heading result-heading">
<div>
<span class="eyebrow">3 · EVIDENCE</span>
<h2 th:text="${queryResult.userLabel() + ' · ' + queryResult.sourceLabel()}">조회 결과</h2>
<p th:text="${queryResult.objectName()}">ADMIN.V_DDS_CUSTOMERS_PG</p>
</div>
<span class="result-status" th:classappend="${queryResult.success()} ? ' success' : ' blocked'"
th:text="${queryResult.success()} ? '정책 적용됨' : '접근 차단'">상태</span>
</div>
<div class="alert" th:classappend="${queryResult.success()} ? ' alert-success' : ' alert-warning'">
<strong th:text="${queryResult.title()}">결과</strong>
<span th:text="${queryResult.message()}">메시지</span>
<span th:if="${queryResult.oracleCode() != null}" class="technical-code" th:text="${'Oracle code: ' + queryResult.oracleCode()}"></span>
</div>
<div class="context-grid" th:if="${queryResult.success()}">
<div><span>SESSION_USER</span><strong th:text="${queryResult.sessionUser() ?: '-'}">-</strong></div>
<div><span>ORA_END_USER_CONTEXT</span><strong th:text="${queryResult.endUser() ?: '-'}">-</strong></div>
<div><span>반환 행</span><strong th:text="${#lists.size(queryResult.rows())}">0</strong></div>
</div>
<div class="table-responsive" th:if="${queryResult.success() and queryResult.hasRows()}">
<table class="table align-middle">
<thead><tr><th>ID</th><th>이름</th><th>이메일</th><th>가입일</th><th>지역</th></tr></thead>
<tbody>
<tr th:each="row : ${queryResult.rows()}">
<td th:text="${row.customerId()}">1</td>
<td th:text="${row.fullName()}">Alice</td>
<td th:text="${row.email() ?: 'NULL'}">alice@example.com</td>
<td th:text="${row.signupDate() ?: '-'}">2026-01-01</td>
<td th:text="${row.region()}">APAC</td>
</tr>
</tbody>
</table>
</div>
<div class="empty-result" th:if="${queryResult.success() and !queryResult.hasRows()}">조회 조건을 통과한 행이 없습니다.</div>
<details class="explanation">
<summary>판정 근거와 운영 메모</summary>
<p>성공한 경우 연결 시점의 <code>SESSION_USER</code><code>ORA_END_USER_CONTEXT</code>를 함께 확인할 수 있습니다. 차단된 경우 DDS가 보호 객체 자체를 숨겨 <code>ORA-00942</code>를 반환하는 것이 정상입니다.</p>
<p class="mb-0">현재 앱은 결과만 읽습니다. 권한 변경은 <code>CREATE DATA ROLE</code>, <code>GRANT DATA ROLE</code>, <code>CREATE DATA GRANT</code> 승인 절차를 거쳐 SQL로 관리합니다.</p>
</details>
</section>
</main>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DDS Permission Console 로그인</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/css/dds.css" rel="stylesheet">
</head>
<body class="login-page">
<main class="login-card">
<span class="eyebrow">DEEP DATA SECURITY</span>
<h1>DDS Permission Console</h1>
<p>VPD와 분리된 DDS 전용 인스턴스입니다.</p>
<div class="alert alert-danger" th:if="${param.error}">관리자 인증에 실패했습니다.</div>
<div class="alert alert-success" th:if="${param.logout}">로그아웃되었습니다.</div>
<form method="post" action="/login">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label class="form-label" for="username">관리자 ID</label>
<input class="form-control mb-3" id="username" name="username" autocomplete="username" required autofocus>
<label class="form-label" for="password">비밀번호</label>
<input class="form-control mb-4" id="password" name="password" type="password" autocomplete="current-password" required>
<button class="btn btn-primary w-100" type="submit">로그인</button>
</form>
</main>
</body>
</html>

View File

@@ -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()
));
}
}

View File

@@ -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));
}
}

View File

@@ -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" <<ENV
# DDS instance overrides. Secrets stay in the existing VPD env file or in this file.
export DDS_BACKOFFICE_PORT="$PORT"
export DDS_BACKOFFICE_BIND_ADDRESS="127.0.0.1"
ENV
chmod 600 "$APP_DIR/.env"
fi
"$APP_DIR/stop.sh" || true
install -o opc -g opc -m 0644 "$JAR" "$APP_DIR/app.jar"
"$APP_DIR/start.sh"
for _ in {1..30}; do
if "$APP_DIR/status.sh" | grep -q '^running ' && curl -fsS -o /dev/null "http://127.0.0.1:${PORT}/health"; then
break
fi
sleep 1
done
"$APP_DIR/status.sh"
curl -sS -o /tmp/dds-backoffice-health.json -w 'health=%{http_code}\n' "http://127.0.0.1:${PORT}/health"
echo "DDS backoffice deployed at ${APP_DIR} (loopback port ${PORT})"