Skip to content

중앙 디렉토리 서비스 통합 운영 가이드 ​

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 한다 — 조회 scope directory:read, 쓰기 scope directory: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 Keyx-directory-admin-key관리 화면·중앙 관리 tRPC API
소비 포털포털 API Key 또는 JWTAuthorization: Bearer …, portal ID, scope사용자·조직 조회, 동기화 상태 보고
권한 요약 발행 포털포털 API Key 또는 JWT위 항목 + authorization:sync자신의 읽기 전용 권한 요약 스냅샷 발행

관리자 API Key는 브라우저 세션 저장소에만 보관하며 쿠키나 OAuth 리디렉션을 사용하지 않는다. 운영 환경에서는 아래 값을 코드 저장소가 아닌 Secret 관리 시스템으로 주입한다.

json
// PORTAL_CONSOLE_ADMIN_API_KEYS_JSON
[
  { "id": 1, "label": "Directory Operator", "apiKey": "replace-with-a-long-random-secret", "enabled": true }
]
json
// 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는 제공하지 않는다 — 권한 판정은 각 포털이 소유한다.

MethodEndpointRequired scope설명
GET/api/directory/v1/usersdirectory:read사용자 검색, 활성 여부 필터, cursor 페이지네이션
GET/api/directory/v1/departmentsdirectory:read부서·조직 계층 전체 조회
POST/api/directory/v1/sync-statusdirectory:sync호출 포털 자신의 읽기 동기화 상태 보고
POST/api/directory/v1/authorization-snapshotauthorization:sync호출 포털 자신의 그룹·역할·권한 요약 전체 교체
GET/api/directory/v1/ontology/packglossary:read온톨로지 용어팩 — 승인된 개념·관계 묶음(scope 로 좁힘). 소비 포털의 AI/RAG 가 답변 문맥(grounding)으로 주입한다
GET/api/directory/v1/glossary/matching-termsglossary:read매칭용 사전 — 승인·활성 용어의 최소 표현(id·표기·별칭·약어·정의). 포털이 컨텐츠 본문에서 용어를 찾을 때 쓴다
POST/api/directory/v1/glossary/term-proposalsglossary:write용어 후보 등록 — 본문에 있던 낯선 표기를 승인 대기(proposed)로 올린다. 포털은 승인할 수 없고, 이미 있는 표기는 data.skipped 로 돌아온다
GET/api/directory/v1/content-term-linksglossary:read컨텐츠↔용어 연결 조회(contentKey·portalId·termId·since 필터, tombstone 기본 포함)
POST/api/directory/v1/content-term-linksglossary:write컨텐츠 본문에서 찾은 용어 연결 push(배치·멱등). 승인하지 않은 용어는 오류가 아니라 data.skipped 로 돌려준다
DELETE/api/directory/v1/content-term-linksglossary:write연결 해제(소프트 삭제) — contentKey 또는 termId 중 하나는 필요하다

사용자 응답은 다음 형태다. 목록을 저장하는 포털은 nextCursor가 없을 때까지 순차로 요청하고, meta.dataVersion을 캐시 비교 기준으로 사용한다.

ts
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 };
};
bash
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로 거부된다.

ts
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[];
  }>;
};
bash
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(대상 회사 조인) 관례를 쓴다. 예: ProcessCenter process_nodes.isAllCompanies + process_node_companies, GuideCenter manual_systems.isAllCompanies + companyId.
  • 포털은 세션의 companyId로 목록·상세 조회를 제한하고, 권한 스냅샷에는 중앙이 사용자 소속 회사를 companyId로 기록한다(관측용). 포털의 그룹·역할·리소스 권한 판정은 계속 포털 소유다.
  • 기존 데이터 호환: 회사 정보가 없는 구버전 포털 토큰은 companyId가 비어 있을 수 있으며, 이 경우 포털은 전체 데이터를 보여 주는 과도기 동작을 유지한다.