fix #557: guide permission-driven VPD flow

This commit is contained in:
devmrko
2026-06-29 07:01:53 +09:00
parent 8dc77f87ab
commit fbe3d4682b
24 changed files with 1351 additions and 489 deletions

View File

@@ -0,0 +1,110 @@
# 설계서: 권한체계 중심 VPD 안내와 토큰 검증 흐름 (#557)
> **상태**: Approved
> **작성**: [AI] Architect · **최종수정**: 2026-06-29
> **추적성** — Redmine: #557 · 관련 ADR: 없음
> · 구현 파일: `ProbeController`, `ProbeResult`, `VpdPolicyService`, 대시보드/VPD/토큰/검증 템플릿
> · 테스트: `ProbeResultTest`, `VpdPolicyServiceTest`, `GuidedFlowTemplateTest`
## 1. 목적 (Why)
권한을 만든 사람의 머릿속 구조를 모르는 운영자도 “누가 어떤 데이터를 볼 수 있는지 정하고, DB가 그대로 제한하는지 확인한다”는 한 흐름으로 제품을 이해하고 사용할 수 있게 한다.
## 2. 범위 (Scope)
- **포함**: Macro→Micro 설명 구조, 내비게이션 재분류, 동적 권한 VPD 기본 적용, custom filter 고급 분리, 토큰 검증 단순화, 상태별 일반 문장과 다음 행동, 발급→검증 연결.
- **제외**: 권한 데이터 모델 변경, VPD 함수 SQL 알고리즘 변경, 토큰 원문 저장, 운영 DB DDL 자동 실행, ORDS handler 생성 방식 변경.
## 3. 인수조건 (Acceptance Criteria)
- [x] 첫 화면에서 권한 설계 → DB 보호 → 토큰 발급 → 결과 확인의 목적과 순서를 이해할 수 있다.
- [x] 기본 VPD 적용은 객체만 선택하고 권한체계 동적 함수와 SELECT 정책을 서버가 결정한다.
- [x] `CB_AGENT_DOC_VPD_FILTER`는 일반 UI에서 수정할 수 없고 직접 POST도 거부된다.
- [x] 별도 filter/predicate와 policy 교체는 사용 기준·안전 규칙이 있는 고급 영역에만 노출된다.
- [x] 검증 화면은 등록 토큰 선택 없이 Bearer 원문과 대상만 입력한다.
- [x] 검증 결과가 상태, 적용 주체, 행 결과, 다음 행동 순으로 설명되고 HTTP 원문은 접혀 있다.
- [x] 존재하지 않는 테스트 토큰은 “DB에 등록된 토큰이 아님”과 재발급 절차를 안내한다.
- [x] 발급 직후 토큰 복사와 검증 화면 이동이 명확하다.
## 4. 컨텍스트 & 제약
- 토큰 원문은 보안상 저장하지 않고 SHA-256 hash만 저장한다. 따라서 목록에서 원문을 재선택해 호출하는 기능은 제공하지 않는다.
- 권한 원천은 사용자/그룹/역할/권한/규칙 테이블이며 VPD 함수가 요청 시점에 이를 조회한다.
- 권한이 없거나 컨텍스트/규칙이 잘못되면 `1=0`으로 닫는 fail-closed 원칙을 유지한다.
- 운영 DB의 기존 custom policy는 삭제하지 않고 고급 관리 기능으로 남긴다.
## 5. 아키텍처 개요
```text
[Macro: 무엇을 하려는가]
누가(User/Group) → 어떤 역할(Role) → 어떤 데이터(Object/Rule)
[Micro: DB가 어떻게 지키는가]
CB_PERMISSION* 테이블 → CB_AGENT_DOC_VPD_FILTER → Oracle VPD
[Evidence: 실제로 지켜졌는가]
1회 표시 토큰 → ORDS 호출 → 사용자/역할 + 보이는 행 + 다음 행동
```
- I/O 경계: controller/service는 DB catalog와 ORDS를 호출한다.
- 순수 표현 경계: `ProbeResult`의 상태별 제목·설명·다음 행동은 외부 I/O 없이 테스트한다.
- 안전 경계: 기본 endpoint는 동적 함수만 사용하고 custom 함수 생성/교체 endpoint는 고급 기능으로 유지한다.
## 6. 데이터 모델
- 입력: `objectKey`, `bearerToken`, `objectId`, `limit`.
- 기본 VPD 명령: policy `CB_PERMISSION_SELECT_POLICY`, function `*.CB_AGENT_DOC_VPD_FILTER`, statements `SELECT`, enabled `true`, update check `false`.
- 검증 표현: 기존 `ProbeResult`에 상태별 `title`, `plainSummary`, `nextAction`, `successLike` 계산 메서드를 둔다.
- 검증 컨텍스트: hash로 찾은 `TokenContextView``ProtectedObject`를 controller model에 추가한다. 원문은 model/result/log에 저장하지 않는다.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임 | 입력 | 출력 | 에러/실패 | 복잡? |
|---|---|---|---|---|---|
| `createDefaultPermissionPolicy` | 객체에 표준 동적 권한 VPD 연결 | `objectKey` | 없음 | 함수 미설치, 중복/DB 오류 | 단순 |
| `saveFilterFunction` guard | 핵심 동적 함수 덮어쓰기 차단 | owner/name/predicate | 없음 | 핵심 함수명이면 `AppException` | 단순 |
| `ProbeResult.title/plainSummary/nextAction` | 기술 상태를 일반 문장으로 변환 | status/result | 문자열 | 알 수 없는 상태도 안전 안내 | 단순 |
| `findTokenContextByPlainToken` | 원문 hash에 대응하는 사용자/역할 설명 조회 | 토큰 원문 | nullable context | 미등록이면 null | 단순 |
| `ProbeController.run` | 단일 토큰 입력으로 실행·설명 model 구성 | form | fragment | status별 결과 fragment | 단순 |
## 8. 흐름 / 알고리즘
1. 운영자는 사용자/그룹/역할과 객체별 행·열 규칙을 저장한다.
2. 보호할 DB 객체를 선택하고 “권한체계 연결”을 누른다.
3. 서버는 설치된 `CB_AGENT_DOC_VPD_FILTER`를 찾아 동적 SELECT policy를 붙인다.
4. 토큰을 발급하면 원문을 한 번 복사하고 검증으로 이동한다.
5. 검증 시 hash로 토큰과 사용자를 식별하고 ORDS를 호출한다.
6. 화면은 사용자/직접 역할/그룹 상속, 보인 행 수 또는 차단 이유, 다음 행동을 먼저 보여준다.
7. HTTP request/response는 문제 분석용 상세 영역에서만 연다.
## 9. 엣지케이스 & 에러 처리
- 토큰 미등록: 환경 파일과 DB가 어긋난 상태로 설명하고 새 발급을 안내한다.
- 만료/회수: 새 토큰 발급 또는 활성 토큰 사용을 안내한다.
- 0행: 오류가 아니라 VPD가 현재 권한 기준으로 모든 행을 제외했을 가능성을 먼저 설명한다.
- ORDS 미설정/경로 오류/접속 실패: 권한 문제와 인프라 문제를 구분한다.
- 기존 별도 함수가 ORA-28110을 내는 경우: 토큰 오류와 분리해 `VPD_FILTER_ERROR`로 설명하고 정상 동적 권한 객체를 검증 목록에서 우선한다.
- 핵심 함수 미설치: 자동으로 custom predicate를 만들지 않고 설치 상태 확인을 요구한다.
- 핵심 함수 수정 시도: UI와 service 양쪽에서 차단한다.
## 10. 테스트 계획
- `ProbeResultTest`: 성공, 0행, 토큰 미등록, 만료, ORDS 장애의 일반 문장/다음 행동.
- `VpdPolicyServiceTest`: 핵심 함수 수정 거부, 기본 적용이 표준 policy/function/SELECT/DYNAMIC 옵션을 사용.
- `GuidedFlowTemplateTest`: 검증의 단일 token 입력, 기술 상세 접힘, VPD 기본/고급 분리, 대시보드 4단계.
- 전체 `mvn test` 34건, `mvn -DskipTests package`.
- 실행 환경에서는 로그인 후 주요 페이지 HTTP 200과 실제 신규 토큰 검증을 확인한다. 외부 ORDS가 없으면 상태별 설명까지만 검증한다.
## 11. 리스크 & 대안 검토
- 기존 template 선택 UI를 기본으로 유지하면 유연하지만 일반 사용자가 정책 이름과 함수를 이해해야 한다. 객체만 받는 전용 기본 endpoint를 선택한다.
- 목록의 등록 토큰을 선택하게 하려면 원문 저장이 필요해진다. 보안 경계를 유지하고 원문 직접 입력만 제공한다.
- custom 기능을 제거하면 기존 운영 정책 관리가 막힌다. 삭제 대신 고급 영역과 서버 안전장치로 격리한다.
## 12. 미해결 질문 (Open Questions)
- 실제 환경에서 동적 함수가 연결된 객체는 신규 토큰으로 ORDS/VPD 성공 응답까지 확인했다.
- 기존 `BOARD_ASSIGNMENTS_FILTER`는 ORA-28110 상태다. 운영 policy 교체는 DDL 승인 후 별도 Filter 고급 화면에서 표준 동적 함수로 복구해야 한다.
- `.env`의 기존 테스트 토큰 두 개는 현재 DB에 없으며, 환경 비밀 교체는 저장소 밖 운영 작업으로 남긴다.

View File

