Compare commits
2 Commits
28379c47f8
...
0ed048905a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ed048905a | ||
|
|
e7070663c3 |
77
docs/design/577-persona-ux-audit/README.md
Normal file
77
docs/design/577-persona-ux-audit/README.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# VPD Backoffice 전 페이지 페르소나 UX 리뷰와 개선 로드맵
|
||||||
|
|
||||||
|
> **상태**: Review complete · UX-1 in progress
|
||||||
|
> **작성**: [AI] UX Facilitator · **최종수정**: 2026-06-30
|
||||||
|
> **추적성** — Redmine: UX audit 상위 이슈 등록 예정 · 선행 개선: #575 대시보드, #576 기본 검증 사용자명
|
||||||
|
|
||||||
|
## 1. 리뷰 방법
|
||||||
|
|
||||||
|
세 페르소나가 같은 화면을 다른 기준으로 검토했다.
|
||||||
|
|
||||||
|
| 페르소나 | 실패로 보는 상황 | 판단 기준 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX 전문가 | 정보가 같은 밀도로 쏟아져 다음 행동을 고르지 못함 | 시선 순서, 한 화면의 결정 수, 진행 상태, 빈 상태 |
|
||||||
|
| 권한 설계자 | 편한 조작이 권한 영향·fail-closed 원칙을 숨김 | 대상/주체/효과/영향 범위, 복구 가능성, 증적 |
|
||||||
|
| 엔드유저 | “그래서 지금 뭘 해야 하지?”를 5초 안에 답하지 못함 | 업무 언어, 기존 값 확인, 성공 기준, 되돌리기 |
|
||||||
|
|
||||||
|
공통 결론은 **설명은 충분하지만, 실행 화면이 ‘관리 매뉴얼’처럼 길다**는 점이다. 모든 페이지를 카드로 더 꾸미는 대신 다음 원칙으로 정리한다.
|
||||||
|
|
||||||
|
1. 첫 화면에는 주 행동 하나와 현재 상태만 둔다.
|
||||||
|
2. 대상·주체·영향을 입력 전에 보여 주고, 기술 설명은 접는다.
|
||||||
|
3. 목록은 탐색용, 상세/편집은 선택한 항목 중심으로 분리한다.
|
||||||
|
4. VPD·토큰·ORDS는 같은 검증 여정으로 연결하고 성공 증적을 다음 화면으로 넘긴다.
|
||||||
|
|
||||||
|
## 2. 페이지별 토론과 개선 백로그
|
||||||
|
|
||||||
|
| 페이지 | UI/UX 전문가 | 권한 설계자 | 엔드유저 | 합의한 개선 | 우선순위 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 대시보드 | 설명·현황·표가 동등하게 경쟁 | 보호 상태의 근거가 약함 | 다음 행동이 불명확 | 핵심 작업·현재 상태·4단계 레일만 전면, 상세 접기 — **#575 완료** | 완료 |
|
||||||
|
| 사용자 | 생성/목록/역할 부여가 세 구역으로 흩어짐 | 역할 해제 영향이 안 보임 | “이 사람에게 지금 어떤 권한이 있나?”를 다시 찾아야 함 | 행 선택 시 현재 역할·그룹·토큰 요약을 보여 주는 사용자 작업 패널, 역할 부여 전 영향 미리보기 | P0 |
|
||||||
|
| 그룹 | 사용자와 역할 배정이 긴 한 페이지에 누적 | 그룹 상속 범위가 숨음 | 그룹을 바꾸면 누가 영향받는지 모름 | 그룹 선택형 master-detail, 영향 사용자 수·상속 역할을 상단에 고정, 해제 전 영향 확인 | P0 |
|
||||||
|
| 역할 | 민감도·설명·삭제가 표 안에서 동등 | 역할 삭제/변경 영향이 중요 | 역할이 누구에게 쓰이는지 모름 | 역할 상세에서 연결 사용자·그룹·권한을 먼저 보여 주고, 삭제는 영향 없음일 때만 노출 | P0 |
|
||||||
|
| 권한 관리 | wizard가 길고 선택의 결과가 늦게 보임 | ALLOW/DENY, 행/열 규칙의 조합 위험 | ‘ALL’을 누르면 무엇이 보이는지 불안 | 역할→객체→행 범위→컬럼→검토의 5단계 progress, 즉시 영향 문장과 변경 요약 고정 | P0 |
|
||||||
|
| 유효 권한 매트릭스 | 사용자/그룹/역할 표 세 개가 한꺼번에 큼 | 최종 권한 근거를 추적해야 함 | 내 사용자 하나만 보고 싶음 | 기본은 사용자 탭+검색, 선택 시 권한 근거 타임라인; 그룹/역할은 보조 탭으로 분리 | P0 |
|
||||||
|
| ORDS 조회 대상 | 등록·경로·핸들러 설정이 한 화면에 섞임 | 보호 대상과 handler 연결 상태를 분리해야 함 | 무엇을 등록해야 하는지 모름 | 객체 등록 후 ‘보호 연결하기’ 단계 CTA, 목록에는 VPD/handler/검증 상태 배지 | P1 |
|
||||||
|
| DB 보호 연결 | 객체 목록·개별 적용·일괄 적용·정책 목록이 매우 김 | 적용/해제의 DB 영향이 중요 | 어떤 객체부터 연결할지 모름 | 미연결 객체를 기본 큐로, 선택 시 영향/복구 설명과 검증 CTA; 일괄 적용은 고급으로 접기 | P0 |
|
||||||
|
| 검증 세션 | 발급과 이력이 분리되어 다음 행동이 약함 | 토큰 원문 보안과 만료가 핵심 | 발급 뒤 무엇을 복사/어디로 가나 | 사용자 선택→만료→발급→즉시 결과 확인의 단일 handoff, 이력은 보조 탭 | P0 |
|
||||||
|
| 권한 결과 확인 | 입력과 증적이 좋지만 초보자가 object/token 선택에서 멈춤 | VPD predicate와 실제 결과는 반드시 함께 보여야 함 | 내 상황으로 빠르게 시험하고 싶음 | ‘검증 사용자’ 기본 모드와 ‘Bearer 직접 입력’ 고급 모드 분리, 결과에 다음 조치 추천 | P0 |
|
||||||
|
| ORDS 핸들러 | 소스·편집이 기술자 화면으로 바로 열림 | handler 변경은 VPD 우회 위험 | 보통 사용자는 건드릴 이유가 없음 | 상태/경로/보호 객체만 기본 표시, 소스·수정은 고급 상세와 영향 경고로 격리 | P1 |
|
||||||
|
| 지식 검색 관리 | 등록·정책·검색 세 큰 작업이 세로로 길게 누적 | 벡터 검색과 접근 조건을 혼동하기 쉬움 | 검색만 해 보고 싶음 | ‘자료 등록 / 접근 정책 / 검색 검증’ 탭화, 검색을 기본 탭으로 하고 정책은 요약 배지 | P1 |
|
||||||
|
| MCP Chatbot | 대화, 토큰, 기술 설명의 경계가 약함 | 도구 호출과 권한 결과의 근거 필요 | 질문만 하고 싶음 | 질문 패널 우선, 호출된 도구·권한 근거는 답변 아래 접힌 evidence로 표시 | P1 |
|
||||||
|
| MCP Reasoning | 도구 목록과 실행 입력이 같은 밀도 | 자동 선택 근거/권한 거부를 보여야 함 | 왜 이 도구가 선택됐는지 알고 싶음 | 질문→선택 도구→권한 결과→답변의 단계형 결과 타임라인 | P1 |
|
||||||
|
| MCP Client | Java 호출, 흐름, 설정이 한 페이지 | 실행 대상 endpoint 검증 필요 | API 호출 방법만 알고 싶음 | 복사 가능한 최소 호출 예시를 전면, 런타임 진단·프로토콜 설명은 접기 | P2 |
|
||||||
|
| MCP SSE | endpoint/tool/JSON-RPC 예시가 문서형 | 배포/인증 상태가 우선 | 연결 가능한지부터 알고 싶음 | 연결 상태·endpoint 복사·도구 수를 상단 요약, 프로토콜 예시는 탭/상세로 이동 | P2 |
|
||||||
|
| 운영 상태 | 표 중심의 상태 나열 | 장애 시 영향 범위/복구가 필요 | 지금 정상인가만 알고 싶음 | 건강/주의/장애를 요약 배너로, 항목별 영향·권장 조치·최근 확인 시각 제공 | P1 |
|
||||||
|
| 설정 | ORDS, schema preview, 초기화가 한 화면에 있고 위험 | 초기화는 오조작 방지가 필요 | URL만 바꾸고 싶음 | 연결 설정과 DB 초기화를 분리; 초기화는 별도 위험 화면·명시 확인·백업 안내 | P0 |
|
||||||
|
| 별도 Filter 관리 | 고급 내용을 충분히 경고하지만 길다 | 기본 권한체계 우회 여부가 중요 | 이 화면을 써야 하는지 모르겠음 | 시작 시 ‘기본 권한으로 가능한가?’ 결정 카드, 실제 filter 편집은 조건 충족 시에만 열기 | P1 |
|
||||||
|
| 로그인 | 업무 시작점/권한 범위 설명 없음 | 운영 계정임을 명확히 해야 함 | 어디에 로그인하는지 모름 | 제품 목적·환경 표시·지원 경로를 최소한으로 추가 | P2 |
|
||||||
|
|
||||||
|
## 3. 우선순위와 실행 묶음
|
||||||
|
|
||||||
|
| 실행 묶음 | 페이지 | 목표 | 선행/완료 조건 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| UX-1 권한 주체와 최종 권한 | 사용자, 그룹, 역할, 유효 권한 매트릭스 | ‘누가 어떤 역할을 통해 무엇을 보는가’를 한 흐름으로 이해 | 영향 미리보기, 선택형 상세, 사용자 중심 매트릭스 |
|
||||||
|
| UX-2 보호·검증 여정 | 권한, DB 보호 연결, 토큰, probe, 설정 | 권한 생성부터 실제 DB 증적까지 끊기지 않게 연결 | 단계별 handoff, 위험/복구 안내, 고급 분리 |
|
||||||
|
| UX-3 연동 도구 정리 | objects, ORDS, vector, MCP 4종, 운영 상태, filter | 운영 화면과 개발자 화면의 밀도를 분리 | 요약 상태 우선, 기술 상세 접기/탭화 |
|
||||||
|
| UX-4 일관성 정리 | 로그인, 빈 상태, 문구, 배지, 모바일 | 모든 화면의 용어·행동·상태 표현을 정렬 | 공통 컴포넌트/카피 가이드 |
|
||||||
|
|
||||||
|
## 4. 첫 구현 범위: UX-1
|
||||||
|
|
||||||
|
UX-1부터 시작한다. 기존 데이터 모델과 VPD 로직은 바꾸지 않고 화면의 정보 구조만 바꾼다.
|
||||||
|
|
||||||
|
1. 사용자/그룹/역할 화면에 선택된 주체의 현재 연결과 영향 수를 먼저 보여 준다.
|
||||||
|
2. 유효 권한 매트릭스는 기본을 ‘사용자’로 두고, 선택한 사용자 한 명의 직접 역할·그룹 상속·최종 역할·보호 객체 근거를 순서대로 표시한다.
|
||||||
|
3. 그룹/역할 기준 전체 표는 보조 탐색으로 유지하되 기본 접힘 또는 탭 뒤로 보낸다.
|
||||||
|
4. 이 변경 뒤 실제 사용자 `김어드민`, `박파이넨스`, `이에이치알`로 권한 근거와 probe 결과를 교차 검증한다.
|
||||||
|
|
||||||
|
## 5. 완료 정의
|
||||||
|
|
||||||
|
- 각 실행 묶음은 Redmine 하위 이슈, 설계서, 테스트, 실제 배포 HTTP 확인을 남긴다.
|
||||||
|
- 화면 변경은 권한 계산·VPD predicate·토큰 보안 동작을 바꾸지 않는다.
|
||||||
|
- UI/UX 전문가, 설계자, 엔드유저 관점의 미해결 반론이 있으면 이 문서와 Redmine에 기록하고 다음 묶음에서 해소한다.
|
||||||
|
|
||||||
|
## 6. UX-1 진행 기록
|
||||||
|
|
||||||
|
- 유효 권한 매트릭스를 사용자 중심 탭형 구조로 변경했다. 기본 화면은 선택한 사용자 한 명의 직접 역할, 그룹 상속, 최종 역할, 보호 객체, 권한 수를 보여 준다.
|
||||||
|
- 그룹/역할 전체 표는 제거하지 않고 보조 탭으로 이동했다.
|
||||||
|
- 복합 Thymeleaf 속성과 layout fragment를 실제로 처리하는 `EffectiveMatrixTemplateRenderTest`를 추가해 UI template parse 오류를 빌드에서 검출한다.
|
||||||
123
docs/design/dds-persona-ux-review/README.md
Normal file
123
docs/design/dds-persona-ux-review/README.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# DDS 트랙 페르소나 UX 리뷰 및 개선 백로그
|
||||||
|
|
||||||
|
> **상태**: 리뷰 완료 · Redmine 등록 진행
|
||||||
|
>
|
||||||
|
> **범위**: DDS 독립 인스턴스의 홈, 보호 연결, 권한 반영, 지식 검색, 보호 객체 상세, 공통 메뉴/흐름 바
|
||||||
|
>
|
||||||
|
> **대상 파일**: `dds-home.html`, `vpd-policies.html`, `dds-provision.html`, `vector-knowledge.html`, `dds.html`, `fragments/layout.html`
|
||||||
|
|
||||||
|
## 1. 검토 방식
|
||||||
|
|
||||||
|
같은 화면을 세 관점에서 검토했다.
|
||||||
|
|
||||||
|
| 페르소나 | 성공 기준 | 가장 민감한 실패 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX 전문가 | 한 화면에서 한 가지 주 행동이 분명하고, 설명은 필요할 때만 열린다. | 모든 정보가 같은 무게로 노출되어 사용자가 다음 행동을 고르지 못함 |
|
||||||
|
| 보안 설계자 | DDS 집행 경계와 권한 변경의 영향이 틀림없이 드러난다. | 토큰 Context 경로와 순수 DDS END USER 경로를 같은 의미처럼 오해함 |
|
||||||
|
| 운영 사용자 | 지금 상태, 다음 조치, 실패 원인을 짧은 시간에 파악한다. | 설명을 읽어도 현재 객체가 보호되는지·게시가 필요한지 모름 |
|
||||||
|
|
||||||
|
## 2. 세 페르소나의 합의
|
||||||
|
|
||||||
|
1. 첫 화면은 설명이 아니라 **상태와 다음 행동**을 보여 준다.
|
||||||
|
2. 제품 기본 경로인 **서비스 토큰 기반 검색**과 보조 기술 검증인 **DDS END USER 직접 비교**를 같은 무게로 놓지 않는다.
|
||||||
|
3. `DATA GRANT` 게시·회수는 보안 변경이다. 단순 브라우저 confirm이 아니라 **diff, 영향, 사유, 결과 검증**이 필요하다.
|
||||||
|
4. `Context`, `predicate`, `DATA GRANT` 같은 기술 용어는 제거하지 않되, 기본 표면에서는 업무 결과로 번역하고 상세에서 근거를 보인다.
|
||||||
|
5. 홈·메뉴·객체 상세의 숫자는 단순 건수가 아니라 `정상/미게시/드리프트/검증 실패` 같은 운영 상태로 연결돼야 한다.
|
||||||
|
|
||||||
|
## 3. 핵심 설계 경계
|
||||||
|
|
||||||
|
현재 DDS 트랙에는 두 종류의 식별 경로가 공존한다. 이 둘을 명확히 구분하지 않으면 제품 사용자가 “Bearer 토큰만으로 DDS END USER가 된다”고 오해할 수 있다.
|
||||||
|
|
||||||
|
| 구분 | 기본 서비스 경로 | 보조 직접 비교 경로 |
|
||||||
|
|---|---|---|
|
||||||
|
| 사용자 식별 | 기술 사용자 세션 + Bearer로 만든 애플리케이션 Context | DDS `END USER` 직접 연결 |
|
||||||
|
| 집행 | 보호 객체별 `DATA GRANT` predicate가 공통 권한 테이블을 평가 | `END USER → DATA ROLE → DATA GRANT` |
|
||||||
|
| 목적 | 제품 기능: 권한 기반 지식 검색 | DDS 선언형 권한 동작 검증 |
|
||||||
|
| 화면 위치 | 기본 경로 | 고급 검증/상세 화면 |
|
||||||
|
|
||||||
|
화면 표준 용어는 아래로 고정한다.
|
||||||
|
|
||||||
|
- **토큰 기반 서비스 경로**: 제품에서 사용하는 기본 검색 경로
|
||||||
|
- **DDS END USER 직접 비교**: DDS Data Role/Data Grant 동작을 확인하는 고급 검증
|
||||||
|
- **보호 객체**: DDS Data Grant가 연결된 VIEW/TABLE
|
||||||
|
- **권한 게시**: 공통 권한의 변경을 DDS 선언으로 반영하는 보안 변경
|
||||||
|
|
||||||
|
## 4. 페이지별 리뷰
|
||||||
|
|
||||||
|
### A. 홈 (`/` · `dds-home.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | 단계 카드, 매크로/마이크로, 숫자, 검증 대상이 같은 위계로 보여 첫 행동이 흐리다. | 상단을 상태 요약과 추천 행동으로 재구성하고, 단계 카드는 상태가 있는 작업 카드로 전환한다. |
|
||||||
|
| 설계 | 역할·권한 규칙 건수는 보호 상태를 보장하지 않는다. | 보호 객체의 Grant 상태, 마지막 게시, 마지막 검색 검증, 드리프트 여부를 핵심 지표로 올린다. |
|
||||||
|
| 운영 | “오늘 무엇을 해야 하나?”에 답이 없다. | `게시 필요`, `검색 검증 필요`, `정상` 중 하나를 명확한 CTA로 제공한다. |
|
||||||
|
|
||||||
|
**완료 조건**: 설명을 열지 않아도 보호 상태와 다음 행동을 파악하고, 두 번 이하의 클릭으로 수정·검증을 시작한다.
|
||||||
|
|
||||||
|
### B. 지식 검색 (`/vector-knowledge` · `vector-knowledge.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | 자료 등록, 권한 설정, 토큰 검색, END USER 직접 비교가 한 화면에 이어져 처음 보는 사용자는 두 검색의 목적을 구분하기 어렵다. | 기본 표면은 `자료 등록`과 `토큰 기반 서비스 검색`에 집중하고, 직접 비교는 고급 검증으로 접거나 별도 화면으로 이동한다. |
|
||||||
|
| 설계 | 토큰 경로와 END USER 경로의 집행·신뢰 경계가 다르다. | 두 경로에 목적·입력·결과 해석을 다른 배지와 문구로 표시한다. |
|
||||||
|
| 운영 | 결과가 0건일 때 권한 차단인지 검색 관련도 문제인지 판단하기 어렵다. | 결과 상단에 사용자, 허용 TAG, 제외 수, 반환 수, 토큰 상태를 요약한다. |
|
||||||
|
|
||||||
|
**완료 조건**: 일반 사용자는 토큰 검색 하나만으로 업무를 완료하고, 직접 비교는 의도적으로 고급 검증을 선택했을 때만 사용한다.
|
||||||
|
|
||||||
|
### C. DDS 보호 연결 (`/vpd-policies` · `vpd-policies.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | DDS 화면인데 route와 일부 용어가 VPD를 드러내며, 설명은 많지만 보호 상태가 보이지 않는다. | 표면 용어를 `DDS 보호 객체`로 통일하고, 객체 카드를 중심으로 정보 구조를 바꾼다. |
|
||||||
|
| 설계 | 공통 권한 관리와 DDS 집행이 섞여 있고, 토큰 Context/END USER의 구분이 약하다. | 객체마다 집행 모드, Grant 상태, predicate 버전, 마지막 검증을 보여 준다. |
|
||||||
|
| 운영 | 어떤 객체가 미게시·드리프트·검증 실패인지 알 수 없다. | `정상/미게시/드리프트/검증 실패` 상태와 바로 실행 가능한 조치를 제공한다. |
|
||||||
|
|
||||||
|
**완료 조건**: 한 화면에서 보호 객체의 상태, 집행 모드, 마지막 검증, 필요한 조치를 판별한다.
|
||||||
|
|
||||||
|
### D. DDS 권한 반영 (`/dds-provision` · `dds-provision.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | 큰 Grant 표와 SQL은 보이지만 변경 전후 차이와 영향이 먼저 보이지 않는다. | 게시 전 요약을 `추가/변경/회수/경고`로 분리하고, 상세 SQL은 접는다. |
|
||||||
|
| 설계 | Data Grant 교체·회수는 권한 확대/축소를 만들 수 있는 보안 변경이다. | 영향 사용자·객체·원문 컬럼, 게시 사유, 승인 확인, 검증 결과를 남긴다. |
|
||||||
|
| 운영 | 브라우저 confirm만으로 게시하면 사후 근거와 실패 복구 경로가 부족하다. | `미리보기 → 변경 사유 → 영향 확인 → 게시 → DB 결과/검증` 단계로 바꾼다. |
|
||||||
|
|
||||||
|
**완료 조건**: 게시 전에 영향과 변경 방향을 알 수 있고, 게시 후 결과·검증·재적용 기준이 감사 가능한 형태로 남는다.
|
||||||
|
|
||||||
|
### E. 보호 객체 상세 (`/dds` · `dds.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | 정적 세일즈 사례와 구현 단계가 길고 현재 접근 결과를 탐색하는 기능이 약하다. | 기본 화면은 `주체 → 규칙 → 보호 객체 → 결과` 근거 체인으로 재구성한다. |
|
||||||
|
| 설계 | 객체, 행 판단 컬럼, Grant, 공통 권한 규칙의 연결 근거가 한 번에 추적되지 않는다. | 선택한 사용자/토큰 기준으로 적용된 Data Grant와 허용·차단 사유를 표시한다. |
|
||||||
|
| 운영 | 차단됐을 때 토큰 문제인지 권한 문제인지 Grant 문제인지 모른다. | `토큰 무효`, `권한 없음`, `객체 Grant 없음`, `TAG 불일치`별 다음 조치를 제공한다. |
|
||||||
|
|
||||||
|
**완료 조건**: 하나의 주체가 왜 허용·차단됐는지를 한 화면에서 추적하고, 정적 사례와 실제 상태를 혼동하지 않는다.
|
||||||
|
|
||||||
|
### F. 공통 메뉴·구조 바 (`fragments/layout.html`)
|
||||||
|
|
||||||
|
| 관점 | 관찰 | 합의된 개선 |
|
||||||
|
|---|---|---|
|
||||||
|
| UI/UX | 모든 페이지에 긴 구조 설명과 메뉴가 반복되어 콘텐츠보다 프레임이 무겁다. | 구조 바는 현재 단계·상태만 남기고 설명은 접는다. 메뉴는 기본 작업과 고급 검증을 분리한다. |
|
||||||
|
| 설계 | 기술 경계가 반복 문구마다 조금씩 달라질 위험이 있다. | 표준 용어와 상태 배지의 단일 사전을 적용한다. |
|
||||||
|
| 운영 | 현재 위치와 다음 작업이 메뉴에서 분명하지 않다. | 활성 메뉴, 경고 배지, 최근 실패 링크를 제공한다. |
|
||||||
|
|
||||||
|
**완료 조건**: 화면 공통 프레임이 주 작업을 방해하지 않고, 같은 개념이 일관된 용어로 보인다.
|
||||||
|
|
||||||
|
## 5. 우선순위와 진행 순서
|
||||||
|
|
||||||
|
| 우선순위 | 개선 묶음 | 이유 |
|
||||||
|
|---|---|---|
|
||||||
|
| P0 | 서비스 토큰 경로와 DDS END USER 직접 비교 경로 분리, 게시 안전 흐름 | 보안 의미를 잘못 전달하거나 권한 변경을 오조작할 위험 |
|
||||||
|
| P1 | 홈 상태·다음 행동, 보호 객체 상태/드리프트 표시 | 운영자가 무엇을 해야 할지 판단하지 못하는 문제 |
|
||||||
|
| P2 | 보호 객체 근거 체인, 공통 용어/점진적 공개/접근성 | 제품 학습 비용과 반복적인 설명 과다를 줄임 |
|
||||||
|
| P3 | 모바일 밀도, 결과 시각화, 추가 자동화 | 기본 경로가 안정된 뒤 개선 |
|
||||||
|
|
||||||
|
## 6. 검증 시나리오
|
||||||
|
|
||||||
|
1. 세일즈 토큰으로 SALES 태그 자료만 검색된다.
|
||||||
|
2. 다른 부서 태그는 관련도가 높아도 제외된다.
|
||||||
|
3. 회수·만료 토큰은 사용자 정보와 자료를 표시하지 않는다.
|
||||||
|
4. DDS END USER 직접 비교에서 Grant 없음은 객체 미노출/차단으로 해석된다.
|
||||||
|
5. 권한 게시 화면은 변경·회수·경고·검증 결과를 구분해 보여 준다.
|
||||||
|
6. 키보드와 모바일에서도 기본 검색과 고급 검증을 혼동하지 않고 완료할 수 있다.
|
||||||
@@ -177,6 +177,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
|
.effective-user-focus dl {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.rw-menu-panel {
|
.rw-menu-panel {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
@@ -665,6 +669,114 @@ body {
|
|||||||
margin-top: .25rem;
|
margin-top: .25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.effective-workbench {
|
||||||
|
background: var(--rw-surface);
|
||||||
|
border: 1px solid var(--rw-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--rw-shadow);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-tabs {
|
||||||
|
background: var(--rw-surface-muted);
|
||||||
|
border-bottom: 1px solid var(--rw-border);
|
||||||
|
display: flex;
|
||||||
|
gap: .25rem;
|
||||||
|
padding: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--rw-muted);
|
||||||
|
font-size: .88rem;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: .55rem .7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-tab:hover,
|
||||||
|
.effective-tab:focus,
|
||||||
|
.effective-tab.is-active {
|
||||||
|
background: var(--rw-surface);
|
||||||
|
color: var(--rw-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-pane {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus {
|
||||||
|
background: var(--rw-primary-soft);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--rw-primary) 35%, var(--rw-border));
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus-heading strong,
|
||||||
|
.effective-user-focus-heading small {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus-heading strong {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-top: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus-heading small {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
margin-top: .15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus dl {
|
||||||
|
display: grid;
|
||||||
|
gap: .7rem;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus dl > div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus dt {
|
||||||
|
color: var(--rw-muted);
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus dd {
|
||||||
|
font-size: .86rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: .25rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus-footer {
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--rw-border);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: .84rem;
|
||||||
|
gap: .75rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus-footer a {
|
||||||
|
color: var(--rw-primary);
|
||||||
|
font-weight: 800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-table-detail {
|
||||||
|
border-top: 1px solid var(--rw-border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.setup-steps {
|
.setup-steps {
|
||||||
color: var(--rw-muted);
|
color: var(--rw-muted);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -1475,6 +1587,18 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.effective-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-tab {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effective-user-focus dl {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.schema-summary {
|
.schema-summary {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -630,6 +630,54 @@ function filterEffectiveMatrixTable(select) {
|
|||||||
row.innerHTML = `<td colspan="${columnCount}" class="text-muted">${table.dataset.emptyMessage || '선택한 항목이 없습니다.'}</td>`;
|
row.innerHTML = `<td colspan="${columnCount}" class="text-muted">${table.dataset.emptyMessage || '선택한 항목이 없습니다.'}</td>`;
|
||||||
body?.appendChild(row);
|
body?.appendChild(row);
|
||||||
}
|
}
|
||||||
|
if (select.matches('[data-effective-user-select]')) {
|
||||||
|
updateEffectiveUserFocus(table, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEffectiveUserFocus(table, userId) {
|
||||||
|
const focus = document.querySelector('[data-effective-user-focus]');
|
||||||
|
if (!focus || !table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = Array.from(table.querySelectorAll('tbody tr[data-effective-id]'))
|
||||||
|
.find((item) => item.dataset.effectiveId === (userId || ''));
|
||||||
|
const values = {
|
||||||
|
username: row?.dataset.username || '사용자 선택',
|
||||||
|
profile: row?.dataset.profile || '사번 / 부서',
|
||||||
|
directRoles: row?.dataset.directRoles || '-',
|
||||||
|
inheritance: row?.dataset.inheritance || '-',
|
||||||
|
effectiveRoles: row?.dataset.effectiveRoles || '-',
|
||||||
|
objects: row?.dataset.objects || '-',
|
||||||
|
permissionCount: row?.dataset.permissionCount || '0'
|
||||||
|
};
|
||||||
|
Object.entries(values).forEach(([field, value]) => {
|
||||||
|
const target = focus.querySelector(`[data-effective-user-field="${field}"]`);
|
||||||
|
if (target) {
|
||||||
|
target.textContent = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEffectiveMatrixTabs() {
|
||||||
|
const tabs = document.querySelectorAll('[data-effective-tab]');
|
||||||
|
const panes = document.querySelectorAll('[data-effective-pane]');
|
||||||
|
if (!tabs.length || !panes.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tabs.forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const target = tab.dataset.effectiveTab;
|
||||||
|
tabs.forEach((item) => {
|
||||||
|
const active = item === tab;
|
||||||
|
item.classList.toggle('is-active', active);
|
||||||
|
item.setAttribute('aria-selected', String(active));
|
||||||
|
});
|
||||||
|
panes.forEach((pane) => {
|
||||||
|
pane.hidden = pane.dataset.effectivePane !== target;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initEffectiveMatrixFilters() {
|
function initEffectiveMatrixFilters() {
|
||||||
@@ -955,6 +1003,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
initTokenContextPreviews();
|
initTokenContextPreviews();
|
||||||
initEffectiveMatrixFilters();
|
initEffectiveMatrixFilters();
|
||||||
|
initEffectiveMatrixTabs();
|
||||||
const objectSelect = document.querySelector('select[name="objectRef"]');
|
const objectSelect = document.querySelector('select[name="objectRef"]');
|
||||||
if (objectSelect) {
|
if (objectSelect) {
|
||||||
objectSelect.addEventListener('change', () => {
|
objectSelect.addEventListener('change', () => {
|
||||||
|
|||||||
@@ -5,203 +5,122 @@
|
|||||||
<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>유효 권한 매트릭스</h1>
|
<h1>유효 권한 확인</h1>
|
||||||
<p class="context-summary">사용자별로 최종 적용되는 역할·권한·보호 객체를 확인합니다.</p>
|
<p class="context-summary">한 사용자가 어떤 역할 경로를 거쳐 어떤 보호 객체를 볼 수 있는지 확인합니다.</p>
|
||||||
<details class="explanation-details">
|
<details class="explanation-details">
|
||||||
<summary>이 표를 읽는 방법</summary>
|
<summary>이 화면의 권한 근거 보기</summary>
|
||||||
<p>백오피스 사용자, 그룹, 역할, 권한의 최종 상속 결과를 확인합니다. 직접 역할과 그룹을 통한 상속 역할을 나누어 보여주므로 권한이 어디에서 왔는지 추적할 수 있습니다.</p>
|
<p>직접 역할과 그룹 상속 역할을 합쳐 최종 권한을 계산합니다. 여기의 결과는 토큰을 발급해 ORDS/VPD 조회를 검증하기 전 확인하는 설계 근거입니다.</p>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning" th:if="${runtimeError}">
|
<div class="alert alert-warning" th:if="${runtimeError}">
|
||||||
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
|
<strong th:text="${runtimeError.title()}">DB 연결 설정이 필요합니다.</strong>
|
||||||
<span th:text="${runtimeError.message()}">message</span>
|
<span th:text="${runtimeError.message()}">message</span>
|
||||||
<div th:if="${runtimeError.showSupportCommand()}">
|
<div th:if="${runtimeError.showSupportCommand()}"><code>./run.sh backoffice-support</code></div>
|
||||||
<code>./run.sh backoffice-support</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section th:replace="~{fragments/layout :: architectureStrip('permission')}"></section>
|
<section class="content-band effective-overview">
|
||||||
|
|
||||||
<section class="content-band">
|
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>권한 해석 기준</h2>
|
<span class="architecture-kicker">권한 근거</span>
|
||||||
<p class="section-subtitle">Bearer Token으로 식별되는 백오피스 사용자 기준입니다.</p>
|
<h2>누가 어떤 경로로 접근하는지 먼저 확인하세요.</h2>
|
||||||
|
<p class="section-subtitle">사용자 기준으로 시작하고, 필요할 때 그룹과 역할 기준으로 전환합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-sm rw-btn-secondary" href="/permissions">권한 관리로 이동</a>
|
<a class="btn btn-sm rw-btn-primary" href="/probe">이어서 실제 결과 확인</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="matrix-summary">
|
<div class="matrix-summary">
|
||||||
<div>
|
<div><span>사용자</span><strong th:text="${#lists.size(matrix.users())}">0</strong></div>
|
||||||
<span>사용자</span>
|
<div><span>그룹</span><strong th:text="${#lists.size(matrix.groups())}">0</strong></div>
|
||||||
<strong th:text="${#lists.size(matrix.users())}">0</strong>
|
<div><span>역할</span><strong th:text="${#lists.size(matrix.roles())}">0</strong></div>
|
||||||
</div>
|
<div><span>권한</span><strong th:text="${matrix.permissionCount()}">0</strong></div>
|
||||||
<div>
|
|
||||||
<span>그룹</span>
|
|
||||||
<strong th:text="${#lists.size(matrix.groups())}">0</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>역할</span>
|
|
||||||
<strong th:text="${#lists.size(matrix.roles())}">0</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>권한</span>
|
|
||||||
<strong th:text="${matrix.permissionCount()}">0</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details class="explanation-details">
|
|
||||||
<summary>권한이 계산되는 순서 보기</summary>
|
|
||||||
<ol class="setup-steps">
|
|
||||||
<li>사용자 관리에서 application user를 등록합니다.</li>
|
|
||||||
<li>그룹 관리에서 사용자를 그룹에 넣고 그룹에 역할을 부여합니다.</li>
|
|
||||||
<li>권한 관리에서 역할에 TABLE/VIEW 행 규칙과 컬럼 NULL 정책을 부여합니다.</li>
|
|
||||||
<li>토큰을 발급하면 ORDS handler가 token user context를 설정하고 VPD가 이 권한을 적용합니다.</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="content-band">
|
|
||||||
<div class="section-heading">
|
|
||||||
<div>
|
|
||||||
<h2>사용자 기준</h2>
|
|
||||||
<p class="section-subtitle">직접 역할과 그룹을 통해 상속된 역할을 분리해서 봅니다.</p>
|
|
||||||
</div>
|
|
||||||
<label class="matrix-filter">
|
|
||||||
사용자
|
|
||||||
<select class="form-select form-select-sm" data-effective-filter="user-effective-table">
|
|
||||||
<option value="">전체 사용자</option>
|
|
||||||
<option th:each="row : ${matrix.users()}" th:value="${row.userId()}" th:text="${row.username()}"></option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm align-middle" id="user-effective-table" data-empty-message="선택한 사용자에 대한 유효 권한이 없습니다. 사용자 등록, 그룹 배정, 역할 부여 순서로 확인하세요.">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>사용자</th>
|
|
||||||
<th>직접 역할</th>
|
|
||||||
<th>소속 그룹</th>
|
|
||||||
<th>그룹 상속 역할</th>
|
|
||||||
<th>최종 역할</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th>보호 객체</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="row : ${matrix.users()}" th:attr="data-effective-id=${row.userId()}">
|
|
||||||
<td>
|
|
||||||
<strong th:text="${row.username()}">이에이치알</strong>
|
|
||||||
<div class="text-muted small" th:text="${row.empNo() + ' / ' + row.deptCode()}">E100 / HR</div>
|
|
||||||
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
|
|
||||||
th:text="${row.active()} ? 'ACTIVE APP USER' : 'INACTIVE APP USER'">ACTIVE</span>
|
|
||||||
</td>
|
|
||||||
<td th:text="${#lists.isEmpty(row.directRoles()) ? '-' : #strings.listJoin(row.directRoles(), ', ')}">-</td>
|
|
||||||
<td th:text="${#lists.isEmpty(row.groups()) ? '-' : #strings.listJoin(row.groups(), ', ')}">-</td>
|
|
||||||
<td th:text="${#lists.isEmpty(row.inheritedRoles()) ? '-' : #strings.listJoin(row.inheritedRoles(), ', ')}">-</td>
|
|
||||||
<td><strong th:text="${#lists.isEmpty(row.effectiveRoles()) ? '-' : #strings.listJoin(row.effectiveRoles(), ', ')}">-</strong></td>
|
|
||||||
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
|
|
||||||
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${#lists.isEmpty(matrix.users())}">
|
|
||||||
<td colspan="7" class="text-muted">등록된 사용자가 없습니다. 사용자 관리에서 application user를 먼저 등록하세요.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="effective-workbench" aria-label="유효 권한 탐색">
|
||||||
<div class="section-heading">
|
<div class="effective-tabs" role="tablist" aria-label="유효 권한 기준">
|
||||||
<div>
|
<button class="effective-tab is-active" type="button" role="tab" aria-selected="true" data-effective-tab="user">사용자 기준</button>
|
||||||
<h2>그룹 기준</h2>
|
<button class="effective-tab" type="button" role="tab" aria-selected="false" data-effective-tab="group">그룹 기준</button>
|
||||||
<p class="section-subtitle">그룹에 속한 사용자와 그룹에 부여된 역할이 제공하는 권한을 봅니다.</p>
|
<button class="effective-tab" type="button" role="tab" aria-selected="false" data-effective-tab="role">역할 기준</button>
|
||||||
</div>
|
|
||||||
<label class="matrix-filter">
|
|
||||||
그룹
|
|
||||||
<select class="form-select form-select-sm" data-effective-filter="group-effective-table">
|
|
||||||
<option value="">전체 그룹</option>
|
|
||||||
<option th:each="row : ${matrix.groups()}" th:value="${row.groupId()}" th:text="${row.groupCode() + ' / ' + row.groupName()}"></option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-sm align-middle" id="group-effective-table" data-empty-message="선택한 그룹의 사용자/역할 매핑이 없습니다. 그룹 사용자와 그룹 역할을 먼저 등록하세요.">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>그룹</th>
|
|
||||||
<th>포함 사용자</th>
|
|
||||||
<th>부여 역할</th>
|
|
||||||
<th>권한</th>
|
|
||||||
<th>보호 객체</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="row : ${matrix.groups()}" th:attr="data-effective-id=${row.groupId()}">
|
|
||||||
<td>
|
|
||||||
<code th:text="${row.groupCode()}">GROUP</code>
|
|
||||||
<div th:text="${row.groupName()}">그룹명</div>
|
|
||||||
<span class="badge" th:classappend="${row.active()} ? ' text-bg-success' : ' text-bg-secondary'"
|
|
||||||
th:text="${row.active()} ? 'ACTIVE' : 'INACTIVE'">ACTIVE</span>
|
|
||||||
</td>
|
|
||||||
<td th:text="${#lists.isEmpty(row.users()) ? '사용자 없음' : #strings.listJoin(row.users(), ', ')}">users</td>
|
|
||||||
<td th:text="${#lists.isEmpty(row.roles()) ? '역할 없음' : #strings.listJoin(row.roles(), ', ')}">roles</td>
|
|
||||||
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
|
|
||||||
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
|
|
||||||
</tr>
|
|
||||||
<tr th:if="${#lists.isEmpty(matrix.groups())}">
|
|
||||||
<td colspan="5" class="text-muted">등록된 그룹이 없습니다. 그룹 관리에서 그룹을 먼저 생성하세요.</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="content-band">
|
<section class="effective-pane" data-effective-pane="user" role="tabpanel">
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<h2>역할 기준</h2>
|
<h2>선택한 사용자의 최종 권한</h2>
|
||||||
<p class="section-subtitle">역할이 직접 사용자와 그룹 사용자에게 어떤 영향을 주는지 봅니다.</p>
|
<p class="section-subtitle">직접 부여와 그룹 상속을 구분해, 실제 검증 전 권한 경로를 확인합니다.</p>
|
||||||
|
</div>
|
||||||
|
<label class="matrix-filter">
|
||||||
|
검증할 사용자
|
||||||
|
<select class="form-select form-select-sm" id="user-effective-select" data-effective-filter="user-effective-table" data-effective-user-select>
|
||||||
|
<option th:each="row, rowStat : ${matrix.users()}"
|
||||||
|
th:value="${row.userId()}"
|
||||||
|
th:selected="${rowStat.index == 0}"
|
||||||
|
th:text="${row.username() + ' · ' + row.empNo()}">사용자</option>
|
||||||
|
<option th:if="${#lists.isEmpty(matrix.users())}" value="">등록된 사용자가 없습니다</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="matrix-filter">
|
|
||||||
역할
|
<div class="effective-user-focus" data-effective-user-focus>
|
||||||
<select class="form-select form-select-sm" data-effective-filter="role-effective-table">
|
<div class="effective-user-focus-heading">
|
||||||
<option value="">전체 역할</option>
|
<span class="architecture-kicker">선택 사용자</span>
|
||||||
<option th:each="row : ${matrix.roles()}" th:value="${row.roleId()}" th:text="${row.roleName()}"></option>
|
<strong data-effective-user-field="username">사용자 선택</strong>
|
||||||
</select>
|
<small data-effective-user-field="profile">사번 / 부서</small>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
<dl>
|
||||||
<div class="table-responsive">
|
<div><dt>직접 역할</dt><dd data-effective-user-field="directRoles">-</dd></div>
|
||||||
<table class="table table-sm align-middle" id="role-effective-table" data-empty-message="선택한 역할의 사용자/그룹/권한 연결이 없습니다. 사용자 역할, 그룹 역할, 권한 등록을 확인하세요.">
|
<div><dt>그룹 상속</dt><dd data-effective-user-field="inheritance">-</dd></div>
|
||||||
<thead>
|
<div><dt>최종 역할</dt><dd data-effective-user-field="effectiveRoles">-</dd></div>
|
||||||
<tr>
|
<div><dt>보호 객체</dt><dd data-effective-user-field="objects">-</dd></div>
|
||||||
<th>역할</th>
|
</dl>
|
||||||
<th>직접 사용자</th>
|
<div class="effective-user-focus-footer">
|
||||||
<th>연결 그룹</th>
|
<span><strong data-effective-user-field="permissionCount">0</strong>개 권한이 적용됩니다.</span>
|
||||||
<th>그룹 상속 사용자</th>
|
<a href="/probe">토큰으로 실제 결과 확인 <span aria-hidden="true">→</span></a>
|
||||||
<th>영향 사용자</th>
|
</div>
|
||||||
<th>권한</th>
|
</div>
|
||||||
<th>보호 객체</th>
|
|
||||||
</tr>
|
<div class="table-responsive effective-table-detail">
|
||||||
</thead>
|
<table class="table table-sm align-middle" id="user-effective-table" data-empty-message="선택한 사용자에 대한 유효 권한이 없습니다. 사용자 등록, 그룹 배정, 역할 부여 순서로 확인하세요.">
|
||||||
<tbody>
|
<thead>
|
||||||
<tr th:each="row : ${matrix.roles()}" th:attr="data-effective-id=${row.roleId()}">
|
<tr><th>사용자</th><th>직접 역할</th><th>그룹/상속 역할</th><th>최종 역할</th><th>권한</th><th>보호 객체</th></tr>
|
||||||
<td>
|
</thead>
|
||||||
<strong th:text="${row.roleName()}">ROLE</strong>
|
<tbody>
|
||||||
<div class="text-muted small">민감도 <span th:text="${row.maxSensitivityLevel()}">PUBLIC</span></div>
|
<tr th:each="row : ${matrix.users()}"
|
||||||
</td>
|
th:attr="data-effective-id=${row.userId()},data-username=${row.username()},data-profile=${row.empNo() + ' / ' + row.deptCode()},data-direct-roles=${#lists.isEmpty(row.directRoles()) ? '직접 역할 없음' : #strings.listJoin(row.directRoles(), ', ')},data-inheritance=${(#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')) + ' / ' + (#lists.isEmpty(row.inheritedRoles()) ? '상속 역할 없음' : #strings.listJoin(row.inheritedRoles(), ', '))},data-effective-roles=${#lists.isEmpty(row.effectiveRoles()) ? '최종 역할 없음' : #strings.listJoin(row.effectiveRoles(), ', ')},data-objects=${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')},data-permission-count=${row.permissionCount()}">
|
||||||
<td th:text="${#lists.isEmpty(row.directUsers()) ? '직접 사용자 없음' : #strings.listJoin(row.directUsers(), ', ')}">users</td>
|
<td><strong th:text="${row.username()}">이에이치알</strong><div class="text-muted small" th:text="${row.empNo() + ' / ' + row.deptCode()}">E100 / HR</div></td>
|
||||||
<td th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">groups</td>
|
<td th:text="${#lists.isEmpty(row.directRoles()) ? '-' : #strings.listJoin(row.directRoles(), ', ')}">-</td>
|
||||||
<td th:text="${#lists.isEmpty(row.inheritedUsers()) ? '상속 사용자 없음' : #strings.listJoin(row.inheritedUsers(), ', ')}">group users</td>
|
<td><span th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">그룹 없음</span><small class="d-block text-muted" th:text="${#lists.isEmpty(row.inheritedRoles()) ? '상속 역할 없음' : #strings.listJoin(row.inheritedRoles(), ', ')}">상속 역할 없음</small></td>
|
||||||
<td><strong th:text="${#lists.isEmpty(row.affectedUsers()) ? '-' : #strings.listJoin(row.affectedUsers(), ', ')}">affected</strong></td>
|
<td><strong th:text="${#lists.isEmpty(row.effectiveRoles()) ? '-' : #strings.listJoin(row.effectiveRoles(), ', ')}">-</strong></td>
|
||||||
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
|
<td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td>
|
||||||
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
|
<td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr th:if="${#lists.isEmpty(matrix.roles())}">
|
<tr th:if="${#lists.isEmpty(matrix.users())}"><td colspan="6" class="text-muted">등록된 사용자가 없습니다. 사용자 관리에서 application user를 먼저 등록하세요.</td></tr>
|
||||||
<td colspan="7" class="text-muted">등록된 역할이 없습니다. 역할 관리에서 역할을 먼저 생성하세요.</td>
|
</tbody>
|
||||||
</tr>
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</section>
|
||||||
</div>
|
|
||||||
|
<section class="effective-pane" data-effective-pane="group" role="tabpanel" hidden>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><h2>그룹을 통한 상속 권한</h2><p class="section-subtitle">그룹에 속한 사용자와 그룹 역할이 만드는 접근 범위를 봅니다.</p></div>
|
||||||
|
<label class="matrix-filter">그룹<select class="form-select form-select-sm" data-effective-filter="group-effective-table"><option value="">전체 그룹</option><option th:each="row : ${matrix.groups()}" th:value="${row.groupId()}" th:text="${row.groupCode() + ' / ' + row.groupName()}"></option></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive"><table class="table table-sm align-middle" id="group-effective-table" data-empty-message="선택한 그룹의 사용자/역할 매핑이 없습니다. 그룹 사용자와 그룹 역할을 먼저 등록하세요."><thead><tr><th>그룹</th><th>포함 사용자</th><th>부여 역할</th><th>권한</th><th>보호 객체</th></tr></thead><tbody>
|
||||||
|
<tr th:each="row : ${matrix.groups()}" th:attr="data-effective-id=${row.groupId()}"><td><code th:text="${row.groupCode()}">GROUP</code><div th:text="${row.groupName()}">그룹명</div></td><td th:text="${#lists.isEmpty(row.users()) ? '사용자 없음' : #strings.listJoin(row.users(), ', ')}">users</td><td th:text="${#lists.isEmpty(row.roles()) ? '역할 없음' : #strings.listJoin(row.roles(), ', ')}">roles</td><td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td><td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td></tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(matrix.groups())}"><td colspan="5" class="text-muted">등록된 그룹이 없습니다. 그룹 관리에서 그룹을 먼저 생성하세요.</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="effective-pane" data-effective-pane="role" role="tabpanel" hidden>
|
||||||
|
<div class="section-heading">
|
||||||
|
<div><h2>역할의 영향 범위</h2><p class="section-subtitle">직접 사용자와 그룹 상속 사용자가 받는 권한을 봅니다.</p></div>
|
||||||
|
<label class="matrix-filter">역할<select class="form-select form-select-sm" data-effective-filter="role-effective-table"><option value="">전체 역할</option><option th:each="row : ${matrix.roles()}" th:value="${row.roleId()}" th:text="${row.roleName()}"></option></select></label>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive"><table class="table table-sm align-middle" id="role-effective-table" data-empty-message="선택한 역할의 사용자/그룹/권한 연결이 없습니다. 사용자 역할, 그룹 역할, 권한 등록을 확인하세요."><thead><tr><th>역할</th><th>직접 사용자</th><th>연결 그룹</th><th>그룹 상속 사용자</th><th>영향 사용자</th><th>권한</th><th>보호 객체</th></tr></thead><tbody>
|
||||||
|
<tr th:each="row : ${matrix.roles()}" th:attr="data-effective-id=${row.roleId()}"><td><strong th:text="${row.roleName()}">ROLE</strong><div class="text-muted small">민감도 <span th:text="${row.maxSensitivityLevel()}">PUBLIC</span></div></td><td th:text="${#lists.isEmpty(row.directUsers()) ? '직접 사용자 없음' : #strings.listJoin(row.directUsers(), ', ')}">users</td><td th:text="${#lists.isEmpty(row.groups()) ? '그룹 없음' : #strings.listJoin(row.groups(), ', ')}">groups</td><td th:text="${#lists.isEmpty(row.inheritedUsers()) ? '상속 사용자 없음' : #strings.listJoin(row.inheritedUsers(), ', ')}">group users</td><td><strong th:text="${#lists.isEmpty(row.affectedUsers()) ? '-' : #strings.listJoin(row.affectedUsers(), ', ')}">affected</strong></td><td><span class="badge text-bg-secondary" th:text="${row.permissionCount()}">0</span></td><td class="matrix-list" th:text="${#lists.isEmpty(row.objectNames()) ? '권한 객체 없음' : #strings.listJoin(row.objectNames(), ', ')}">objects</td></tr>
|
||||||
|
<tr th:if="${#lists.isEmpty(matrix.roles())}"><td colspan="7" class="text-muted">등록된 역할이 없습니다. 역할 관리에서 역할을 먼저 생성하세요.</td></tr>
|
||||||
|
</tbody></table></div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.cloudhandson.vpdbackoffice.web;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.effective.EffectiveMatrixView;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.effective.GroupEffectiveAccessView;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.effective.RoleEffectiveImpactView;
|
||||||
|
import com.cloudhandson.vpdbackoffice.domain.effective.UserEffectiveAccessView;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.thymeleaf.context.Context;
|
||||||
|
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||||
|
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||||
|
|
||||||
|
class EffectiveMatrixTemplateRenderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rendersTheUserFocusedEffectiveMatrixWithItsLayoutFragments() {
|
||||||
|
var resolver = new FileTemplateResolver();
|
||||||
|
resolver.setPrefix(Path.of("src/main/resources/templates").toAbsolutePath() + "/");
|
||||||
|
resolver.setSuffix(".html");
|
||||||
|
resolver.setTemplateMode("HTML");
|
||||||
|
resolver.setCacheable(false);
|
||||||
|
var engine = new SpringTemplateEngine();
|
||||||
|
engine.setTemplateResolver(resolver);
|
||||||
|
|
||||||
|
var matrix = new EffectiveMatrixView(
|
||||||
|
List.of(new UserEffectiveAccessView(
|
||||||
|
103L, "김어드민", "E99999", "HQ", true,
|
||||||
|
List.of("ALL_DOC_ROLE"), List.of(), List.of(), List.of("ALL_DOC_ROLE"),
|
||||||
|
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||||
|
List.of(new GroupEffectiveAccessView(
|
||||||
|
1L, "OPS", "운영", true, List.of("김어드민"), List.of("ALL_DOC_ROLE"),
|
||||||
|
1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||||
|
List.of(new RoleEffectiveImpactView(
|
||||||
|
30L, "ALL_DOC_ROLE", "CONFIDENTIAL", List.of("김어드민"), List.of(), List.of(),
|
||||||
|
List.of("김어드민"), 1, List.of("CB_VECTOR_SEARCH_DOCUMENTS"))),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
var context = new Context(Locale.KOREAN);
|
||||||
|
context.setVariable("matrix", matrix);
|
||||||
|
context.setVariable("_csrf", new CsrfFixture("_csrf", "test-token"));
|
||||||
|
|
||||||
|
String rendered = engine.process("effective-matrix", context);
|
||||||
|
|
||||||
|
assertThat(rendered)
|
||||||
|
.contains("선택한 사용자의 최종 권한")
|
||||||
|
.contains("김어드민")
|
||||||
|
.contains("data-effective-user-focus")
|
||||||
|
.contains("data-inheritance=\"그룹 없음 / 상속 역할 없음\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
private record CsrfFixture(String parameterName, String token) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,6 +113,22 @@ class GuidedFlowTemplateTest {
|
|||||||
.contains("기술 태그 비교 예시");
|
.contains("기술 태그 비교 예시");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void effectiveMatrixStartsWithOneUserAndKeepsGroupAndRoleAsSecondaryViews() throws IOException {
|
||||||
|
String html = template("effective-matrix.html");
|
||||||
|
String javascript = Files.readString(Path.of("src/main/resources/static/js/app.js"));
|
||||||
|
|
||||||
|
assertThat(html)
|
||||||
|
.contains("선택한 사용자의 최종 권한")
|
||||||
|
.contains("data-effective-user-focus")
|
||||||
|
.contains("data-effective-tab=\"user\"")
|
||||||
|
.contains("data-effective-pane=\"group\"")
|
||||||
|
.contains("data-effective-pane=\"role\"");
|
||||||
|
assertThat(javascript)
|
||||||
|
.contains("updateEffectiveUserFocus")
|
||||||
|
.contains("initEffectiveMatrixTabs");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void mcpPagesDescribeReasoningAndProtocolBoundaries() throws IOException {
|
void mcpPagesDescribeReasoningAndProtocolBoundaries() throws IOException {
|
||||||
String chatbot = template("mcp-chatbot.html");
|
String chatbot = template("mcp-chatbot.html");
|
||||||
|
|||||||
Reference in New Issue
Block a user