중앙 디렉토리 서비스 통합 운영 가이드
1. 확정된 소유권 경계
디렉토리 서비스는 모든 포털이 공통으로 참조하는 사용자·재직 상태·조직도의 신뢰 원본이다. Document Library, 프로세스 포털, 향후 제3 포털은 중앙 사용자 ID와 조직 정보를 읽어 사용한다. 반면, 사용자그룹·업무 역할·메뉴 및 리소스 권한은 각 포털의 업무 문맥에 종속되므로 해당 포털이 직접 소유하고 변경한다.
| 정보 영역 | 원본·변경 책임 | 중앙 디렉토리의 역할 | 다른 포털의 역할 |
|---|---|---|---|
| 사용자 ID, Login ID, 이름, 이메일, 재직 상태 | 중앙 디렉토리 | 생성·수정·비활성화·삭제 | 읽기 전용 소비 |
| 부서, 상하위 조직도, 부서 활성 상태 | 중앙 디렉토리 | 생성·수정·비활성화·삭제 | 읽기 전용 소비 |
| 업무 사용자그룹 | 해당 포털 | 읽기 전용 요약 관측 | 생성·변경·삭제·권한 반영 |
| 역할과 메뉴·리소스 권한 | 해당 포털 | 읽기 전용 요약 관측 | 생성·변경·삭제·최종 판정 |
| 포털별 권한 현황 대시보드 | 중앙 디렉토리 | 최신 스냅샷을 통합 조회 | 자신의 최신 요약 발행 |
중앙 디렉토리는 포털의 사용자그룹이나 역할을 편집하지 않는다. 중앙에서 보이는 권한 정보는 포털이 발행한 관측용 복제본이며, 실제 권한 판단과 변경은 반드시 해당 포털에서 수행한다.
개정 (2026-10-04) — 공통 사용자 그룹은 원장으로 이동. 위 소유 경계의 예외다. 그룹 + 멤버십만 PortalConsole 을 원장으로 두고 공통화한다(
todo.md「공통 사용자 그룹 (PortalConsole 원장 + PC push/pull)」). 소비 포털은/api/directory/v1/user-groups로 증분 pull(tombstone포함)하고 생성·수정·삭제를 push 한다 — 조회 scopedirectory:read, 쓰기 scopedirectory:sync, 충돌 시 원장 우선. 각 포털의 리소스 권한(resource/action)은 그대로 그 포털 소유로 남는다. 콘솔 관리 화면은client/src/components/UserGroupsDesk.tsx, 실행 절차는user-group-sync-runbook.md.
개정 (2026-10-04) — 컨텐츠↔용어 연결도 원장으로 모은다. 용어·온톨로지 지식(정의·관계)은 PortalConsole 이 원장이고, 컨텐츠 본문에서 찾은 용어 연결도 같은 원장에 모은다(
directory_content_term_links). 컨텐츠 자체는 각 포털 소유로 남고 연결만 공통화한다 —content_key는portalId:entityType:entityId로 온톨로지 참조 노드와 같은 형식이다. 소비 포털은 컨텐츠를 저장할 때 본문을 비동기 큐에 넣고, 워커가 원장이 준 매칭용 사전으로 승인 용어를 찾아/api/directory/v1/content-term-links로 push 한다. 규칙: (1) 사전 매칭(detectedBy=dictionary)은 승인(approved)·활성 용어만 링크되고, 본문에 낯선 말이 있어도 링크로 지식을 만들지 않는다(그건 용어 자체를proposed로 등록해 사람 승인을 거치는 일). (2) 같은(contentKey, termId)는 한 줄이고 삭제는 소프트 삭제(tombstone)다. (3) 자동 매칭은 사람이 만든 연결(manual)·LLM 후보(llm)를 건드리지 않는다 —detectedBy는dictionary(사전 매칭)·llm(모델 후보)·manual(사람이 화면에서 고른 연결)이다. (4) 해제는contentKey·termId로 대상을 정하고portalId로 좁힐 수 있다 — 한 포털이 자기 연결을 바꾸면서 다른 포털의 연결을 지우지 않게 한다. 조회 scope 는glossary:read, 쓰기 scope 는glossary:write(용어 지식 원장과 같은 등급). 기준 구현은 Process Center 의server/termLinkSync.ts(term_extraction_jobs큐 +flushTermExtractionJobs()+ 스케줄러)이고, 화면에서 고른 연결은server/repositories/directoryGlossaryRepository.ts가manual로 원장에 쓴다(이 포털 DB에는 연결 테이블이 없다). 사전에 없는 표기는 후보로만 올린다(POST /glossary/term-proposals, 항상proposed) — 승인은 사람이 콘솔에서 하고, 승인되는 순간 원장이 그 후보의 근거(evidence.kind = content)에 남은 컨텐츠로 연결을 만든다(linkApprovedEvidence). 포털의 REST 수정은 상태를 바꾸지 못한다(상태를 주지 않으면 기존 상태 유지 — 조용한 승인 금지). 승인된 용어팩(GET /ontology/pack)은 소비 포털의 RAG 답변 문맥으로도 들어간다 — Process Center 는server/ontologyGrounding.ts에서 질문에 걸린 개념·관계만 골라(표기 이형은 표준 표기로 표시) 로컬 답변의 시스템 프롬프트와 RAGCenter 위임 질문 앞에 붙인다. 팩을 못 받으면 grounding 없이 답하고, 용어는 해석 기준일 뿐 문서 밖 사실의 근거로 쓰지 않는다. 화면에서는 포털이 연결을 표기·정의와 함께 읽어(GET /content-term-links?...&includeTerm=true) 공용 툴팁(PortalTerm·PortalTermList, portal-foundation 0.6.168)으로 보여 준다 — 정의를 포털이 복제하지 않으므로 원장의 표기·정의 변경이 화면에 바로 반영된다.
2. 외부 배포용 인증 구조
외부 플랫폼 OAuth, OAuth 콜백, 세션 쿠키와 프로젝트 전용 사용자 테이블은 제거했다. 관리 화면과 내부 tRPC API는 환경변수로 설정하는 독립 관리자 API Key를 전용 헤더로 확인한다. 포털 간 REST API는 Authorization: Bearer <credential> 형식의 API Key 또는 JWT를 사용한다.
| 호출 주체 | 인증 수단 | 헤더 또는 검증 항목 | 사용 범위 |
|---|---|---|---|
| Directory 관리자 브라우저 | 관리자 API Key | x-directory-admin-key | 관리 화면·중앙 관리 tRPC API |
| 소비 포털 | 포털 API Key 또는 JWT | Authorization: Bearer …, portal ID, scope | 사용자·조직 조회, 동기화 상태 보고 |
| 권한 요약 발행 포털 | 포털 API Key 또는 JWT | 위 항목 + authorization:sync | 자신의 읽기 전용 권한 요약 스냅샷 발행 |
관리자 API Key는 브라우저 세션 저장소에만 보관하며 쿠키나 OAuth 리디렉션을 사용하지 않는다. 운영 환경에서는 아래 값을 코드 저장소가 아닌 Secret 관리 시스템으로 주입한다.
// PORTAL_CONSOLE_ADMIN_API_KEYS_JSON
[
{ "id": 1, "label": "Directory Operator", "apiKey": "replace-with-a-long-random-secret", "enabled": true }
]// PORTAL_API_KEYS_JSON
[
{
"portalId": "document-library",
"apiKey": "replace-with-a-rotated-portal-secret",
"scopes": ["directory:read", "directory:sync", "authorization:sync"],
"enabled": true
}
]JWT 기반 포털 인증을 사용할 때는 PORTAL_JWT_SECRET, PORTAL_JWT_ISSUER, PORTAL_JWT_AUDIENCE를 별도로 설정한다. iss, aud, 만료, sub, azp 또는 client_id, scope 검증에 실패한 요청은 허용하지 않는다. 포털 REST API는 PORTAL_JWT_SECRET이 설정된 경우에만 서버에 등록되므로, 비밀값이 없는 환경에서는 Fail Closed 상태로 노출되지 않는다.
3. 중앙 읽기 전용 REST API
외부 배포 주소는 환경별 DIRECTORY_BASE_URL로 설정한다. 포털은 사용자·부서·용어와 함께 공통 사용자 그룹을 중앙에서 읽는다(GET /api/directory/v1/user-groups — tombstone 포함 증분). 중앙 권한 판정 API는 제공하지 않는다 — 권한 판정은 각 포털이 소유한다.
| Method | Endpoint | Required scope | 설명 |
|---|---|---|---|
GET | /api/directory/v1/users | directory:read | 사용자 검색, 활성 여부 필터, cursor 페이지네이션 |
GET | /api/directory/v1/departments | directory:read | 부서·조직 계층 전체 조회 |
POST | /api/directory/v1/sync-status | directory:sync | 호출 포털 자신의 읽기 동기화 상태 보고 |
POST | /api/directory/v1/authorization-snapshot | authorization:sync | 호출 포털 자신의 그룹·역할·권한 요약 전체 교체 |
GET | /api/directory/v1/ontology/pack | glossary:read | 온톨로지 용어팩 — 승인된 개념·관계 묶음(scope 로 좁힘). 소비 포털의 AI/RAG 가 답변 문맥(grounding)으로 주입한다 |
GET | /api/directory/v1/glossary/matching-terms | glossary:read | 매칭용 사전 — 승인·활성 용어의 최소 표현(id·표기·별칭·약어·정의). 포털이 컨텐츠 본문에서 용어를 찾을 때 쓴다 |
POST | /api/directory/v1/glossary/term-proposals | glossary:write | 용어 후보 등록 — 본문에 있던 낯선 표기를 승인 대기(proposed)로 올린다. 포털은 승인할 수 없고, 이미 있는 표기는 data.skipped 로 돌아온다 |
GET | /api/directory/v1/content-term-links | glossary:read | 컨텐츠↔용어 연결 조회(contentKey·portalId·termId·since 필터, tombstone 기본 포함) |
POST | /api/directory/v1/content-term-links | glossary:write | 컨텐츠 본문에서 찾은 용어 연결 push(배치·멱등). 승인하지 않은 용어는 오류가 아니라 data.skipped 로 돌려준다 |
DELETE | /api/directory/v1/content-term-links | glossary:write | 연결 해제(소프트 삭제) — contentKey 또는 termId 중 하나는 필요하다 |
사용자 응답은 다음 형태다. 목록을 저장하는 포털은 nextCursor가 없을 때까지 순차로 요청하고, meta.dataVersion을 캐시 비교 기준으로 사용한다.
type DirectoryUser = {
id: string;
loginId: string;
/** 하위 호환 표시 이름 — koreanName 우선, 없으면 englishName. */
name: string;
koreanName: string;
englishName: string;
email: string;
jobTitle: string; // 직급
position: string; // 직위
employeeNo: string | null; // 사번(선택)
isDepartmentHead: boolean;
departmentId: string | null;
departmentName: string | null;
deptCode: string | null;
employmentStatus: "ACTIVE" | "LEAVE" | "RETIRED";
/** 하위 호환 — employmentStatus === "ACTIVE" 와 같은 값이다. */
isEmployed: boolean;
isActive: boolean;
version: number;
updatedAt: string;
};
> 1.0.49부터 `headRole`(역할 enum)은 제거되고 `isDepartmentHead`(boolean)로 바뀌었다. 화면 언어에 따라 `englishName || koreanName`(영어) 또는 `koreanName || englishName`(한국어)을 표시 이름으로 고른다. `name`과 `isEmployed`는 기존 소비 포탈을 위한 하위 호환 필드다.
type UsersResponse = {
data: { items: DirectoryUser[]; nextCursor: string | null };
meta: { dataVersion: number; generatedAt: string };
};curl -H "Authorization: Bearer $DOCUMENT_LIBRARY_DIRECTORY_KEY" \
"$DIRECTORY_BASE_URL/api/directory/v1/users?onlyActive=true&limit=50"/groups와 /permission-check는 의도적으로 제공하지 않는다. 게시판·결재·문서 등 포털 업무 권한은 포털이 중앙 사용자 ID, 재직 상태, 조직 정보를 입력으로 하여 자체 권한 저장소에서 판정한다.
4. 포털 권한 요약 수집 계약
각 포털은 자체 그룹·역할·권한이 변경된 뒤 또는 주기적 재조정 시점에 전체 요약을 발행한다. 중앙은 포털 ID별 기존 요약을 원자적으로 삭제하고 새 요약으로 교체한다. 다른 포털의 요약을 변경하려 하면 403 portal_mismatch로 거부된다.
type AuthorizationAssignment = {
kind: "GROUP" | "ROLE" | "PERMISSION";
key: string; // 포털 내부의 안정적 식별자
label: string; // 대시보드 표시명
scope?: string; // 메뉴·리소스 등 사람이 이해할 수 있는 범위
};
type AuthorizationSnapshotRequest = {
portalId: string;
sourceVersion: string;
entries: Array<{
userId: string; // 중앙 directory_users.id
assignments: AuthorizationAssignment[];
}>;
};curl -X POST "$DIRECTORY_BASE_URL/api/directory/v1/authorization-snapshot" \
-H "Authorization: Bearer $DOCUMENT_LIBRARY_DIRECTORY_KEY" \
-H "Content-Type: application/json" \
-d '{
"portalId": "document-library",
"sourceVersion": "document-library-acl-2026-08-17T00:00:00Z",
"entries": [
{
"userId": "usr_directory_user_id",
"assignments": [
{ "kind": "GROUP", "key": "reviewers", "label": "문서 검토자" },
{ "kind": "ROLE", "key": "ADMIN", "label": "Document Library 관리자", "scope": "admin" }
]
}
]
}'중앙은 모든 userId가 중앙 사용자 원장에 존재하는지 확인한다. 알 수 없는 ID가 하나라도 포함되면 전체 스냅샷을 거부한다. 성공한 수집은 portal_authorization_snapshots, portal_authorization_entries, 감사 로그에 기록되며, 관리 화면에서는 사용자별·포털별로 조회된다.
5. 동기화와 장애 정책
포털은 중앙 사용자·조직 API를 cursor 기반으로 동기화한다. 성공·실패는 자신과 같은 portalId로 /sync-status에 보고한다. 중앙은 NEVER_SYNCED, HEALTHY, STALE, FAILED와 마지막 성공·실패 시각, 오류, data version을 운영센터에 표시한다.
| 상황 | 포털의 사용자·조직 읽기 | 포털의 업무 권한 판정 | 중앙 대시보드 |
|---|---|---|---|
| 중앙 API 정상 | 최신 원장·dataVersion 반영 | 포털 내부 ACL과 최신 신원 상태를 함께 적용 | 최신 요약 표시 |
| 중앙 API 일시 실패 | 표시용 캐시는 제한적으로 사용 가능 | 보호된 기능은 허용하지 않음(Fail Closed) | 마지막 성공한 권한 요약과 수집 시각 표시 |
| 포털 권한 요약 발행 실패 | 포털 내부 권한은 계속 자체 관리 | 포털에서 재시도 | 해당 포털의 마지막 수집 시각을 유지하고 실패 상태 표시 |
| 포털 API Key 또는 JWT 오류 | 중앙 데이터 호출 거부 | 권한 허용 금지 | 해당 포털의 상태 확인 필요 |
FailClosedCache, CircuitBreaker, DirectoryReadClient는 중앙 사용자·조직 상태를 요구하는 보호 기능에서 stale 결과만으로 접근을 허용하지 않도록 사용한다. 포털의 자체 그룹·권한은 중앙 API의 리소스별 권한 판정에 의존하지 않는다.
6. 외부 배포 체크리스트
| 항목 | 완료 기준 |
|---|---|
| 데이터베이스 | 사용자·부서·감사·포털 동기화·포털 권한 요약 테이블이 적용됨 |
| 레거시 제거 | 중앙 사용자그룹, 중앙 포털 역할, OAuth 사용자 테이블이 제거됨 |
| 관리자 인증 | PORTAL_CONSOLE_ADMIN_API_KEYS_JSON이 Secret으로 주입되고 로그인 검증됨 |
| 포털 인증 | PORTAL_API_KEYS_JSON 또는 JWT 설정과 포털별 최소 scope가 등록됨 |
| REST 연결 | 포털에서 /users, /departments, /sync-status, /authorization-snapshot을 계약대로 호출함 |
| 운영 대시보드 | 포털별 스냅샷 시각·사용자 수·권한 항목 수와 사용자별 요약이 표시됨 |
| 보안 | 사용자그룹·역할 변경은 포털에만 존재하고, 중앙은 읽기 전용 관측만 수행함 |
7. 검증 범위
현재 Vitest 계약은 관리자 API Key의 Fail Closed 동작, API Key/JWT 포털 인증, scope 거부, 사용자·부서 API의 cursor 응답, 동기화 상태의 실패 시각, 중앙 그룹 API 미노출, 권한 요약 스냅샷의 scope·portal ID 경계, 사용자·조직 입력 계약을 검증한다. 외부 포털이 실제 연결된 뒤에는 해당 포털의 API Key로 권한 요약 발행과 관리자 대시보드 표시를 통합 검증한다.
8. 다회사(회사 스코프)
중앙은 신원·회사 컨텍스트의 원장이고, 리소스별 읽기 권한은 각 소비 포털이 소유한다.
companies—id,code,name,nameEn,defaultLocale,isActive,version. 기본 회사(cmp_default)가 마이그레이션에서 생성된다.directory_users.companyId— 사용자당 하나의 주 소속 회사. 기존 사용자는cmp_default로 이관된다.directory_departments.companyId— 회사별 조직 트리. 부서 코드 유일성은(companyId, code)기준이다.- Directory access token / SSO 응답 /
/api/directory/v1/users//departments는companyId와companyDefaultLocale을 함께 노출한다. - 소비 포털은
isAllCompanies(전체 회사 적용) +entity_company(대상 회사 조인) 관례를 쓴다. 예: ProcessCenterprocess_nodes.isAllCompanies+process_node_companies, GuideCentermanual_systems.isAllCompanies+companyId. - 포털은 세션의
companyId로 목록·상세 조회를 제한하고, 권한 스냅샷에는 중앙이 사용자 소속 회사를companyId로 기록한다(관측용). 포털의 그룹·역할·리소스 권한 판정은 계속 포털 소유다. - 기존 데이터 호환: 회사 정보가 없는 구버전 포털 토큰은
companyId가 비어 있을 수 있으며, 이 경우 포털은 전체 데이터를 보여 주는 과도기 동작을 유지한다.