@@ -44,4 +44,65 @@ public record ProbeResult(
responseBody responseBody
); );
} }
public boolean successLike() {
return status == ProbeStatus.SUCCESS || status == ProbeStatus.VPD_DENY_EMPTY_RESULT;
}
public String title() {
return switch (status) {
case SUCCESS -> "권한에 따라 데이터를 볼 수 있습니다.";
case VPD_DENY_EMPTY_RESULT -> "현재 권한으로 볼 수 있는 행이 없습니다.";
case TOKEN_NOT_FOUND -> "DB에 등록되지 않은 토큰입니다.";
case TOKEN_INACTIVE -> "만료되었거나 회수된 토큰입니다.";
case INVALID_TOKEN -> "입력한 토큰 정보가 일치하지 않습니다.";
case OBJECT_DISABLED -> "검증 대상이 비활성 상태입니다.";
case OBJECT_NOT_ACCESSIBLE -> "현재 권한으로 이 대상에 접근할 수 없습니다.";
case VPD_FILTER_ERROR -> "이 객체에 연결된 별도 VPD Filter가 실행되지 않습니다.";
case ORDS_PATH_NOT_FOUND -> "검증 대상의 ORDS 경로를 찾지 못했습니다.";
case ORDS_NOT_CONFIGURED -> "ORDS 연결 주소가 아직 설정되지 않았습니다.";
case ORDS_UNAVAILABLE -> "ORDS 서버에 연결할 수 없습니다.";
case ORDS_TIMEOUT -> "ORDS 응답을 기다리다 시간이 초과되었습니다.";
case INVALID_ORDS_RESPONSE -> "ORDS 응답 형식을 해석할 수 없습니다.";
case UNKNOWN_ERROR -> "검증 중 예상하지 못한 문제가 발생했습니다.";
};
}
public String plainSummary() {
return switch (status) {
case SUCCESS -> "토큰의 사용자와 역할을 기준으로 VPD가 적용되었고, 허용된 데이터 " + rowCount
+ "개가 반환되었습니다.";
case VPD_DENY_EMPTY_RESULT -> "호출은 정상 처리됐지만 VPD가 현재 사용자에게 허용한 행은 0개입니다. 권한 규칙과 실제 데이터가 맞지 않으면 정상 결과이며 오류가 아닐 수 있습니다.";
case TOKEN_NOT_FOUND -> "입력한 원문과 일치하는 등록 기록이 현재 DB에 없습니다. 예전에 발급한 값이거나 다른 환경의 토큰일 수 있습니다.";
case TOKEN_INACTIVE -> "토큰은 DB에 있지만 만료되었거나 관리자가 회수해 더 이상 사용자 권한을 증명할 수 없습니다.";
case INVALID_TOKEN -> "화면에서 선택한 정보와 입력한 토큰 원문이 서로 다릅니다.";
case OBJECT_DISABLED -> "선택한 객체가 검증 대상으로 활성화되어 있지 않아 ORDS 호출을 시작하지 않았습니다.";
case OBJECT_NOT_ACCESSIBLE -> "토큰은 확인됐지만 ORDS 또는 DB가 이 객체에 대한 접근을 거부했습니다.";
case VPD_FILTER_ERROR -> "토큰과 사용자 권한은 확인됐지만, 대상 객체의 VPD 함수가 DB 오류를 내어 행 필터를 계산하지 못했습니다.";
case ORDS_PATH_NOT_FOUND -> "권한 판단 전 단계에서 실제 ORDS 주소와 등록된 객체 경로가 일치하지 않았습니다.";
case ORDS_NOT_CONFIGURED -> "백오피스가 호출할 ORDS 기준 주소가 없어 권한 검증을 시작하지 못했습니다.";
case ORDS_UNAVAILABLE -> "권한 판단 전 단계에서 ORDS 서버 또는 네트워크에 연결하지 못했습니다.";
case ORDS_TIMEOUT -> "ORDS가 제한 시간 안에 응답하지 않아 권한 결과를 확인하지 못했습니다.";
case INVALID_ORDS_RESPONSE -> "ORDS 호출은 끝났지만 rows/items 배열이 없는 응답이라 권한 결과로 표시하지 못했습니다.";
case UNKNOWN_ERROR -> "토큰, 권한, ORDS 중 어느 단계의 문제인지 기술 상세를 확인해야 합니다.";
};
}
public String nextAction() {
return switch (status) {
case SUCCESS -> "반환된 행과 마스킹 컬럼이 예상한 범위인지 확인하세요. 다르면 권한 화면의 행·열 규칙을 조정한 뒤 다시 검증하세요.";
case VPD_DENY_EMPTY_RESULT -> "유효 권한 화면에서 사용자에게 직접 또는 그룹으로 상속된 역할과 행 규칙을 확인하세요.";
case TOKEN_NOT_FOUND -> "토큰 화면에서 현재 환경의 사용자에게 새 토큰을 발급하고, 한 번만 표시되는 원문을 복사해 다시 검증하세요.";
case TOKEN_INACTIVE -> "토큰 화면에서 활성 토큰을 새로 발급한 뒤 다시 검증하세요.";
case INVALID_TOKEN -> "복사한 원문이 맞는지 확인하고, 원문을 잃었다면 새 토큰을 발급하세요.";
case OBJECT_DISABLED -> "보호 객체를 활성화하고 권한을 등록한 뒤 다시 검증하세요.";
case OBJECT_NOT_ACCESSIBLE -> "유효 권한과 ORDS handler의 대상 객체가 같은지 확인하세요.";
case VPD_FILTER_ERROR -> "토큰이나 권한을 바꾸지 말고 DB 보호 연결에서 이 객체의 Filter를 확인하세요. 일반 권한 객체라면 권한체계 자동 Filter로 복구하세요.";
case ORDS_PATH_NOT_FOUND -> "보호 객체의 ORDS 경로와 실제 module/template 경로를 맞춘 뒤 다시 실행하세요.";
case ORDS_NOT_CONFIGURED -> "설정에서 ORDS 기준 주소를 등록한 뒤 백오피스를 재시작하세요.";
case ORDS_UNAVAILABLE, ORDS_TIMEOUT -> "권한 설정을 바꾸지 말고 먼저 ORDS 실행 상태, 주소와 네트워크를 확인하세요.";
case INVALID_ORDS_RESPONSE -> "ORDS handler가 rows 또는 items 배열을 반환하는지 확인하세요.";
case UNKNOWN_ERROR -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요.";
};
}
} }

View File

@@ -8,6 +8,7 @@ public enum ProbeStatus {
OBJECT_DISABLED, OBJECT_DISABLED,
INVALID_TOKEN, INVALID_TOKEN,
OBJECT_NOT_ACCESSIBLE, OBJECT_NOT_ACCESSIBLE,
VPD_FILTER_ERROR,
ORDS_PATH_NOT_FOUND, ORDS_PATH_NOT_FOUND,
ORDS_NOT_CONFIGURED, ORDS_NOT_CONFIGURED,
ORDS_UNAVAILABLE, ORDS_UNAVAILABLE,

View File

@@ -15,4 +15,8 @@ public record VpdFunctionOption(
public String label() { public String label() {
return value() + " / " + objectType; return value() + " / " + objectType;
} }
public boolean permissionSystemDefault() {
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName);
}
} }

View File

@@ -24,4 +24,8 @@ public record VpdPolicyView(
String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + "."; String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + ".";
return functionOwner + "." + packagePrefix + functionName; return functionOwner + "." + packagePrefix + functionName;
} }
public boolean permissionSystemDefault() {
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName);
}
} }

View File

@@ -87,6 +87,17 @@ public class BearerTokenService {
.toList(); .toList();
} }
public TokenContextView findTokenContextByPlainToken(String plainToken) {
BearerTokenRecord record = findByPlainToken(plainToken);
if (record == null) {
return null;
}
return findTokenContextOptions().stream()
.filter(context -> context.keyId() == record.keyId())
.findFirst()
.orElse(null);
}
public BearerTokenRecord findById(long keyId) { public BearerTokenRecord findById(long keyId) {
return tokenMapper.findById(keyId); return tokenMapper.findById(keyId);
} }

View File

@@ -80,7 +80,10 @@ public class OrdsProbeService {
token = tokenService.findByPlainToken(command.bearerToken()); token = tokenService.findByPlainToken(command.bearerToken());
if (token == null) { if (token == null) {
return auditAndReturn(command, ProbeResult.blocked( return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다.")); ProbeStatus.TOKEN_NOT_FOUND,
"TOKEN_NOT_FOUND",
"현재 DB에 등록된 토큰이 아닙니다. 토큰 화면에서 이 환경의 새 토큰을 발급하세요."
));
} }
} else { } else {
token = tokenService.findById(command.tokenKeyId()); token = tokenService.findById(command.tokenKeyId());

View File

@@ -20,6 +20,9 @@ public class ProbeErrorClassifier {
if (text.contains("ORA-00942") || text.contains("ORA-01031")) { if (text.contains("ORA-00942") || text.contains("ORA-01031")) {
return ProbeStatus.OBJECT_NOT_ACCESSIBLE; return ProbeStatus.OBJECT_NOT_ACCESSIBLE;
} }
if (text.contains("ORA-28110") || text.contains("SQL Error Code 28110")) {
return ProbeStatus.VPD_FILTER_ERROR;
}
if (status != null && (status.value() == 401 || status.value() == 403)) { if (status != null && (status.value() == 401 || status.value() == 403)) {
return ProbeStatus.INVALID_TOKEN; return ProbeStatus.INVALID_TOKEN;
} }

View File

@@ -96,6 +96,11 @@ public class VpdPolicyService {
@Transactional @Transactional
public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) { public void saveFilterFunction(String functionOwnerValue, String functionNameValue, String filterPredicateValue) {
String functionName = requiredIdentifier(functionNameValue, "Function name");
if (DEFAULT_PERMISSION_FILTER_FUNCTION.equalsIgnoreCase(functionName)) {
throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION
+ "는 이 화면에서 수정할 수 없습니다. 권한체계는 사용자·그룹·역할·권한 규칙 화면에서 변경하세요.");
}
String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class); String currentUser = jdbcTemplate.queryForObject("SELECT USER FROM dual", String.class);
String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank() String functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
? currentUser ? currentUser
@@ -104,7 +109,6 @@ public class VpdPolicyService {
throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: " throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
+ currentUser + ", Function owner: " + functionOwner); + currentUser + ", Function owner: " + functionOwner);
} }
String functionName = requiredIdentifier(functionNameValue, "Function name");
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim(); String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
if (filterPredicate.isBlank()) { if (filterPredicate.isBlank()) {
throw new AppException("Filter predicate는 필수입니다."); throw new AppException("Filter predicate는 필수입니다.");
@@ -113,6 +117,31 @@ public class VpdPolicyService {
clearCatalogCache(); clearCatalogCache();
} }
@Transactional
public void createDefaultPermissionPolicy(String objectKey) {
String[] objectParts = objectKey == null ? new String[0] : objectKey.split("\\.", 2);
if (objectParts.length != 2) {
throw new AppException("보호할 객체 형식이 올바르지 않습니다: " + objectKey);
}
String functionKey = formOptions().defaultPermissionFunctionKey();
if (functionKey.isBlank()) {
throw new AppException("기본 동적 권한 필터 " + DEFAULT_PERMISSION_FILTER_FUNCTION
+ "가 설치되어 있지 않습니다. 운영 상태에서 동적 권한 필터 설치 여부를 확인한 뒤 다시 적용하세요.");
}
createPolicy(new VpdPolicyCreateCommand(
objectParts[0],
objectParts[1],
COMMON_POLICY_NAME,
functionKey,
null,
null,
"SELECT",
true,
false,
null
));
}
@Transactional @Transactional
public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) { public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) {
String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2); String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2);

View File

