fix #557: guide permission-driven VPD flow
This commit is contained in:
110
docs/design/557-guided-permission-vpd-flow/README.md
Normal file
110
docs/design/557-guided-permission-vpd-flow/README.md
Normal 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에 없으며, 환경 비밀 교체는 저장소 밖 운영 작업으로 남긴다.
|
||||
@@ -44,4 +44,65 @@ public record ProbeResult(
|
||||
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 -> "아래 기술 상세의 오류 코드와 응답을 확인한 뒤 해당 단계부터 점검하세요.";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ public enum ProbeStatus {
|
||||
OBJECT_DISABLED,
|
||||
INVALID_TOKEN,
|
||||
OBJECT_NOT_ACCESSIBLE,
|
||||
VPD_FILTER_ERROR,
|
||||
ORDS_PATH_NOT_FOUND,
|
||||
ORDS_NOT_CONFIGURED,
|
||||
ORDS_UNAVAILABLE,
|
||||
|
||||
@@ -15,4 +15,8 @@ public record VpdFunctionOption(
|
||||
public String label() {
|
||||
return value() + " / " + objectType;
|
||||
}
|
||||
|
||||
public boolean permissionSystemDefault() {
|
||||
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ public record VpdPolicyView(
|
||||
String packagePrefix = packageName == null || packageName.isBlank() ? "" : packageName + ".";
|
||||
return functionOwner + "." + packagePrefix + functionName;
|
||||
}
|
||||
|
||||
public boolean permissionSystemDefault() {
|
||||
return "CB_AGENT_DOC_VPD_FILTER".equalsIgnoreCase(functionName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,6 +87,17 @@ public class BearerTokenService {
|
||||
.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) {
|
||||
return tokenMapper.findById(keyId);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,10 @@ public class OrdsProbeService {
|
||||
token = tokenService.findByPlainToken(command.bearerToken());
|
||||
if (token == null) {
|
||||
return auditAndReturn(command, ProbeResult.blocked(
|
||||
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다."));
|
||||
ProbeStatus.TOKEN_NOT_FOUND,
|
||||
"TOKEN_NOT_FOUND",
|
||||
"현재 DB에 등록된 토큰이 아닙니다. 토큰 화면에서 이 환경의 새 토큰을 발급하세요."
|
||||
));
|
||||
}
|
||||
} else {
|
||||
token = tokenService.findById(command.tokenKeyId());
|
||||
|
||||
@@ -20,6 +20,9 @@ public class ProbeErrorClassifier {
|
||||
if (text.contains("ORA-00942") || text.contains("ORA-01031")) {
|
||||
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)) {
|
||||
return ProbeStatus.INVALID_TOKEN;
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ public class VpdPolicyService {
|
||||
|
||||
@Transactional
|
||||
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 functionOwner = functionOwnerValue == null || functionOwnerValue.isBlank()
|
||||
? currentUser
|
||||
@@ -104,7 +109,6 @@ public class VpdPolicyService {
|
||||
throw new AppException("Filter function 등록/수정은 현재 연결 사용자 스키마에만 가능합니다. 현재 사용자: "
|
||||
+ currentUser + ", Function owner: " + functionOwner);
|
||||
}
|
||||
String functionName = requiredIdentifier(functionNameValue, "Function name");
|
||||
String filterPredicate = filterPredicateValue == null ? "" : filterPredicateValue.trim();
|
||||
if (filterPredicate.isBlank()) {
|
||||
throw new AppException("Filter predicate는 필수입니다.");
|
||||
@@ -113,6 +117,31 @@ public class VpdPolicyService {
|
||||
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
|
||||
public void replacePolicy(String oldObjectKey, String oldPolicyName, VpdPolicyCreateCommand command) {
|
||||
String[] objectParts = oldObjectKey == null ? new String[0] : oldObjectKey.split("\\.", 2);
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.cloudhandson.vpdbackoffice.web;
|
||||
|
||||
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.OrdsProbeService;
|
||||
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.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -16,33 +22,58 @@ public class ProbeController {
|
||||
private final OrdsProbeService probeService;
|
||||
private final ProtectedObjectService protectedObjectService;
|
||||
private final BearerTokenService tokenService;
|
||||
private final VpdPolicyService vpdPolicyService;
|
||||
|
||||
public ProbeController(
|
||||
OrdsProbeService probeService,
|
||||
ProtectedObjectService protectedObjectService,
|
||||
BearerTokenService tokenService
|
||||
BearerTokenService tokenService,
|
||||
VpdPolicyService vpdPolicyService
|
||||
) {
|
||||
this.probeService = probeService;
|
||||
this.protectedObjectService = protectedObjectService;
|
||||
this.tokenService = tokenService;
|
||||
this.vpdPolicyService = vpdPolicyService;
|
||||
}
|
||||
|
||||
@GetMapping("/probe")
|
||||
public String probe(Model model) {
|
||||
model.addAttribute("objects", protectedObjectService.findEnabled());
|
||||
model.addAttribute("tokens", tokenService.findTokenContextOptions());
|
||||
Set<String> defaultObjectKeys = defaultPermissionObjectKeys();
|
||||
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";
|
||||
}
|
||||
|
||||
@PostMapping("/probe")
|
||||
public String run(
|
||||
@RequestParam(required = false) Long tokenKeyId,
|
||||
@RequestParam long objectId,
|
||||
@RequestParam String bearerToken,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
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";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,26 @@ public class VpdPolicyController {
|
||||
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")
|
||||
public String saveFilter(
|
||||
@RequestParam(required = false) String functionOwner,
|
||||
|
||||
@@ -213,7 +213,7 @@ body {
|
||||
align-items: stretch;
|
||||
display: grid;
|
||||
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;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -1508,3 +1508,323 @@ body {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
@@ -811,6 +849,8 @@ function initVpdTargetFilters() {
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initPersistentMenus();
|
||||
initQuestionPresets();
|
||||
initCopyButtons();
|
||||
renderMarkdownViews();
|
||||
initVpdTargetFilters();
|
||||
const master = document.getElementById('userRoleMaster');
|
||||
|
||||
@@ -1,64 +1,93 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>VPD 권한 백오피스</h1>
|
||||
<p>Oracle Database의 VPD/ORDS 기능을 권한 테이블과 운영 UI로 제어하고 검증합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('')}"></section>
|
||||
<header class="page-title guided-hero">
|
||||
<span class="architecture-kicker">권한을 정하면 DB가 그대로 지킵니다</span>
|
||||
<h1>누가 어떤 데이터를 볼 수 있는지 설계하고, 실제 결과까지 확인하세요.</h1>
|
||||
<p>사용자·그룹·역할에 권한을 연결하면 Oracle VPD가 요청할 때마다 그 규칙을 읽어 허용된 행만 반환합니다. 별도 SQL 필터를 만드는 일은 예외적인 고급 작업입니다.</p>
|
||||
</header>
|
||||
|
||||
<div class="alert alert-warning" th:if="${runtimeErrorMessage}">
|
||||
<div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
|
||||
<div th:text="${runtimeErrorMessage}"></div>
|
||||
<div class="mt-2" th:if="${showSupportCommand}">
|
||||
<code>./run.sh backoffice-support</code>
|
||||
</div>
|
||||
<div class="mt-2" th:if="${showSupportCommand}"><code>./run.sh backoffice-support</code></div>
|
||||
</div>
|
||||
|
||||
<section class="summary-grid">
|
||||
<a class="summary-tile" href="/permissions">
|
||||
<span class="label">보호 객체</span>
|
||||
<strong th:text="${#lists.size(objects)}">0</strong>
|
||||
<section class="journey-grid" aria-label="권한 적용 네 단계">
|
||||
<a class="journey-card" href="/permissions">
|
||||
<span class="journey-number">1</span>
|
||||
<div>
|
||||
<h2>1. 권한 설계</h2>
|
||||
<p>사용자와 그룹에 역할을 주고, 역할마다 볼 수 있는 객체·행·컬럼을 정합니다.</p>
|
||||
<strong>권한 규칙 만들기 →</strong>
|
||||
</div>
|
||||
</a>
|
||||
<a class="summary-tile" href="/permissions">
|
||||
<span class="label">역할</span>
|
||||
<strong th:text="${#lists.size(roles)}">0</strong>
|
||||
<a class="journey-card" href="/vpd-policies">
|
||||
<span class="journey-number">2</span>
|
||||
<div>
|
||||
<h2>2. DB 보호 연결</h2>
|
||||
<p>보호할 TABLE/VIEW를 고르면 동적 VPD가 1단계의 권한체계를 자동으로 적용합니다.</p>
|
||||
<strong>보호 객체 연결하기 →</strong>
|
||||
</div>
|
||||
</a>
|
||||
<a class="summary-tile" href="/tokens">
|
||||
<span class="label">토큰</span>
|
||||
<strong th:text="${#lists.size(tokens)}">0</strong>
|
||||
<a class="journey-card" href="/tokens">
|
||||
<span class="journey-number">3</span>
|
||||
<div>
|
||||
<h2>3. 토큰 발급</h2>
|
||||
<p>검증할 사용자를 선택해 토큰을 발급하고, 한 번만 보이는 원문을 복사합니다.</p>
|
||||
<strong>테스트 토큰 발급하기 →</strong>
|
||||
</div>
|
||||
</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 class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>최근 보호 객체</h2>
|
||||
<a class="btn btn-sm rw-btn-secondary" href="/probe">검증 실행</a>
|
||||
<div>
|
||||
<h2>현재 검증 가능한 데이터</h2>
|
||||
<p class="section-subtitle">권한 규칙과 ORDS 경로가 등록된 보호 객체입니다.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm rw-btn-primary" href="/probe">결과 확인</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Object</th>
|
||||
<th>ORDS Path</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>데이터 객체</th><th>검증 경로</th><th>상태</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="object : ${objects}">
|
||||
<td th:text="${object.owner()}">ADMIN</td>
|
||||
<td th:text="${object.objectName()}">CB_V_SEARCH_DOCUMENTS</td>
|
||||
<td><code th:text="${object.ordsPath()}">search/documents</code></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>
|
||||
<td><strong th:text="${object.displayName()}">ADMIN.OBJECT</strong></td>
|
||||
<td><code th:text="${object.ordsPath()}">path</code></td>
|
||||
<td><span class="badge text-bg-success">사용 가능</span></td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(objects)}"><td colspan="3" class="text-muted">아직 검증할 보호 객체가 없습니다. 1단계에서 객체 권한을 먼저 등록하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -16,27 +16,28 @@
|
||||
<a class="navbar-brand" href="/">VPD Backoffice</a>
|
||||
<div class="rw-menu">
|
||||
<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">
|
||||
<a class="nav-link" href="/users">사용자</a>
|
||||
<a class="nav-link" href="/groups">그룹</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="/tokens">토큰</a>
|
||||
<a class="nav-link" href="/permissions">데이터 권한 규칙</a>
|
||||
<a class="nav-link" href="/effective-matrix">사용자별 최종 권한</a>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<a class="nav-link" href="/objects">조회 Handler 생성</a>
|
||||
<a class="nav-link" href="/probe">ORDS 검증</a>
|
||||
<a class="nav-link" href="/vpd-policies">DB 보호 연결</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>
|
||||
</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-reasoning">Reasoning</a>
|
||||
<a class="nav-link" href="/mcp-sse">SSE 서비스</a>
|
||||
@@ -44,11 +45,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<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 Policy 관리</a>
|
||||
<a class="nav-link" href="/vpd-filter-policies">별도 Filter 관리</a>
|
||||
<a class="nav-link" href="/settings">설정</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,23 +60,29 @@
|
||||
</div>
|
||||
</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'">
|
||||
<span class="architecture-kicker">Backoffice Tables</span>
|
||||
<strong>권한 테이블</strong>
|
||||
<p>사용자, 그룹, 역할, 행 규칙, 컬럼 원문 허용을 저장합니다.</p>
|
||||
<span class="architecture-kicker">1 · WHO / WHAT</span>
|
||||
<strong>권한 설계</strong>
|
||||
<p>누가 어떤 데이터의 어느 행과 컬럼을 볼지 정합니다.</p>
|
||||
</div>
|
||||
<div class="architecture-arrow">→</div>
|
||||
<div class="architecture-step" th:classappend="${activeLayer == 'vpd'} ? ' active'">
|
||||
<span class="architecture-kicker">Oracle Database</span>
|
||||
<strong>VPD Policy</strong>
|
||||
<p>TABLE/VIEW에 policy function을 붙여 DB에서 행 접근을 제한합니다.</p>
|
||||
<span class="architecture-kicker">2 · ENFORCE</span>
|
||||
<strong>DB 보호 연결</strong>
|
||||
<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 class="architecture-arrow">→</div>
|
||||
<div class="architecture-step" th:classappend="${activeLayer == 'ords'} ? ' active'">
|
||||
<span class="architecture-kicker">Oracle REST Data Services</span>
|
||||
<strong>ORDS 서빙/검증</strong>
|
||||
<p>VPD가 적용된 TABLE/VIEW를 HTTP API로 호출해 결과를 확인합니다.</p>
|
||||
<span class="architecture-kicker">4 · EVIDENCE</span>
|
||||
<strong>결과 확인</strong>
|
||||
<p>ORDS를 호출해 그 사용자에게 실제로 보이는 데이터만 확인합니다.</p>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
|
||||
@@ -1,58 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
|
||||
<body>
|
||||
<div th:fragment="result">
|
||||
<div th:fragment="result" class="probe-result-flow">
|
||||
<div class="section-heading">
|
||||
<h2>검증 결과</h2>
|
||||
<span class="badge" th:classappend="${result.status().name() == 'SUCCESS'} ? ' text-bg-success' : ' text-bg-warning'"
|
||||
th:text="${result.status()}">SUCCESS</span>
|
||||
<div>
|
||||
<span class="architecture-kicker">검증 결론</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 class="alert alert-warning" th:if="${result.errorCode()}">
|
||||
<strong th:text="${result.errorCode()}">ERROR</strong>
|
||||
<span th:text="${result.errorMessage()}">message</span>
|
||||
</div>
|
||||
<p class="probe-summary" th:text="${result.plainSummary()}">결과 설명</p>
|
||||
|
||||
<div class="meta-row">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 class="table-responsive" th:if="${!#lists.isEmpty(result.rows())}">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th th:each="column : ${result.columns()}" th:text="${column}">column</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${result.rows()}">
|
||||
<td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="table-responsive mt-3" th:if="${!#lists.isEmpty(result.rows())}">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead><tr><th th:each="column : ${result.columns()}" th:text="${column}">column</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="row : ${result.rows()}">
|
||||
<td th:each="column : ${result.columns()}" th:text="${row.get(column)}">value</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,96 +1,67 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>ORDS 검증</h1>
|
||||
<p>ORDS가 서빙하는 TABLE/VIEW 호출 결과로 VPD/Redaction 적용 여부를 확인합니다.</p>
|
||||
<h1>권한 결과 확인</h1>
|
||||
<p>한 사용자의 토큰으로 실제 데이터를 요청해, 설계한 권한이 DB에서 그대로 적용되는지 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="section-heading">
|
||||
<h2>ORDS 호출</h2>
|
||||
<a class="btn btn-sm rw-btn-secondary" href="/mcp-reasoning">MCP Reasoning으로 해석</a>
|
||||
<div>
|
||||
<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">
|
||||
<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}">
|
||||
<label>
|
||||
등록 토큰
|
||||
<select class="form-select" name="tokenKeyId" data-token-context-select>
|
||||
<option value="">원문 직접 입력</option>
|
||||
<option th:each="token : ${tokens}"
|
||||
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 class="span-2">
|
||||
1. 발급받은 토큰 원문
|
||||
<input class="form-control" name="bearerToken" type="password" autocomplete="off"
|
||||
placeholder="토큰 발급 직후 복사한 값을 붙여 넣으세요" required>
|
||||
<span class="form-hint">원문은 DB에 저장되지 않습니다. 목록에 보이는 prefix만으로는 검증할 수 없으며, 원문을 잃었다면 새 토큰을 발급해야 합니다.</span>
|
||||
</label>
|
||||
<label>
|
||||
Bearer Token 원문
|
||||
<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 트랙
|
||||
2. 확인할 데이터
|
||||
<select class="form-select" name="objectId" required>
|
||||
<option th:each="object : ${objects}"
|
||||
th:value="${object.objectId()}"
|
||||
th:text="${object.displayName() + ' / ' + object.ordsPath()}"></option>
|
||||
th:text="${object.displayName() + (defaultObjectKeys.contains(object.displayName()) ? ' · 권한체계 자동 (권장)' : ' · 별도 Filter (고급 점검 필요)')}"></option>
|
||||
</select>
|
||||
<span class="form-hint">이 객체에 저장한 권한 규칙과 실제 반환 행을 비교합니다.</span>
|
||||
</label>
|
||||
<label>
|
||||
Limit
|
||||
최대 확인 행 수
|
||||
<input class="form-control" name="limit" type="number" min="1" max="500" value="50">
|
||||
<span class="form-hint">권한 판정에는 영향을 주지 않고 화면에 가져올 최대 행만 제한합니다.</span>
|
||||
</label>
|
||||
<button class="btn rw-btn-primary" type="submit">호출</button>
|
||||
<button class="btn rw-btn-primary probe-submit" type="submit">3. 권한 결과 확인</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="probe-result" class="content-band">
|
||||
<div class="text-muted">검증 결과가 여기에 표시됩니다.</div>
|
||||
<section id="probe-result" class="content-band" aria-live="polite">
|
||||
<div class="empty-result-guide">
|
||||
<strong>결과는 세 가지 순서로 설명합니다.</strong>
|
||||
<ol>
|
||||
<li>토큰이 어떤 사용자와 역할로 해석됐는지</li>
|
||||
<li>VPD 적용 후 실제로 몇 행이 보였는지</li>
|
||||
<li>예상과 다를 때 어디를 확인해야 하는지</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
@@ -1,22 +1,35 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>Bearer Token</h1>
|
||||
<p>토큰은 발급 직후 한 번만 원문을 표시합니다.</p>
|
||||
<h1>테스트 토큰 발급</h1>
|
||||
<p>검증할 사용자를 나타내는 토큰을 만들고, 한 번만 표시되는 원문을 다음 단계에서 사용합니다.</p>
|
||||
</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-warning" th:if="${issued}">
|
||||
<div class="fw-semibold">새 토큰이 발급되었습니다. 이 값은 다시 표시되지 않습니다.</div>
|
||||
<code class="token-value" th:text="${issued.plainToken()}"></code>
|
||||
</div>
|
||||
<section class="issued-token-card" th:if="${issued}" th:attr="data-issued-key-id=${issued.keyId()}">
|
||||
<span class="architecture-kicker">발급 완료 · 지금 한 번만 표시됩니다</span>
|
||||
<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>
|
||||
<p class="form-hint mb-0">화면을 떠난 뒤에는 원문을 다시 볼 수 없습니다. 잃어버리면 기존 토큰을 복구하지 말고 새로 발급하세요.</p>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
@@ -30,32 +43,31 @@
|
||||
<input class="form-control" name="expiresAt" type="datetime-local" th:value="${defaultExpiresAt}" required>
|
||||
</label>
|
||||
<label>
|
||||
설명
|
||||
<input class="form-control" name="description" maxlength="200">
|
||||
용도 메모
|
||||
<input class="form-control" name="description" maxlength="200" placeholder="예: HR 권한 확인">
|
||||
</label>
|
||||
<button class="btn btn-primary" type="submit">발급</button>
|
||||
<button class="btn rw-btn-primary" type="submit">2. 토큰 발급</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>User</th>
|
||||
<th>Prefix</th>
|
||||
<th>Expires</th>
|
||||
<th>Revoked</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead><tr><th>ID</th><th>사용자</th><th>용도</th><th>식별용 Prefix</th><th>만료</th><th>회수</th><th></th></tr></thead>
|
||||
<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.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.revokedAt()} ?: '-'">-</td>
|
||||
<td>
|
||||
@@ -67,9 +79,7 @@
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tokens)}">
|
||||
<td colspan="6" class="text-muted">등록된 토큰이 없습니다.</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(tokens)}"><td colspan="7" class="text-muted">아직 발급된 토큰이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>Filter Policy 관리</h1>
|
||||
<p>Filter function과 적용된 VPD policy를 관리합니다. TABLE/VIEW 개별/벌크 적용은 VPD 설정에서 처리합니다.</p>
|
||||
<span class="badge text-bg-warning">고급 설정</span>
|
||||
<h1 class="mt-2">별도 Filter 관리</h1>
|
||||
<p>일반 권한은 이 화면에서 만들지 않습니다. 권한체계로 표현할 수 없는 객체 전용 조건이 있을 때만 별도 함수를 추가합니다.</p>
|
||||
</div>
|
||||
|
||||
<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-danger" th:if="${errorMessage}" th:text="${errorMessage}">처리할 수 없습니다.</div>
|
||||
|
||||
<section class="content-band">
|
||||
<section class="content-band default-filter-explainer">
|
||||
<div class="section-heading">
|
||||
<h2>Filter 등록/수정</h2>
|
||||
<a class="btn btn-sm rw-btn-secondary" href="/vpd-policies">VPD 적용 화면</a>
|
||||
</div>
|
||||
<form method="post" action="/vpd-filter-policies/filters" class="form-grid">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
Function Owner
|
||||
<select class="form-select" name="functionOwner">
|
||||
<option value="">현재 연결 사용자</option>
|
||||
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Function 이름
|
||||
<input class="form-control" name="functionName" list="filter-function-names"
|
||||
placeholder="예: BOARD_POSTS_FILTER" required>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Filter predicate
|
||||
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
|
||||
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
|
||||
</label>
|
||||
<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=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="owner_emp_no = SYS_CONTEXT('CB_AGENT_CTX', 'EMP_NO')">본인 소유</button>
|
||||
<div>
|
||||
<span class="architecture-kicker">기본 동작: 권한체계 자동 반영</span>
|
||||
<h2>대부분의 변경은 권한 규칙 화면에서 끝납니다.</h2>
|
||||
</div>
|
||||
<button class="btn rw-btn-primary" type="submit">Filter 저장</button>
|
||||
</form>
|
||||
<datalist id="filter-function-names">
|
||||
<option th:each="function : ${formOptions.functions()}" th:value="${function.functionName()}"></option>
|
||||
</datalist>
|
||||
<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">
|
||||
<div class="section-heading">
|
||||
<h2>Filter 목록</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Function</th>
|
||||
<th>Type</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<th:block th:each="function, iter : ${formOptions.functions()}">
|
||||
<tr>
|
||||
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
|
||||
<td th:text="${function.objectType()}">FUNCTION</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-target="${'#filter-source-' + iter.index}"
|
||||
hx-swap="innerHTML">
|
||||
Source 보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="policy-source-cell">
|
||||
<div class="filter-edit-grid">
|
||||
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">
|
||||
Source 보기를 누르면 현재 function source가 표시됩니다.
|
||||
</div>
|
||||
<form method="post" action="/vpd-filter-policies/filters" class="filter-edit-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<input type="hidden" name="functionOwner" th:value="${function.owner()}">
|
||||
<input type="hidden" name="functionName" th:value="${function.functionName()}">
|
||||
<label>
|
||||
수정할 Filter predicate
|
||||
<textarea class="form-control" name="filterPredicate" rows="3"
|
||||
th:id="${'filter-edit-predicate-' + iter.index}"
|
||||
placeholder="예: dept_code = SYS_CONTEXT(''CB_AGENT_CTX'', ''DEPT_CODE'')" required></textarea>
|
||||
</label>
|
||||
<div class="question-presets" aria-label="Filter predicate 예시">
|
||||
<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>
|
||||
<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}">
|
||||
<label>
|
||||
Function owner
|
||||
<select class="form-select" name="functionOwner">
|
||||
<option value="">현재 연결 사용자</option>
|
||||
<option th:each="owner : ${formOptions.owners()}" th:value="${owner}" th:text="${owner}"></option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
새 Function 이름
|
||||
<input class="form-control" name="functionName" placeholder="예: BOARD_POSTS_SPECIAL_FILTER" required>
|
||||
<span class="form-hint">CB_AGENT_DOC_VPD_FILTER 이름은 사용할 수 없습니다.</span>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
반환할 predicate
|
||||
<textarea class="form-control" id="filter-only-predicate" name="filterPredicate" rows="4"
|
||||
placeholder="예: tenant_id = SYS_CONTEXT('CB_AGENT_CTX', 'TENANT_ID')" required></textarea>
|
||||
</label>
|
||||
<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="dept_code = SYS_CONTEXT('CB_AGENT_CTX', 'DEPT_CODE')">컨텍스트 비교 예시</button>
|
||||
</div>
|
||||
<button class="btn rw-btn-primary" type="submit">별도 Filter 저장</button>
|
||||
</form>
|
||||
|
||||
<div class="section-heading mt-4">
|
||||
<h2>설치된 Filter 함수</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(formOptions.functions())}">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Function</th><th>구분</th><th>Source / 변경</th></tr></thead>
|
||||
<tbody>
|
||||
<th:block th:each="function, iter : ${formOptions.functions()}">
|
||||
<tr>
|
||||
<td><code th:text="${function.value()}">ADMIN.FILTER</code></td>
|
||||
<td>
|
||||
<span class="badge text-bg-primary" th:if="${function.permissionSystemDefault()}">권한체계 기본</span>
|
||||
<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-target="${'#filter-source-' + iter.index}" hx-swap="innerHTML">Source 보기</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="3" class="policy-source-cell">
|
||||
<div th:id="${'filter-source-' + iter.index}" class="text-muted small">Source 보기를 누르면 현재 함수 내용을 표시합니다.</div>
|
||||
<div class="alert alert-light mt-3 mb-0" th:if="${function.permissionSystemDefault()}">
|
||||
이 함수는 권한체계의 핵심 실행 경로이므로 직접 수정할 수 없습니다. 권한 규칙을 변경하세요.
|
||||
</div>
|
||||
<button class="btn btn-sm rw-btn-primary" type="submit">Filter 수정</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
<tr th:if="${#lists.isEmpty(formOptions.functions())}">
|
||||
<td colspan="3" class="text-muted">등록된 VPD filter function 후보가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<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" name="functionOwner" th:value="${function.owner()}">
|
||||
<input type="hidden" name="functionName" th:value="${function.functionName()}">
|
||||
<label>
|
||||
이 별도 Filter의 새 predicate
|
||||
<textarea class="form-control" name="filterPredicate" rows="3" required></textarea>
|
||||
</label>
|
||||
<button class="btn btn-sm rw-btn-secondary" type="submit">별도 Filter 수정</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
</th:block>
|
||||
<tr th:if="${#lists.isEmpty(formOptions.functions())}"><td colspan="3" class="text-muted">설치된 VPD 함수가 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>적용된 Policy 목록/수정</h2>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(policies)}">0</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Object</th>
|
||||
<th>Policy</th>
|
||||
<th>Function</th>
|
||||
<th>Status</th>
|
||||
<th>수정</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr th:each="policy : ${policies}">
|
||||
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
|
||||
<td><code th:text="${policy.policyName()}">POLICY</code></td>
|
||||
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
|
||||
<td th:text="${policy.enabled()}">YES</td>
|
||||
<td>
|
||||
<form method="post" action="/vpd-filter-policies/replace" class="inline-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<input type="hidden" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
|
||||
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
|
||||
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
|
||||
<input class="form-control form-control-sm" name="policyName" th:value="${policy.policyName()}">
|
||||
<select class="form-select form-select-sm" name="functionKey">
|
||||
<option th:each="function : ${formOptions.functions()}"
|
||||
th:value="${function.value()}"
|
||||
th:text="${function.value()}"
|
||||
th:selected="${function.value() == policy.functionDisplayName()}"></option>
|
||||
</select>
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
|
||||
<button class="btn btn-sm rw-btn-secondary" type="submit">수정</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(policies)}">
|
||||
<td colspan="5" class="text-muted">등록된 VPD policy가 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<details class="advanced-details">
|
||||
<summary>고급: 적용된 Policy를 별도 Filter로 교체</summary>
|
||||
<div class="advanced-guidance mt-3">
|
||||
<strong>영향 범위</strong>
|
||||
<p>기존 policy를 삭제한 뒤 새 policy를 등록합니다. 기본 권한체계 경로에서 벗어나므로 대상 객체, 거부 조건, 복구할 기본 함수와 검증 토큰을 먼저 준비하세요.</p>
|
||||
</div>
|
||||
<div class="table-responsive mt-3">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Object</th><th>현재 Policy</th><th>현재 Function</th><th>구분</th><th>교체</th></tr></thead>
|
||||
<tbody>
|
||||
<tr th:each="policy : ${policies}">
|
||||
<td><code th:text="${policy.objectDisplayName()}">ADMIN.TABLE</code></td>
|
||||
<td><code th:text="${policy.policyName()}">POLICY</code></td>
|
||||
<td><code th:text="${policy.functionDisplayName()}">ADMIN.FILTER</code></td>
|
||||
<td>
|
||||
<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" name="oldObjectKey" th:value="${policy.objectDisplayName()}">
|
||||
<input type="hidden" name="oldPolicyName" th:value="${policy.policyName()}">
|
||||
<input type="hidden" name="objectKey" th:value="${policy.objectDisplayName()}">
|
||||
<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" aria-label="새 filter function">
|
||||
<option th:each="function : ${formOptions.functions()}"
|
||||
th:value="${function.value()}" th:text="${function.value()}"
|
||||
th:selected="${function.value() == policy.functionDisplayName()}"></option>
|
||||
</select>
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<input type="hidden" name="statementTypes" th:value="${policy.statementTypes()} ?: 'SELECT'">
|
||||
<button class="btn btn-sm btn-outline-warning" type="submit">영향 확인 후 교체</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<tr th:if="${#lists.isEmpty(policies)}"><td colspan="5" class="text-muted">적용된 VPD policy가 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<nav th:replace="~{fragments/layout :: nav}"></nav>
|
||||
<main class="container py-4">
|
||||
<div class="page-title">
|
||||
<h1>VPD 설정</h1>
|
||||
<p>Oracle Database의 TABLE/VIEW에 VPD policy와 policy function/filter predicate를 적용합니다.</p>
|
||||
<h1>DB 보호 연결</h1>
|
||||
<p>권한 화면에서 만든 사용자·그룹·역할·행·열 규칙을 Oracle VPD가 실제 TABLE/VIEW에 적용하도록 연결합니다.</p>
|
||||
</div>
|
||||
|
||||
<section th:replace="~{fragments/layout :: architectureStrip('vpd')}"></section>
|
||||
@@ -20,24 +20,29 @@
|
||||
|
||||
<section class="content-band">
|
||||
<div class="section-heading">
|
||||
<h2>VPD 적용</h2>
|
||||
<a class="btn btn-sm rw-btn-primary" href="/vpd-filter-policies">Filter Policy 관리</a>
|
||||
<div>
|
||||
<span class="architecture-kicker">기본 동작</span>
|
||||
<h2>권한체계 자동 적용</h2>
|
||||
</div>
|
||||
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 규칙 확인</a>
|
||||
</div>
|
||||
<div class="policy-apply-flow" aria-label="VPD 적용 계층">
|
||||
<span>Filter Function</span>
|
||||
<span>사용자·그룹·역할</span>
|
||||
<strong>→</strong>
|
||||
<span>Policy Template</span>
|
||||
<span>객체별 행·열 권한</span>
|
||||
<strong>→</strong>
|
||||
<span>TABLE/VIEW 적용</span>
|
||||
<span>동적 VPD</span>
|
||||
<strong>→</strong>
|
||||
<span>허용된 데이터만 반환</span>
|
||||
</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 class="content-band">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>VPD 적용 대상 TABLE/VIEW</h2>
|
||||
<p class="section-subtitle">Oracle DB catalog 기준의 TABLE/VIEW 목록입니다. ORDS handler는 여러 객체를 조합할 수 있으므로 이 표의 ORDS 경로는 사용 여부가 아니라 백오피스에 등록된 검증용 경로입니다.</p>
|
||||
<h2>보호할 수 있는 DB 객체</h2>
|
||||
<p class="section-subtitle">VPD가 붙었는지, 권한 규칙과 검증 경로가 준비됐는지 한곳에서 확인합니다. 아래 기본 적용에서는 객체만 선택하면 됩니다.</p>
|
||||
</div>
|
||||
<span class="badge text-bg-secondary" th:text="${#lists.size(vpdTargets)}">0</span>
|
||||
</div>
|
||||
@@ -186,73 +191,40 @@
|
||||
|
||||
<section class="content-band">
|
||||
<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">
|
||||
<form method="post" action="/vpd-policies/default" class="form-grid default-vpd-form">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<label>
|
||||
VPD 적용 대상 TABLE/VIEW
|
||||
보호할 TABLE/VIEW
|
||||
<select class="form-select" name="objectKey" required>
|
||||
<option th:each="target : ${vpdTargets}"
|
||||
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>
|
||||
</label>
|
||||
<label class="span-2">
|
||||
적용할 Policy Template
|
||||
<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 class="default-policy-summary">
|
||||
<strong>자동 적용 내용</strong>
|
||||
<span>권한체계 동적 조회 · SELECT 보호 · 즉시 활성화 · 권한 없음은 차단</span>
|
||||
</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="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>
|
||||
<button class="btn rw-btn-primary" type="submit">권한체계 자동 적용</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<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">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
<input type="hidden" name="enabled" value="true">
|
||||
<label>
|
||||
Schema
|
||||
<select class="form-select" name="schemaOwner" required>
|
||||
@@ -272,60 +244,18 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label class="span-2">
|
||||
적용할 Policy Template
|
||||
<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>
|
||||
<p class="text-muted small span-2 mb-0">모든 대상에 표준 <code>CB_PERMISSION_SELECT_POLICY</code>와 <code>CB_AGENT_DOC_VPD_FILTER</code>를 적용합니다. 별도 predicate가 필요하면 운영·고급 메뉴의 가이드를 먼저 확인하세요.</p>
|
||||
<button class="btn rw-btn-primary" type="submit">선택 스키마에 권한체계 일괄 연결</button>
|
||||
</form>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="content-band">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -368,6 +298,8 @@
|
||||
<code th:text="${policy.functionDisplayName()}">OWNER.FUNC</code>
|
||||
</button>
|
||||
<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 th:text="${policy.statementTypes()} ?: '-'">SELECT</td>
|
||||
<td>
|
||||
@@ -416,16 +348,5 @@
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</html>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,14 @@ class ProbeErrorClassifierTest {
|
||||
.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
|
||||
void detectsOrdsConnectionRefused() {
|
||||
assertThat(classifier.isUnavailable(new ResourceAccessException(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user