@@ -1,9 +1,15 @@
package com.cloudhandson.vpdbackoffice.web; package com.cloudhandson.vpdbackoffice.web;
import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand; import com.cloudhandson.vpdbackoffice.domain.probe.ProbeCommand;
import com.cloudhandson.vpdbackoffice.domain.protectedobject.ProtectedObject;
import com.cloudhandson.vpdbackoffice.service.BearerTokenService; import com.cloudhandson.vpdbackoffice.service.BearerTokenService;
import com.cloudhandson.vpdbackoffice.service.OrdsProbeService; import com.cloudhandson.vpdbackoffice.service.OrdsProbeService;
import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService; import com.cloudhandson.vpdbackoffice.service.ProtectedObjectService;
import com.cloudhandson.vpdbackoffice.service.VpdPolicyService;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model; import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -16,33 +22,58 @@ public class ProbeController {
private final OrdsProbeService probeService; private final OrdsProbeService probeService;
private final ProtectedObjectService protectedObjectService; private final ProtectedObjectService protectedObjectService;
private final BearerTokenService tokenService; private final BearerTokenService tokenService;
private final VpdPolicyService vpdPolicyService;
public ProbeController( public ProbeController(
OrdsProbeService probeService, OrdsProbeService probeService,
ProtectedObjectService protectedObjectService, ProtectedObjectService protectedObjectService,
BearerTokenService tokenService BearerTokenService tokenService,
VpdPolicyService vpdPolicyService
) { ) {
this.probeService = probeService; this.probeService = probeService;
this.protectedObjectService = protectedObjectService; this.protectedObjectService = protectedObjectService;
this.tokenService = tokenService; this.tokenService = tokenService;
this.vpdPolicyService = vpdPolicyService;
} }
@GetMapping("/probe") @GetMapping("/probe")
public String probe(Model model) { public String probe(Model model) {
model.addAttribute("objects", protectedObjectService.findEnabled()); Set<String> defaultObjectKeys = defaultPermissionObjectKeys();
model.addAttribute("tokens", tokenService.findTokenContextOptions()); List<ProtectedObject> objects =
protectedObjectService.findEnabled().stream()
.sorted(Comparator
.comparing((ProtectedObject object) ->
!defaultObjectKeys.contains(object.displayName()))
.thenComparing(ProtectedObject::displayName))
.toList();
model.addAttribute("objects", objects);
model.addAttribute("defaultObjectKeys", defaultObjectKeys);
return "probe"; return "probe";
} }
@PostMapping("/probe") @PostMapping("/probe")
public String run( public String run(
@RequestParam(required = false) Long tokenKeyId,
@RequestParam long objectId, @RequestParam long objectId,
@RequestParam String bearerToken, @RequestParam String bearerToken,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
Model model Model model
) { ) {
model.addAttribute("result", probeService.runProbe(new ProbeCommand(tokenKeyId, objectId, bearerToken, limit))); String normalizedToken = bearerToken == null ? "" : bearerToken.trim();
model.addAttribute("result", probeService.runProbe(new ProbeCommand(null, objectId, normalizedToken, limit)));
model.addAttribute("tokenContext", tokenService.findTokenContextByPlainToken(normalizedToken));
model.addAttribute("selectedObject", protectedObjectService.findEnabled().stream()
.filter(object -> object.objectId() == objectId)
.findFirst()
.orElse(null));
return "fragments/probe-result :: result"; return "fragments/probe-result :: result";
} }
private Set<String> defaultPermissionObjectKeys() {
Set<String> result = new LinkedHashSet<>();
vpdPolicyService.findPolicies().stream()
.filter(policy -> policy.permissionSystemDefault() && "YES".equalsIgnoreCase(policy.enabled()))
.map(policy -> policy.objectDisplayName().toUpperCase())
.forEach(result::add);
return result;
}
} }

View File

@@ -80,6 +80,26 @@ public class VpdPolicyController {
return "redirect:/vpd-policies"; return "redirect:/vpd-policies";
} }
@PostMapping("/vpd-policies/default")
public String createDefaultPolicy(
@RequestParam String objectKey,
RedirectAttributes redirectAttributes
) {
try {
vpdPolicyService.createDefaultPermissionPolicy(objectKey);
redirectAttributes.addFlashAttribute(
"successMessage",
"권한체계 자동 필터를 연결했습니다: " + objectKey
);
} catch (AppException exception) {
redirectAttributes.addFlashAttribute("errorMessage", exception.getMessage());
} catch (DataAccessException exception) {
RuntimeErrorMessage message = RuntimeErrorMessages.dataAccess(exception);
redirectAttributes.addFlashAttribute("errorMessage", message.message());
}
return "redirect:/vpd-policies";
}
@PostMapping("/vpd-filter-policies/filters") @PostMapping("/vpd-filter-policies/filters")
public String saveFilter( public String saveFilter(
@RequestParam(required = false) String functionOwner, @RequestParam(required = false) String functionOwner,

View File

@@ -213,7 +213,7 @@ body {
align-items: stretch; align-items: stretch;
display: grid; display: grid;
gap: .5rem; gap: .5rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
margin-bottom: 1rem; margin-bottom: 1rem;
min-width: 0; min-width: 0;
} }
@@ -1508,3 +1508,323 @@ body {
padding-right: .65rem; padding-right: .65rem;
} }
} }
/* Guided permission journey (#557) */
.guided-hero {
max-width: 920px;
}
.guided-hero h1 {
font-size: clamp(1.75rem, 3.2vw, 2.5rem);
line-height: 1.18;
margin-top: .35rem;
}
.guided-hero p {
font-size: 1rem;
line-height: 1.65;
max-width: 850px;
}
.journey-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-bottom: 1rem;
}
.journey-card {
align-items: flex-start;
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
box-shadow: var(--rw-shadow);
color: inherit;
display: flex;
gap: 1rem;
min-width: 0;
padding: 1.15rem;
text-decoration: none;
}
.journey-card:hover {
border-color: var(--rw-primary);
color: inherit;
transform: translateY(-1px);
}
.journey-number {
align-items: center;
background: var(--rw-primary-soft);
border-radius: 999px;
color: var(--rw-primary);
display: inline-flex;
flex: 0 0 2.2rem;
font-weight: 800;
height: 2.2rem;
justify-content: center;
}
.journey-card h2 {
font-size: 1.05rem;
font-weight: 800;
margin: .1rem 0 .35rem;
}
.journey-card p {
color: var(--rw-muted);
line-height: 1.5;
margin: 0 0 .75rem;
}
.journey-card strong {
color: var(--rw-primary);
font-size: .88rem;
}
.macro-micro-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.macro-micro-grid > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
min-width: 0;
padding: 1rem;
}
.macro-micro-grid h2,
.macro-micro-grid h3 {
font-size: 1rem;
font-weight: 800;
margin: .3rem 0 .45rem;
}
.macro-micro-grid p {
color: var(--rw-muted);
line-height: 1.55;
margin: 0;
}
.guided-check-intro {
align-items: center;
display: flex;
gap: 1rem;
justify-content: space-between;
}
.guided-check-intro h2 {
margin: .25rem 0 .4rem;
}
.guided-check-intro p {
color: var(--rw-muted);
margin: 0;
max-width: 760px;
}
.probe-form {
align-items: end;
}
.probe-submit {
min-height: 2.8rem;
}
.empty-result-guide {
color: var(--rw-muted);
}
.empty-result-guide ol {
margin: .65rem 0 0;
padding-left: 1.25rem;
}
.probe-result-flow {
display: grid;
gap: 1rem;
}
.probe-result-flow > .section-heading {
margin-bottom: 0;
}
.probe-summary {
color: var(--rw-text);
font-size: 1rem;
line-height: 1.6;
margin: 0;
}
.result-section {
border-top: 1px solid var(--rw-border);
padding-top: 1rem;
}
.result-section h3,
.next-action-card h3,
.probe-exchange h3 {
font-size: .92rem;
font-weight: 800;
margin: 0;
}
.result-metrics {
display: grid;
gap: .75rem;
grid-template-columns: minmax(120px, .5fr) minmax(0, 1.5fr);
}
.result-metrics > div {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .8rem;
}
.result-metrics span {
color: var(--rw-muted);
display: block;
font-size: .76rem;
font-weight: 700;
}
.result-metrics strong {
display: block;
margin-top: .25rem;
overflow-wrap: anywhere;
}
.next-action-card {
background: var(--rw-primary-soft);
border: 1px solid rgba(122, 62, 47, .2);
border-radius: 8px;
padding: 1rem;
}
.next-action-card p {
line-height: 1.55;
margin: .45rem 0 .8rem;
}
.technical-details,
.advanced-details {
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .8rem;
}
.technical-details > summary,
.advanced-details > summary {
cursor: pointer;
font-weight: 800;
}
.issued-token-card {
background: var(--rw-primary-soft);
border: 1px solid rgba(122, 62, 47, .25);
border-radius: 8px;
box-shadow: var(--rw-shadow);
margin-bottom: 1rem;
padding: 1.15rem;
}
.issued-token-card h2 {
font-size: 1.05rem;
font-weight: 800;
margin: .3rem 0 .65rem;
}
.issued-token-card .token-value {
background: var(--rw-surface);
border: 1px solid var(--rw-border);
border-radius: 8px;
padding: .75rem;
}
.default-vpd-form {
grid-template-columns: minmax(220px, 1.2fr) minmax(260px, 1.5fr) auto;
}
.default-policy-summary,
.protected-function-card {
background: var(--rw-surface-muted);
border: 1px solid var(--rw-border);
border-radius: 8px;
display: grid;
gap: .2rem;
min-width: 0;
padding: .7rem .85rem;
}
.default-policy-summary span,
.protected-function-card span {
color: var(--rw-muted);
font-size: .8rem;
}
.advanced-guidance {
background: #fff8e8;
border: 1px solid #e7d29c;
border-radius: 8px;
padding: 1rem;
}
.advanced-guidance h2 {
margin-bottom: .6rem;
}
.advanced-guidance p {
margin: .3rem 0 0;
}
.advanced-guidance ul {
margin: 0;
padding-left: 1.25rem;
}
.advanced-guidance li + li {
margin-top: .45rem;
}
.protected-function-card {
grid-template-columns: auto minmax(180px, auto) 1fr;
margin-top: 1rem;
}
.filter-replace-form {
display: grid;
gap: .4rem;
grid-template-columns: minmax(150px, .8fr) minmax(220px, 1fr) auto;
min-width: 520px;
}
@media (max-width: 920px) {
.journey-grid,
.macro-micro-grid,
.default-vpd-form {
grid-template-columns: 1fr;
}
.guided-check-intro {
align-items: flex-start;
flex-direction: column;
}
.protected-function-card {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.journey-card {
padding: .9rem;
}
.result-metrics {
grid-template-columns: 1fr;
}
}

View File

@@ -115,6 +115,44 @@ function initPersistentMenus() {
}); });
} }
function initQuestionPresets(root = document) {
root.querySelectorAll('.question-preset[data-target]').forEach((button) => {
if (button.dataset.presetReady === 'true') {
return;
}
button.dataset.presetReady = 'true';
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
}
function initCopyButtons(root = document) {
root.querySelectorAll('[data-copy-source]').forEach((button) => {
if (button.dataset.copyReady === 'true') {
return;
}
button.dataset.copyReady = 'true';
button.addEventListener('click', async () => {
const source = document.getElementById(button.dataset.copySource || '');
const value = source?.textContent?.trim();
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
button.textContent = '복사했습니다';
} catch (error) {
button.textContent = '복사 실패 · 직접 선택하세요';
}
});
});
}
function escapeHtml(value) { function escapeHtml(value) {
return value return value
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -811,6 +849,8 @@ function initVpdTargetFilters() {
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initPersistentMenus(); initPersistentMenus();
initQuestionPresets();
initCopyButtons();
renderMarkdownViews(); renderMarkdownViews();
initVpdTargetFilters(); initVpdTargetFilters();
const master = document.getElementById('userRoleMaster'); const master = document.getElementById('userRoleMaster');

View File

@@ -1,64 +1,93 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('VPD 백오피스')}"></head> <head th:replace="~{fragments/layout :: head('VPD 권한 백오피스')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <header class="page-title guided-hero">
<h1>VPD 권한 백오피스</h1> <span class="architecture-kicker">권한을 정하면 DB가 그대로 지킵니다</span>
<p>Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.</p> <h1>누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.</h1>
</div> <p>사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.</p>
</header>
<section th:replace="~{fragments/layout :: architectureStrip('')}"></section>
<div class="alert alert-warning" th:if="${runtimeErrorMessage}"> <div class="alert alert-warning" th:if="${runtimeErrorMessage}">
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div> <div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
<div th:text="${runtimeErrorMessage}"></div> <div th:text="${runtimeErrorMessage}"></div>
<div class="mt-2" th:if="${showSupportCommand}"> <div class="mt-2" th:if="${showSupportCommand}"><code>./run.sh backoffice-support</code></div>
<code>./run.sh backoffice-support</code>
</div>
</div> </div>
<section class="summary-grid"> <section class="journey-grid" aria-label="권한 적용 네 단계">
<a class="summary-tile" href="/permissions"> <a class="journey-card" href="/permissions">
<span class="label">보호 객체</span> <span class="journey-number">1</span>
<strong th:text="${#lists.size(objects)}">0</strong> <div>
<h2>1. 권한 설계</h2>
<p>사용자와 그룹에 역할을 주고, 역할마다 볼 수 있는 객체·행·컬럼을 정합니다.</p>
<strong>권한 규칙 만들기 →</strong>
</div>
</a> </a>
<a class="summary-tile" href="/permissions"> <a class="journey-card" href="/vpd-policies">
<span class="label">역할</span> <span class="journey-number">2</span>
<strong th:text="${#lists.size(roles)}">0</strong> <div>
<h2>2. DB 보호 연결</h2>
<p>보호할 TABLE/VIEW를 고르면 동적 VPD가 1단계의 권한체계를 자동으로 적용합니다.</p>
<strong>보호 객체 연결하기 →</strong>
</div>
</a> </a>
<a class="summary-tile" href="/tokens"> <a class="journey-card" href="/tokens">
<span class="label">토큰</span> <span class="journey-number">3</span>
<strong th:text="${#lists.size(tokens)}">0</strong> <div>
<h2>3. 토큰 발급</h2>
<p>검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.</p>
<strong>테스트 토큰 발급하기 →</strong>
</div>
</a> </a>
<a class="journey-card" href="/probe">
<span class="journey-number">4</span>
<div>
<h2>4. 결과 확인</h2>
<p>토큰으로 ORDS를 호출해 사용자·상속 역할과 실제로 보이는 행을 함께 확인합니다.</p>
<strong>권한 결과 확인하기 →</strong>
</div>
</a>
</section>
<section class="content-band macro-micro-grid">
<div>
<span class="architecture-kicker">MACRO · 전체 관점</span>
<h2>권한체계가 유일한 기준입니다.</h2>
<p>일상적인 변경은 사용자, 그룹, 역할, 권한 규칙에서만 합니다. 같은 규칙을 화면과 DB 필터에 이중으로 작성하지 않습니다.</p>
</div>
<div>
<span class="architecture-kicker">MICRO · 실행 관점</span>
<h2>VPD가 요청마다 조건을 계산합니다.</h2>
<p>토큰에서 사용자를 찾고 직접 역할과 그룹 상속 역할을 합친 뒤, 객체의 ALLOW/DENY 및 행·열 규칙을 적용합니다. 근거는 마지막 검증 단계에서 확인합니다.</p>
</div>
</section>
<section class="summary-grid" aria-label="현재 등록 현황">
<a class="summary-tile" href="/permissions"><span class="label">보호 객체</span><strong th:text="${#lists.size(objects)}">0</strong></a>
<a class="summary-tile" href="/roles"><span class="label">역할</span><strong th:text="${#lists.size(roles)}">0</strong></a>
<a class="summary-tile" href="/tokens"><span class="label">발급 이력</span><strong th:text="${#lists.size(tokens)}">0</strong></a>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>최근 보호 객체</h2> <div>
<a class="btn btn-sm rw-btn-secondary" href="/probe">검증 실행</a> <h2>현재 검증 가능한 데이터</h2>
<p class="section-subtitle">권한 규칙과 ORDS 경로가 등록된 보호 객체입니다.</p>
</div>
<a class="btn btn-sm rw-btn-primary" href="/probe">결과 확인</a>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead><tr><th>데이터 객체</th><th>검증 경로</th><th>상태</th></tr></thead>
<tr>
<th>Owner</th>
<th>Object</th>
<th>ORDS Path</th>
<th>Status</th>
</tr>
</thead>
<tbody> <tbody>
<tr th:each="object : ${objects}"> <tr th:each="object : ${objects}">
<td th:text="${object.owner()}">ADMIN</td> <td><strong th:text="${object.displayName()}">ADMIN.OBJECT</strong></td>
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td> <td><code th:text="${object.ordsPath()}">path</code></td>
<td><code th:text="${object.ordsPath()}">search/documents</code></td> <td><span class="badge text-bg-success">사용 가능</span></td>
<td><span class="badge text-bg-success">enabled</span></td>
</tr>
<tr th:if="${#lists.isEmpty(objects)}">
<td colspan="4" class="text-muted">등록된 보호 객체가 없습니다.</td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(objects)}"><td colspan="3" class="text-muted">아직 검증할 보호 객체가 없습니다. 1단계에서 객체 권한을 먼저 등록하세요.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -16,27 +16,28 @@
<a class="navbar-brand" href="/">VPD Backoffice</a> <a class="navbar-brand" href="/">VPD Backoffice</a>
<div class="rw-menu"> <div class="rw-menu">
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">권한 관리</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">1. 권한 설계</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/users">사용자</a> <a class="nav-link" href="/users">사용자</a>
<a class="nav-link" href="/groups">그룹</a> <a class="nav-link" href="/groups">그룹</a>
<a class="nav-link" href="/roles">역할</a> <a class="nav-link" href="/roles">역할</a>
<a class="nav-link" href="/effective-matrix">유효 권한</a> <a class="nav-link" href="/permissions">데이터 권한 규칙</a>
<a class="nav-link" href="/permissions">권한</a> <a class="nav-link" href="/effective-matrix">사용자별 최종 권한</a>
<a class="nav-link" href="/tokens">토큰</a>
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">ORDS 서빙</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">2. 보호·검증</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/objects">조회 Handler 생성</a> <a class="nav-link" href="/vpd-policies">DB 보호 연결</a>
<a class="nav-link" href="/probe">ORDS 검증</a> <a class="nav-link" href="/tokens">테스트 토큰 발급</a>
<a class="nav-link" href="/probe">권한 결과 확인</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">연동 도구</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/objects">ORDS 조회 대상</a>
<a class="nav-link" href="/ords-handlers">ORDS 핸들러</a> <a class="nav-link" href="/ords-handlers">ORDS 핸들러</a>
</div>
</div>
<div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">MCP</button>
<div class="rw-menu-panel">
<a class="nav-link" href="/mcp-chatbot">Chatbot</a> <a class="nav-link" href="/mcp-chatbot">Chatbot</a>
<a class="nav-link" href="/mcp-reasoning">Reasoning</a> <a class="nav-link" href="/mcp-reasoning">Reasoning</a>
<a class="nav-link" href="/mcp-sse">SSE 서비스</a> <a class="nav-link" href="/mcp-sse">SSE 서비스</a>
@@ -44,11 +45,10 @@
</div> </div>
</div> </div>
<div class="rw-menu-group"> <div class="rw-menu-group">
<button class="rw-menu-trigger" type="button" aria-expanded="false">운영</button> <button class="rw-menu-trigger" type="button" aria-expanded="false">운영·고급</button>
<div class="rw-menu-panel"> <div class="rw-menu-panel">
<a class="nav-link" href="/operation-status">운영 상태</a> <a class="nav-link" href="/operation-status">운영 상태</a>
<a class="nav-link" href="/vpd-policies">VPD 설정</a> <a class="nav-link" href="/vpd-filter-policies">별도 Filter 관리</a>
<a class="nav-link" href="/vpd-filter-policies">Filter Policy 관리</a>
<a class="nav-link" href="/settings">설정</a> <a class="nav-link" href="/settings">설정</a>
</div> </div>
</div> </div>
@@ -60,23 +60,29 @@
</div> </div>
</nav> </nav>
<section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="VPD Backoffice architecture"> <section th:fragment="architectureStrip(activeLayer)" class="architecture-strip" aria-label="권한 설정부터 결과 확인까지의 흐름">
<div class="architecture-step" th:classappend="${activeLayer == 'permission'} ? ' active'"> <div class="architecture-step" th:classappend="${activeLayer == 'permission'} ? ' active'">
<span class="architecture-kicker">Backoffice Tables</span> <span class="architecture-kicker">1 · WHO / WHAT</span>
<strong>권한 테이블</strong> <strong>권한 설계</strong>
<p>사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.</p> <p>누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
</div> </div>
<div class="architecture-arrow"></div> <div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'"> <div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
<span class="architecture-kicker">Oracle Database</span> <span class="architecture-kicker">2 · ENFORCE</span>
<strong>VPD Policy</strong> <strong>DB 보호 연결</strong>
<p>TABLE/VIEW에 policy function을 붙여 DB에서 행 접근을 제한합니다.</p> <p>VPD가 저장된 권한체계를 매번 읽어 DB에서 행을 자동 제한합니다.</p>
</div>
<div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'token'} ? ' active'">
<span class="architecture-kicker">3 · IDENTITY</span>
<strong>토큰 발급</strong>
<p>검증할 사용자를 나타내는 일회성 원문 토큰을 준비합니다.</p>
</div> </div>
<div class="architecture-arrow"></div> <div class="architecture-arrow"></div>
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'"> <div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">
<span class="architecture-kicker">Oracle REST Data Services</span> <span class="architecture-kicker">4 · EVIDENCE</span>
<strong>ORDS 서빙/검증</strong> <strong>결과 확인</strong>
<p>VPD가 적용된 TABLE/VIEW를 HTTP API로 호출해 결과를 확인합니다.</p> <p>ORDS를 호출해 그 사용자에게 실제로 보이는 데이터만 확인합니다.</p>
</div> </div>
</section> </section>
</body> </body>

View File

@@ -1,51 +1,66 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<body> <body>
<div th:fragment="result"> <div th:fragment="result" class="probe-result-flow">
<div class="section-heading"> <div class="section-heading">
<h2>검증 결과</h2> <div>
<span class="badge" th:classappend="${result.status().name() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'" <span class="architecture-kicker">검증 결론</span>
th:text="${result.status()}">SUCCESS</span> <h2 th:text="${result.title()}">권한 결과</h2>
</div>
<span class="badge"
th:classappend="${result.successLike()} ? ' text-bg-success' : ' text-bg-warning'"
th:text="${result.successLike()} ? '검증 완료' : '확인 필요'">검증 완료</span>
</div> </div>
<div class="alert alert-warning" th:if="${result.errorCode()}"> <p class="probe-summary" th:text="${result.plainSummary()}">결과 설명</p>
<strong th:text="${result.errorCode()}">ERROR</strong>
<span th:text="${result.errorMessage()}">message</span> <section class="result-section">
<div class="section-heading compact-heading">
<h3>적용된 사용자와 권한</h3>
<span class="badge text-bg-light" th:if="${tokenContext}" th:text="${tokenContext.statusLabel()}">ACTIVE</span>
</div>
<div class="effective-preview" th:if="${tokenContext}">
<dl>
<div><dt>사용자</dt><dd th:text="${tokenContext.username()}">user</dd></div>
<div>
<dt>직접 받은 역할</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.directRoles())}" th:text="${#strings.listJoin(tokenContext.directRoles(), ', ')}">ROLE</dd>
<dd th:if="${#lists.isEmpty(tokenContext.directRoles())}" class="text-muted">없음</dd>
</div>
<div>
<dt>소속 그룹</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.groups())}" th:text="${#strings.listJoin(tokenContext.groups(), ', ')}">GROUP</dd>
<dd th:if="${#lists.isEmpty(tokenContext.groups())}" class="text-muted">없음</dd>
</div>
<div>
<dt>그룹에서 상속된 역할</dt>
<dd th:if="${!#lists.isEmpty(tokenContext.inheritedRoles())}" th:text="${#strings.listJoin(tokenContext.inheritedRoles(), ', ')}">ROLE</dd>
<dd th:if="${#lists.isEmpty(tokenContext.inheritedRoles())}" class="text-muted">없음</dd>
</div>
</dl>
</div>
<div class="alert alert-light mb-0" th:unless="${tokenContext}">
토큰에서 사용자를 찾지 못해 역할과 그룹을 계산하지 않았습니다.
</div>
</section>
<section class="result-section">
<div class="section-heading compact-heading">
<h3>VPD가 적용된 실제 결과</h3>
<span th:if="${selectedObject}"><code th:text="${selectedObject.displayName()}">ADMIN.OBJECT</code></span>
</div>
<div class="result-metrics">
<div><span>보이는 행</span><strong th:text="${result.rowCount()}">0</strong></div>
<div>
<span>NULL/마스킹으로 확인된 컬럼</span>
<strong th:if="${!#lists.isEmpty(result.maskedColumns())}" th:text="${#strings.listJoin(result.maskedColumns(), ', ')}">column</strong>
<strong th:if="${#lists.isEmpty(result.maskedColumns())}">없음</strong>
</div>
</div> </div>
<div class="meta-row"> <div class="table-responsive mt-3" th:if="${!#lists.isEmpty(result.rows())}">
<span>Rows: <strong th:text="${result.rowCount()}">0</strong></span>
<span th:if="${!#lists.isEmpty(result.maskedColumns())}">
Masked: <code th:text="${#strings.listJoin(result.maskedColumns(), ', ')}"></code>
</span>
</div>
<div class="probe-exchange-grid">
<section class="probe-exchange">
<h3>Request Headers</h3>
<pre th:text="${result.requestHeaders()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Request Payload</h3>
<pre th:text="${result.requestPayload()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Response Headers</h3>
<pre th:text="${result.responseHeaders()} ?: '{}'">{}</pre>
</section>
<section class="probe-exchange">
<h3>Response Body</h3>
<pre th:text="${result.responseBody()} ?: ''"></pre>
</section>
</div>
<div class="table-responsive" th:if="${!#lists.isEmpty(result.rows())}">
<table class="table table-sm table-striped align-middle"> <table class="table table-sm table-striped align-middle">
<thead> <thead><tr><th th:each="column : ${result.columns()}" th:text="${column}">column</th></tr></thead>
<tr>
<th th:each="column : ${result.columns()}" th:text="${column}">column</th>
</tr>
</thead>
<tbody> <tbody>
<tr th:each="row : ${result.rows()}"> <tr th:each="row : ${result.rows()}">
<td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td> <td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td>
@@ -53,6 +68,31 @@
</tbody> </tbody>
</table> </table>
</div> </div>
</section>
<section class="next-action-card">
<h3>다음에 할 일</h3>
<p th:text="${result.nextAction()}">다음 행동</p>
<div class="action-stack">
<a class="btn btn-sm rw-btn-secondary" href="/effective-matrix">사용자별 최종 권한 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 보기</a>
<a class="btn btn-sm rw-btn-secondary" href="/tokens" th:if="${result.status().name() == 'TOKEN_NOT_FOUND' || result.status().name() == 'TOKEN_INACTIVE'}">새 토큰 발급</a>
</div>
</section>
<details class="technical-details">
<summary>기술 상세: 오류 코드와 HTTP 요청·응답 보기</summary>
<div class="alert alert-warning mt-3" th:if="${result.errorCode()}">
<strong th:text="${result.errorCode()}">ERROR</strong>
<span th:text="${result.errorMessage()}">message</span>
</div>
<div class="probe-exchange-grid mt-3">
<section class="probe-exchange"><h3>Request Headers</h3><pre th:text="${result.requestHeaders()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Request Payload</h3><pre th:text="${result.requestPayload()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Response Headers</h3><pre th:text="${result.responseHeaders()} ?: '{}'">{}</pre></section>
<section class="probe-exchange"><h3>Response Body</h3><pre th:text="${result.responseBody()} ?: ''"></pre></section>
</div>
</details>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -1,96 +1,67 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('ORDS 검증')}"></head> <head th:replace="~{fragments/layout :: head('권한 결과 확인')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>ORDS 검증</h1> <h1>권한 결과 확인</h1>
<p>ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.</p> <p>한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section> <section th:replace="~{fragments/layout :: architectureStrip('ords')}"></section>
<section class="content-band guided-check-intro">
<div>
<span class="architecture-kicker">이번 단계에서 확인하는 것</span>
<h2>“이 사용자는 이 데이터에서 무엇을 볼 수 있는가?”</h2>
<p>토큰은 사용자를 찾는 열쇠입니다. 서버가 직접 역할과 그룹 상속 역할을 합치고, VPD가 저장된 행·열 규칙을 적용한 결과를 보여줍니다.</p>
</div>
<a class="btn rw-btn-secondary" href="/tokens">테스트 토큰이 없나요? 먼저 발급하기</a>
</section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>ORDS 호출</h2> <div>
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a> <h2>검증할 사용자와 데이터 입력</h2>
<p class="section-subtitle">등록 토큰 목록에서 고르는 대신 발급할 때 복사한 원문 하나만 사용합니다.</p>
</div> </div>
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid token-form"> </div>
<form hx-post="/probe" hx-target="#probe-result" hx-swap="innerHTML" class="form-grid probe-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label class="span-2">
등록 토큰 1. 발급받은 토큰 원문
<select class="form-select" name="tokenKeyId" data-token-context-select> <input class="form-control" name="bearerToken" type="password" autocomplete="off"
<option value="">원문 직접 입력</option> placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요" required>
<option th:each="token : ${tokens}" <span class="form-hint">원문은 DB에 저장되지 않습니다. 목록에 보이는 prefix만으로는 검증할 수 없으며, 원문을 잃었다면 새 토큰을 발급해야 합니다.</span>
th:value="${token.keyId()}"
th:text="${token.displayLabel()}"
th:attr="data-username=${token.username()},
data-prefix=${token.maskedToken()},
data-status=${token.statusLabel()},
data-expires-at=${token.expiresAt()},
data-description=${token.description()},
data-direct-roles=${#strings.listJoin(token.directRoles(), '|')},
data-groups=${#strings.listJoin(token.groups(), '|')},
data-inherited-roles=${#strings.listJoin(token.inheritedRoles(), '|')}"></option>
</select>
</label> </label>
<label> <label>
Bearer Token 원문 2. 확인할 데이터
<input class="form-control" name="bearerToken" type="password" autocomplete="off" required>
<span class="form-hint">등록 토큰을 선택해도 원문은 저장되지 않아 실제 호출 시 필요합니다.</span>
</label>
<aside class="token-context-preview effective-preview span-2" data-token-context-preview>
<div class="section-heading compact-heading">
<h3>선택 토큰 컨텍스트</h3>
<span class="badge text-bg-secondary" data-token-preview="status">미선택</span>
</div>
<dl>
<div>
<dt>사용자</dt>
<dd data-token-preview="username">원문 직접 입력</dd>
</div>
<div>
<dt>Prefix</dt>
<dd><code data-token-preview="prefix">-</code></dd>
</div>
<div>
<dt>만료</dt>
<dd data-token-preview="expiresAt">-</dd>
</div>
<div>
<dt>직접 역할</dt>
<dd data-token-preview="directRoles">-</dd>
</div>
<div>
<dt>그룹</dt>
<dd data-token-preview="groups">-</dd>
</div>
<div>
<dt>그룹 상속 역할</dt>
<dd data-token-preview="inheritedRoles">-</dd>
</div>
</dl>
<p class="form-hint" data-token-preview="description">토큰을 선택하면 등록된 사용자/역할 컨텍스트를 먼저 확인할 수 있습니다.</p>
</aside>
<label>
ORDS 트랙
<select class="form-select" name="objectId" required> <select class="form-select" name="objectId" required>
<option th:each="object : ${objects}" <option th:each="object : ${objects}"
th:value="${object.objectId()}" th:value="${object.objectId()}"
th:text="${object.displayName() + ' / ' + object.ordsPath()}"></option> th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
</select> </select>
<span class="form-hint">이 객체에 저장한 권한 규칙과 실제 반환 행을 비교합니다.</span>
</label> </label>
<label> <label>
Limit 최대 확인 행 수
<input class="form-control" name="limit" type="number" min="1" max="500" value="50"> <input class="form-control" name="limit" type="number" min="1" max="500" value="50">
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
</label> </label>
<button class="btn rw-btn-primary" type="submit">호출</button> <button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
</form> </form>
</section> </section>
<section id="probe-result" class="content-band"> <section id="probe-result" class="content-band" aria-live="polite">
<div class="text-muted">검증 결과가 여기에 표시됩니다.</div> <div class="empty-result-guide">
<strong>결과는 세 가지 순서로 설명합니다.</strong>
<ol>
<li>토큰이 어떤 사용자와 역할로 해석됐는지</li>
<li>VPD 적용 후 실제로 몇 행이 보였는지</li>
<li>예상과 다를 때 어디를 확인해야 하는지</li>
</ol>
</div>
</section> </section>
</main> </main>
</body> </body>

View File

@@ -1,22 +1,35 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Bearer Token')}"></head> <head th:replace="~{fragments/layout :: head('테스트 토큰 발급')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>Bearer Token</h1> <h1>테스트 토큰 발급</h1>
<p>토큰은 발급 직후 한 번만 원문을 표시합니다.</p> <p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('token')}"></section>
<div class="alert alert-success" th:if="${message}" th:text="${message}"></div> <div class="alert alert-success" th:if="${message}" th:text="${message}"></div>
<div class="alert alert-warning" th:if="${issued}"> <section class="issued-token-card" th:if="${issued}" th:attr="data-issued-key-id=${issued.keyId()}">
<div class="fw-semibold">새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.</div> <span class="architecture-kicker">발급 완료 · 지금 한 번만 표시됩니다</span>
<code class="token-value" th:text="${issued.plainToken()}"></code> <h2>이 토큰 원문을 복사한 뒤 권한 결과 확인으로 이동하세요.</h2>
<code class="token-value" id="issued-token-value" th:text="${issued.plainToken()}"></code>
<div class="action-stack mt-3">
<button class="btn rw-btn-primary" type="button" data-copy-source="issued-token-value">토큰 원문 복사</button>
<a class="btn rw-btn-secondary" href="/probe">권한 결과 확인으로 이동</a>
</div> </div>
<p class="form-hint mb-0">화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.</p>
</section>
<section class="content-band"> <section class="content-band">
<h2>토큰 발급</h2> <div class="section-heading">
<div>
<h2>1. 검증할 사용자 선택</h2>
<p class="section-subtitle">토큰에는 사용자의 직접 역할과 그룹 상속 역할이 연결됩니다. 권한 자체를 토큰에 복사하지 않으므로 이후 권한 변경도 동적으로 반영됩니다.</p>
</div>
</div>
<form method="post" action="/tokens" class="form-grid"> <form method="post" action="/tokens" class="form-grid">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>
@@ -30,32 +43,31 @@
<input class="form-control" name="expiresAt" type="datetime-local" th:value="${defaultExpiresAt}" required> <input class="form-control" name="expiresAt" type="datetime-local" th:value="${defaultExpiresAt}" required>
</label> </label>
<label> <label>
설명 용도 메모
<input class="form-control" name="description" maxlength="200"> <input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
</label> </label>
<button class="btn btn-primary" type="submit">발급</button> <button class="btn rw-btn-primary" type="submit">2. 토큰 발급</button>
</form> </form>
</section> </section>
<section class="content-band"> <section class="content-band">
<h2>토큰 목록</h2> <div class="section-heading">
<div>
<h2>발급 이력</h2>
<p class="section-subtitle">보안상 prefix와 상태만 보관합니다. 이 목록에서는 원문을 복사하거나 복구할 수 없습니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(tokens)}">0</span>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead><tr><th>ID</th><th>사용자</th><th>용도</th><th>식별용 Prefix</th><th>만료</th><th>회수</th><th></th></tr></thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Prefix</th>
<th>Expires</th>
<th>Revoked</th>
<th></th>
</tr>
</thead>
<tbody> <tbody>
<tr th:each="token : ${tokens}"> <tr th:each="token : ${tokens}"
th:attr="data-key-id=${token.keyId()},data-description=${token.description()},data-revoked=${token.revokedAt() != null}">
<td th:text="${token.keyId()}">1</td> <td th:text="${token.keyId()}">1</td>
<td th:text="${token.username()}">user</td> <td th:text="${token.username()}">user</td>
<td><code th:text="${token.keyPrefix()}">vpd_live_x</code></td> <td th:text="${token.description()} ?: '-'">용도</td>
<td><code th:text="${token.keyPrefix() + '****'}">vpd_****</code></td>
<td th:text="${token.expiresAt()}">2026</td> <td th:text="${token.expiresAt()}">2026</td>
<td th:text="${token.revokedAt()} ?: '-'">-</td> <td th:text="${token.revokedAt()} ?: '-'">-</td>
<td> <td>
@@ -67,9 +79,7 @@
</form> </form>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(tokens)}"> <tr th:if="${#lists.isEmpty(tokens)}"><td colspan="7" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
<td colspan="6" class="text-muted">등록된 토큰이 없습니다.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>

View File

@@ -1,12 +1,13 @@
<!doctype html> <!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head th:replace="~{fragments/layout :: head('Filter Policy 관리')}"></head> <head th:replace="~{fragments/layout :: head('별도 Filter 고급 관리')}"></head>
<body> <body>
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>Filter Policy 관리</h1> <span class="badge text-bg-warning">고급 설정</span>
<p>Filter function과 적용된 VPD policy를 관리합니다. TABLE/VIEW 개별/벌크 적용은 VPD 설정에서 처리합니다.</p> <h1 class="mt-2">별도 Filter 관리</h1>
<p>일반 권한은 이 화면에서 만들지 않습니다. 권한체계로 표현할 수 없는 객체 전용 조건이 있을 때만 별도 함수를 추가합니다.</p>
</div> </div>
<div class="alert alert-warning" th:if="${runtimeError}"> <div class="alert alert-warning" th:if="${runtimeError}">
@@ -16,173 +17,161 @@
<div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}">등록되었습니다.</div> <div class="alert alert-success" th:if="${successMessage}" th:text="${successMessage}">등록되었습니다.</div>
<div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div> <div class="alert alert-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div>
<section class="content-band"> <section class="content-band default-filter-explainer">
<div class="section-heading"> <div class="section-heading">
<h2>Filter 등록/수정</h2> <div>
<a class="btn btn-sm rw-btn-secondary" href="/vpd-policies">VPD 적용 화면</a> <span class="architecture-kicker">기본 동작: 권한체계 자동 반영</span>
<h2>대부분의 변경은 권한 규칙 화면에서 끝납니다.</h2>
</div> </div>
<form method="post" action="/vpd-filter-policies/filters" class="form-grid"> <a class="btn btn-sm rw-btn-primary" href="/permissions">권한 규칙으로 이동</a>
</div>
<div class="macro-micro-grid">
<div>
<h3>운영자가 관리하는 것</h3>
<p>사용자, 그룹, 역할, 객체별 ALLOW/DENY, 행 조건과 컬럼 공개 범위를 관리합니다.</p>
</div>
<div>
<h3>DB가 자동으로 하는 것</h3>
<p><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청마다 그 권한을 합쳐 predicate를 만들고, 권한이나 유효한 컨텍스트가 없으면 <code>1=0</code>으로 차단합니다.</p>
</div>
</div>
<div class="protected-function-card">
<strong>보호되는 핵심 함수</strong>
<code>CB_AGENT_DOC_VPD_FILTER</code>
<span>이 화면과 서버 양쪽에서 직접 덮어쓰기를 차단합니다.</span>
</div>
</section>
<section class="content-band">
<details class="advanced-details">
<summary>고급: 별도 Filter 만들기</summary>
<div class="advanced-guidance mt-3">
<h2>먼저 이 기준을 확인하세요.</h2>
<ul>
<li><strong>사용 가능:</strong> 권한 테이블로 표현할 수 없는 객체 고유 조건을 별도 함수로 격리할 때</li>
<li><strong>사용 금지:</strong> 부서, 본인, 특정 값, 역할별 허용처럼 기존 권한 규칙이 지원하는 조건을 중복 작성할 때</li>
<li><strong>안전 원칙:</strong> 컨텍스트가 없거나 값이 잘못되면 반드시 <code>1=0</code>인 fail-closed 결과를 사용</li>
<li><strong>검증 원칙:</strong> 허용·거부·빈 컨텍스트·예상 밖 값 테스트를 만든 뒤 한 객체에 먼저 적용</li>
</ul>
</div>
<form method="post" action="/vpd-filter-policies/filters" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>
Function Owner Function owner
<select class="form-select" name="functionOwner"> <select class="form-select" name="functionOwner">
<option value="">현재 연결 사용자</option> <option value="">현재 연결 사용자</option>
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option> <option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
</select> </select>
</label> </label>
<label> <label>
Function 이름 Function 이름
<input class="form-control" name="functionName" list="filter-function-names" <input class="form-control" name="functionName" placeholder="예: BOARD_POSTS_SPECIAL_FILTER" required>
placeholder="예: BOARD_POSTS_FILTER" required> <span class="form-hint">CB_AGENT_DOC_VPD_FILTER 이름은 사용할 수 없습니다.</span>
</label> </label>
<label class="span-2"> <label class="span-2">
Filter predicate 반환할 predicate
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4" <textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea> placeholder="예: tenant_id = SYS_CONTEXT('CB_AGENT_CTX', 'TENANT_ID')" required></textarea>
</label> </label>
<div class="question-presets span-2" aria-label="Filter predicate 예시"> <div class="question-presets span-2" aria-label="Filter predicate 안전 예시">
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">전체 차단</button> <button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=0">안전한 기본 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="1=1">전체 허용</button> <button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">컨텍스트 비교 예시</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">부서 일치</button>
<button class="btn rw-btn-secondary question-preset" type="button" data-target="filter-only-predicate" data-question="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">본인 소유</button>
</div> </div>
<button class="btn rw-btn-primary" type="submit">Filter 저장</button> <button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
</form> </form>
<datalist id="filter-function-names">
<option th:each="function : ${formOptions.functions()}" th:value="${function.functionName()}"></option>
</datalist>
</section>
<section class="content-band"> <div class="section-heading mt-4">
<div class="section-heading"> <h2>설치된 Filter 함수</h2>
<h2>Filter 목록</h2>
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span> <span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead><tr><th>Function</th><th>구분</th><th>Source / 변경</th></tr></thead>
<tr>
<th>Function</th>
<th>Type</th>
<th>Source</th>
</tr>
</thead>
<tbody> <tbody>
<th:block th:each="function, iter : ${formOptions.functions()}"> <th:block th:each="function, iter : ${formOptions.functions()}">
<tr> <tr>
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td> <td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
<td th:text="${function.objectType()}">FUNCTION</td>
<td> <td>
<button class="btn btn-sm rw-btn-secondary" <span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
type="button" <span class="badge text-bg-warning" th:unless="${function.permissionSystemDefault()}">별도 Filter</span>
</td>
<td>
<button class="btn btn-sm rw-btn-secondary" type="button"
th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}" th:hx-get="@{/vpd-policies/function-source(owner=${function.owner()},packageName=${function.packageName()},functionName=${function.functionName()})}"
th:hx-target="${'#filter-source-' + iter.index}" th:hx-target="${'#filter-source-' + iter.index}" hx-swap="innerHTML">Source 보기</button>
hx-swap="innerHTML">
Source 보기
</button>
</td> </td>
</tr> </tr>
<tr> <tr>
<td colspan="3" class="policy-source-cell"> <td colspan="3" class="policy-source-cell">
<div class="filter-edit-grid"> <div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
<div th:id="${'filter-source-' + iter.index}" class="text-muted small"> <div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
Source 보기를 누르면 현재 function source가 표시됩니다. 이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
</div> </div>
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form"> <form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form mt-3"
th:unless="${function.permissionSystemDefault()}">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="functionOwner" th:value="${function.owner()}"> <input type="hidden" name="functionOwner" th:value="${function.owner()}">
<input type="hidden" name="functionName" th:value="${function.functionName()}"> <input type="hidden" name="functionName" th:value="${function.functionName()}">
<label> <label>
수정할 Filter predicate 이 별도 Filter의 새 predicate
<textarea class="form-control" name="filterPredicate" rows="3" <textarea class="form-control" name="filterPredicate" rows="3" required></textarea>
th:id="${'filter-edit-predicate-' + iter.index}"
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
</label> </label>
<div class="question-presets" aria-label="Filter predicate 예시"> <button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=0">전체 차단</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="1=1">전체 허용</button>
<button class="btn rw-btn-secondary question-preset" type="button"
th:attr="data-target=${'filter-edit-predicate-' + iter.index}"
data-question="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">부서 일치</button>
</div>
<button class="btn btn-sm rw-btn-primary" type="submit">Filter 수정</button>
</form> </form>
</div>
</td> </td>
</tr> </tr>
</th:block> </th:block>
<tr th:if="${#lists.isEmpty(formOptions.functions())}"> <tr th:if="${#lists.isEmpty(formOptions.functions())}"><td colspan="3" class="text-muted">설치된 VPD 함수가 없습니다.</td></tr>
<td colspan="3" class="text-muted">등록된 VPD filter function 후보가 없습니다.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</details>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <details class="advanced-details">
<h2>적용된 Policy 목록/수정</h2> <summary>고급: 적용된 Policy를 별도 Filter로 교체</summary>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span> <div class="advanced-guidance mt-3">
<strong>영향 범위</strong>
<p>기존 policy를 삭제한 뒤 새 policy를 등록합니다. 기본 권한체계 경로에서 벗어나므로 대상 객체, 거부 조건, 복구할 기본 함수와 검증 토큰을 먼저 준비하세요.</p>
</div> </div>
<div class="table-responsive"> <div class="table-responsive mt-3">
<table class="table table-sm align-middle"> <table class="table table-sm align-middle">
<thead> <thead><tr><th>Object</th><th>현재 Policy</th><th>현재 Function</th><th>구분</th><th>교체</th></tr></thead>
<tr>
<th>Object</th>
<th>Policy</th>
<th>Function</th>
<th>Status</th>
<th>수정</th>
</tr>
</thead>
<tbody> <tbody>
<tr th:each="policy : ${policies}"> <tr th:each="policy : ${policies}">
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td> <td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
<td><code th:text="${policy.policyName()}">POLICY</code></td> <td><code th:text="${policy.policyName()}">POLICY</code></td>
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td> <td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
<td th:text="${policy.enabled()}">YES</td>
<td> <td>
<form method="post" action="/vpd-filter-policies/replace" class="inline-form"> <span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
</td>
<td>
<form method="post" action="/vpd-filter-policies/replace" class="filter-replace-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}"> <input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}"> <input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}"> <input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
<input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}"> <input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}" aria-label="새 policy 이름">
<select class="form-select form-select-sm" name="functionKey"> <select class="form-select form-select-sm" name="functionKey" aria-label="새 filter function">
<option th:each="function : ${formOptions.functions()}" <option th:each="function : ${formOptions.functions()}"
th:value="${function.value()}" th:value="${function.value()}" th:text="${function.value()}"
th:text="${function.value()}"
th:selected="${function.value() == policy.functionDisplayName()}"></option> th:selected="${function.value() == policy.functionDisplayName()}"></option>
</select> </select>
<input type="hidden" name="enabled" value="true"> <input type="hidden" name="enabled" value="true">
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'"> <input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
<button class="btn btn-sm rw-btn-secondary" type="submit">수정</button> <button class="btn btn-sm btn-outline-warning" type="submit">영향 확인 후 교체</button>
</form> </form>
</td> </td>
</tr> </tr>
<tr th:if="${#lists.isEmpty(policies)}"> <tr th:if="${#lists.isEmpty(policies)}"><td colspan="5" class="text-muted">적용된 VPD policy가 없습니다.</td></tr>
<td colspan="5" class="text-muted">등록된 VPD policy가 없습니다.</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</details>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -5,8 +5,8 @@
<nav th:replace="~{fragments/layout :: nav}"></nav> <nav th:replace="~{fragments/layout :: nav}"></nav>
<main class="container py-4"> <main class="container py-4">
<div class="page-title"> <div class="page-title">
<h1>VPD 설정</h1> <h1>DB 보호 연결</h1>
<p>Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.</p> <p>권한 화면에서 만든 사용자·그룹·역할·행·열 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다.</p>
</div> </div>
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section> <section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
@@ -20,24 +20,29 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>VPD 적용</h2> <div>
<a class="btn btn-sm rw-btn-primary" href="/vpd-filter-policies">Filter Policy 관리</a> <span class="architecture-kicker">기본 동작</span>
<h2>권한체계 자동 적용</h2>
</div>
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 확인</a>
</div> </div>
<div class="policy-apply-flow" aria-label="VPD 적용 계층"> <div class="policy-apply-flow" aria-label="VPD 적용 계층">
<span>Filter Function</span> <span>사용자·그룹·역할</span>
<strong></strong> <strong></strong>
<span>Policy Template</span> <span>객체별 행·열 권한</span>
<strong></strong> <strong></strong>
<span>TABLE/VIEW 적용</span> <span>동적 VPD</span>
<strong></strong>
<span>허용된 데이터만 반환</span>
</div> </div>
<p class="text-muted mb-0">VPD는 개별 보호 객체 적용을 기본으로 하고, 같은 Policy Template을 스키마 TABLE/VIEW에 확장할 때만 벌크 적용을 사용합니다.</p> <p class="text-muted mb-0"><code>CB_AGENT_DOC_VPD_FILTER</code>가 요청할 때마다 현재 권한체계를 읽습니다. 일상적인 권한 변경은 이 함수나 predicate가 아니라 <a href="/permissions">권한 규칙</a>에서 하세요.</p>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<div> <div>
<h2>VPD 적용 대상 TABLE/VIEW</h2> <h2>보호할 수 있는 DB 객체</h2>
<p class="section-subtitle">Oracle DB catalog 기준의 TABLE/VIEW 목록입니다. ORDS handler는 여러 객체를 조합할 수 있으므로 이 표의 ORDS 경로는 사용 여부가 아니라 백오피스에 등록된 검증용 경로입니다.</p> <p class="section-subtitle">VPD가 붙었는지, 권한 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.</p>
</div> </div>
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span> <span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>
</div> </div>
@@ -186,73 +191,40 @@
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>개별 적용</h2> <div>
<span class="architecture-kicker">권장 · 일반 작업</span>
<h2>객체 하나에 권한체계 연결</h2>
<p class="section-subtitle">객체만 선택하면 표준 동적 함수, SELECT 정책, 즉시 활성화를 서버가 안전한 기본값으로 적용합니다.</p>
</div> </div>
<form method="post" action="/vpd-policies" class="form-grid"> </div>
<form method="post" action="/vpd-policies/default" class="form-grid default-vpd-form">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<label> <label>
VPD 적용 대상 TABLE/VIEW 보호할 TABLE/VIEW
<select class="form-select" name="objectKey" required> <select class="form-select" name="objectKey" required>
<option th:each="target : ${vpdTargets}" <option th:each="target : ${vpdTargets}"
th:value="${target.objectDisplayName()}" th:value="${target.objectDisplayName()}"
th:text="${target.objectDisplayName() + ' / ' + target.objectType() + (target.vpdApplied() ? ' / VPD 적용됨' : ' / 미적용')}"></option> th:text="${target.objectDisplayName() + ' · ' + target.objectType() + (target.vpdApplied() ? ' · 이미 VPD 있음' : ' · 연결 필요')}"></option>
</select> </select>
</label> </label>
<label class="span-2"> <aside class="default-policy-summary">
적용할 Policy Template <strong>자동 적용 내용</strong>
<select class="form-select" data-policy-template-select required> <span>권한체계 동적 조회 · SELECT 보호 · 즉시 활성화 · 권한 없음은 차단</span>
<option value="">등록된 policy template 선택</option>
<option th:each="template : ${formOptions.policyTemplates()}"
th:value="${template.label()}"
th:data-policy-name="${template.policyName()}"
th:data-function-key="${template.functionKey()}"
th:data-statement-types="${template.statementTypes()}"
th:data-enabled="${template.enabledValue()}"
th:data-update-check="${template.updateCheckValue()}"
th:text="${template.label()}"></option>
</select>
</label>
<input type="hidden" name="policyName" data-policy-template-field="policyName">
<input type="hidden" name="functionKey" data-policy-template-field="functionKey">
<aside class="policy-template-preview span-2" data-policy-template-summary>
<dl>
<div><dt>Policy</dt><dd data-policy-template-preview="policyName">선택 전</dd></div>
<div><dt>Filter Function</dt><dd data-policy-template-preview="functionKey">선택 전</dd></div>
<div><dt>Statements</dt><dd data-policy-template-preview="statementTypes">SELECT</dd></div>
<div><dt>Options</dt><dd data-policy-template-preview="options">Enabled: YES / Check: NO</dd></div>
</dl>
</aside> </aside>
<div> <button class="btn rw-btn-primary" type="submit">권한체계 자동 적용</button>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}"
data-policy-template-statement>
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="vpd-enabled" type="checkbox" name="enabled" value="true" checked
data-policy-template-field="enabled">
<label class="form-check-label" for="vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="vpd-update-check" type="checkbox" name="updateCheck" value="true"
data-policy-template-field="updateCheck">
<label class="form-check-label" for="vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<p class="text-muted small span-2 mb-0">Policy Template은 policy name과 filter function의 조합입니다. Filter Function은 Filter Policy 관리에서 등록/수정하고, 여기서는 선택한 template을 대상 TABLE/VIEW에 적용합니다.</p>
<button class="btn rw-btn-primary" type="submit">개별 객체에 VPD 적용</button>
</form> </form>
</section> </section>
<section class="content-band"> <section class="content-band">
<details> <details>
<summary class="bulk-apply-summary">벌크 적용</summary> <summary class="bulk-apply-summary">고급: 여러 객체에 권한체계 일괄 연결</summary>
<div class="advanced-guidance mt-3">
<strong>언제 사용하나요?</strong>
<p>한 스키마의 TABLE/VIEW가 모두 같은 권한체계를 사용해야 하고, 대상 목록을 먼저 검토했을 때만 사용합니다. 기존 policy가 있는 객체는 건너뜁니다.</p>
</div>
<form method="post" action="/vpd-policies/bulk" class="form-grid mt-3"> <form method="post" action="/vpd-policies/bulk" class="form-grid mt-3">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"> <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
<input type="hidden" name="enabled" value="true">
<label> <label>
Schema Schema
<select class="form-select" name="schemaOwner" required> <select class="form-select" name="schemaOwner" required>
@@ -272,60 +244,18 @@
</label> </label>
</div> </div>
</div> </div>
<label class="span-2"> <p class="text-muted small span-2 mb-0">모든 대상에 표준 <code>CB_PERMISSION_SELECT_POLICY</code><code>CB_AGENT_DOC_VPD_FILTER</code>를 적용합니다. 별도 predicate가 필요하면 운영·고급 메뉴의 가이드를 먼저 확인하세요.</p>
적용할 Policy Template <button class="btn rw-btn-primary" type="submit">선택 스키마에 권한체계 일괄 연결</button>
<select class="form-select" data-policy-template-select required>
<option value="">등록된 policy template 선택</option>
<option th:each="template : ${formOptions.policyTemplates()}"
th:value="${template.label()}"
th:data-policy-name="${template.policyName()}"
th:data-function-key="${template.functionKey()}"
th:data-statement-types="${template.statementTypes()}"
th:data-enabled="${template.enabledValue()}"
th:data-update-check="${template.updateCheckValue()}"
th:text="${template.label()}"></option>
</select>
</label>
<input type="hidden" name="policyName" data-policy-template-field="policyName">
<input type="hidden" name="functionKey" data-policy-template-field="functionKey">
<aside class="policy-template-preview span-2" data-policy-template-summary>
<dl>
<div><dt>Policy</dt><dd data-policy-template-preview="policyName">선택 전</dd></div>
<div><dt>Filter Function</dt><dd data-policy-template-preview="functionKey">선택 전</dd></div>
<div><dt>Statements</dt><dd data-policy-template-preview="statementTypes">SELECT</dd></div>
<div><dt>Options</dt><dd data-policy-template-preview="options">Enabled: YES / Check: NO</dd></div>
</dl>
</aside>
<div>
Statement Types
<div class="checkbox-row">
<label class="form-check" th:each="statement : ${formOptions.statementTypes()}">
<input class="form-check-input" type="checkbox" name="statementTypes"
th:value="${statement}" th:checked="${statement == 'SELECT'}"
data-policy-template-statement>
<span class="form-check-label" th:text="${statement}">SELECT</span>
</label>
</div>
</div>
<div class="form-check align-self-end">
<input class="form-check-input" id="bulk-vpd-enabled" type="checkbox" name="enabled" value="true" checked
data-policy-template-field="enabled">
<label class="form-check-label" for="bulk-vpd-enabled">등록 즉시 활성화</label>
</div>
<div class="form-check span-2">
<input class="form-check-input" id="bulk-vpd-update-check" type="checkbox" name="updateCheck" value="true"
data-policy-template-field="updateCheck">
<label class="form-check-label" for="bulk-vpd-update-check">INSERT/UPDATE에도 predicate check 적용</label>
</div>
<p class="text-muted small span-2 mb-0">벌크 적용은 선택한 Policy Template을 스키마의 TABLE/VIEW 목록에 적용합니다. Filter Function을 직접 선택하거나 predicate를 입력하지 않습니다.</p>
<button class="btn rw-btn-primary" type="submit">스키마 TABLE/VIEW에 VPD 일괄 적용</button>
</form> </form>
</details> </details>
</section> </section>
<section class="content-band"> <section class="content-band">
<div class="section-heading"> <div class="section-heading">
<h2>VPD Policies</h2> <div>
<h2>현재 적용된 DB 보호 정책</h2>
<p class="section-subtitle">“권한체계 자동” 표시는 권한 규칙 변경을 요청 시점에 동적으로 반영하는 표준 정책입니다.</p>
</div>
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span> <span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
</div> </div>
@@ -368,6 +298,8 @@
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code> <code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
</button> </button>
<div class="text-muted small">클릭하면 filter source를 봅니다.</div> <div class="text-muted small">클릭하면 filter source를 봅니다.</div>
<span class="badge text-bg-primary" th:if="${policy.permissionSystemDefault()}">권한체계 자동</span>
<span class="badge text-bg-warning" th:unless="${policy.permissionSystemDefault()}">별도 Filter</span>
</td> </td>
<td th:text="${policy.statementTypes()} ?: '-'">SELECT</td> <td th:text="${policy.statementTypes()} ?: '-'">SELECT</td>
<td> <td>
@@ -416,16 +348,5 @@
</div> </div>
</section> </section>
</main> </main>
<script>
document.querySelectorAll('.question-preset').forEach((button) => {
button.addEventListener('click', () => {
const target = document.getElementById(button.dataset.target || '');
if (target) {
target.value = button.dataset.question || '';
target.focus();
}
});
});
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,100 @@
package com.cloudhandson.vpdbackoffice.domain.probe;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
class ProbeResultTest {
@Test
void explainsSuccessfulResultInPlainLanguage() {
ProbeResult result = new ProbeResult(
ProbeStatus.SUCCESS,
List.of("DOC_ID"),
List.of(Map.of("DOC_ID", 1), Map.of("DOC_ID", 2)),
2,
List.of(),
null,
null,
null,
null,
null,
null
);
assertThat(result.successLike()).isTrue();
assertThat(result.title()).contains("데이터를 볼 수 있습니다");
assertThat(result.plainSummary()).contains("2개").contains("VPD");
assertThat(result.nextAction()).contains("예상한 범위");
}
@Test
void treatsEmptyRowsAsAnEnforcedPermissionOutcome() {
ProbeResult result = new ProbeResult(
ProbeStatus.VPD_DENY_EMPTY_RESULT,
List.of(),
List.of(),
0,
List.of(),
null,
null,
null,
null,
null,
null
);
assertThat(result.successLike()).isTrue();
assertThat(result.title()).contains("볼 수 있는 행이 없습니다");
assertThat(result.plainSummary()).contains("오류가 아닐 수 있습니다");
assertThat(result.nextAction()).contains("유효 권한");
}
@Test
void givesActionableGuidanceForUnknownToken() {
ProbeResult result = ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND,
"TOKEN_NOT_FOUND",
"토큰을 찾을 수 없습니다."
);
assertThat(result.successLike()).isFalse();
assertThat(result.title()).contains("등록되지 않은 토큰");
assertThat(result.plainSummary()).contains("DB").contains("원문");
assertThat(result.nextAction()).contains("새 토큰").contains("발급");
}
@Test
void distinguishesInactiveTokenAndOrdsFailure() {
ProbeResult inactive = ProbeResult.blocked(
ProbeStatus.TOKEN_INACTIVE,
"TOKEN_INACTIVE",
"만료되었거나 회수된 토큰입니다."
);
ProbeResult unavailable = ProbeResult.blocked(
ProbeStatus.ORDS_UNAVAILABLE,
"ORDS_UNAVAILABLE",
"연결할 수 없습니다."
);
assertThat(inactive.title()).contains("만료되었거나 회수");
assertThat(inactive.nextAction()).contains("활성 토큰");
assertThat(unavailable.title()).contains("ORDS");
assertThat(unavailable.nextAction()).contains("권한 설정을 바꾸지 말고");
}
@Test
void identifiesBrokenCustomVpdFilterSeparatelyFromTokenErrors() {
ProbeResult result = ProbeResult.blocked(
ProbeStatus.VPD_FILTER_ERROR,
"VPD_FILTER_ERROR",
"ORA-28110"
);
assertThat(result.title()).contains("VPD Filter");
assertThat(result.plainSummary()).contains("토큰과 사용자 권한은 확인");
assertThat(result.nextAction()).contains("토큰이나 권한을 바꾸지 말고").contains("자동 Filter");
}
}

View File

@@ -37,6 +37,14 @@ class ProbeErrorClassifierTest {
.isEqualTo(ProbeStatus.ORDS_PATH_NOT_FOUND); .isEqualTo(ProbeStatus.ORDS_PATH_NOT_FOUND);
} }
@Test
void classifiesBrokenVpdPolicyFunction() {
assertThat(classifier.classify(
HttpStatus.INTERNAL_SERVER_ERROR,
"SQL Error Code 28110, Error Message: ORA-28110: The VPD policy function has error"
)).isEqualTo(ProbeStatus.VPD_FILTER_ERROR);
}
@Test @Test
void detectsOrdsConnectionRefused() { void detectsOrdsConnectionRefused() {
assertThat(classifier.isUnavailable(new ResourceAccessException( assertThat(classifier.isUnavailable(new ResourceAccessException(

View File

@@ -0,0 +1,96 @@
package com.cloudhandson.vpdbackoffice.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdFunctionOption;
import com.cloudhandson.vpdbackoffice.domain.vpd.VpdPolicyFormOptions;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
class VpdPolicyServiceTest {
@Test
void protectsTheDynamicPermissionFunctionFromManualOverwrite() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null);
assertThatThrownBy(() -> service.saveFilterFunction(
"ADMIN",
"CB_AGENT_DOC_VPD_FILTER",
"1=1"
))
.isInstanceOf(AppException.class)
.hasMessageContaining("권한체계")
.hasMessageContaining("수정할 수 없습니다");
assertThat(jdbcTemplate.updateCount).isZero();
}
@Test
void defaultApplyAlwaysUsesTheDynamicPermissionFunction() {
RecordingJdbcTemplate jdbcTemplate = new RecordingJdbcTemplate();
VpdPolicyService service = new VpdPolicyService(null, jdbcTemplate, null) {
@Override
public VpdPolicyFormOptions formOptions() {
return new VpdPolicyFormOptions(
List.of(),
List.of("ADMIN"),
List.of("ADMIN"),
List.of(new VpdFunctionOption("ADMIN", null, "CB_AGENT_DOC_VPD_FILTER", "FUNCTION")),
List.of(),
List.of("SELECT")
);
}
};
service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS");
assertThat(jdbcTemplate.lastSql).contains("DBMS_RLS.ADD_POLICY").contains("DBMS_RLS.DYNAMIC");
assertThat(jdbcTemplate.lastArgs).containsExactly(
"ADMIN",
"DOCUMENTS",
"CB_PERMISSION_SELECT_POLICY",
"ADMIN",
"CB_AGENT_DOC_VPD_FILTER",
"SELECT"
);
}
@Test
void defaultApplyFailsClosedWhenPermissionFunctionIsNotInstalled() {
VpdPolicyService service = new VpdPolicyService(null, new RecordingJdbcTemplate(), null) {
@Override
public VpdPolicyFormOptions formOptions() {
return new VpdPolicyFormOptions(
List.of(), List.of(), List.of(), List.of(), List.of(), List.of("SELECT")
);
}
};
assertThatThrownBy(() -> service.createDefaultPermissionPolicy("ADMIN.DOCUMENTS"))
.isInstanceOf(AppException.class)
.hasMessageContaining("동적 권한 필터")
.hasMessageContaining("설치");
}
private static class RecordingJdbcTemplate extends JdbcTemplate {
private String lastSql;
private Object[] lastArgs = new Object[0];
private int updateCount;
@Override
public <T> T queryForObject(String sql, Class<T> requiredType) {
return requiredType.cast("ADMIN");
}
@Override
public int update(String sql, Object... args) {
lastSql = sql;
lastArgs = args;
updateCount++;
return 1;
}
}
}

View File

@@ -0,0 +1,55 @@
package com.cloudhandson.vpdbackoffice.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;
class GuidedFlowTemplateTest {
@Test
void dashboardExplainsTheFourStepPermissionJourney() throws IOException {
String html = template("dashboard.html");
assertThat(html)
.contains("1. 권한 설계")
.contains("2. DB 보호 연결")
.contains("3. 토큰 발급")
.contains("4. 결과 확인");
}
@Test
void probeUsesOneTokenInputAndHidesTechnicalExchangeByDefault() throws IOException {
String probe = template("probe.html");
String result = template("fragments/probe-result.html");
assertThat(probe).contains("name=\"bearerToken\"").doesNotContain("name=\"tokenKeyId\"");
assertThat(result)
.contains("적용된 사용자와 권한")
.contains("다음에 할 일")
.contains("<details")
.contains("기술 상세");
}
@Test
void vpdDefaultApplyAndAdvancedCustomFiltersAreVisuallySeparated() throws IOException {
String policies = template("vpd-policies.html");
String filters = template("vpd-filter-policies.html");
assertThat(policies)
.contains("action=\"/vpd-policies/default\"")
.contains("권한체계 자동 적용")
.contains("객체만 선택");
assertThat(filters)
.contains("기본 동작: 권한체계 자동 반영")
.contains("고급: 별도 Filter 만들기")
.contains("fail-closed")
.contains("CB_AGENT_DOC_VPD_FILTER");
}
private String template(String relativePath) throws IOException {
return Files.readString(Path.of("src/main/resources/templates").resolve(relativePath));
}
}