134 lines
13 KiB
Markdown
134 lines
13 KiB
Markdown
# SSO 사용자 테넌트와 회사·부서 매핑 확인
|
|
|
|
확인일: 2026-10-30
|
|
|
|
## 확인 결과
|
|
|
|
사용자 소속 목록과 조직 계층을 조회하는 경로가 있다. 다만 로그인 claim, 본인 가입 테넌트, Integration Credential로 조회하는 조직 subtree는 서로 다른 범위다. `tenants` scope를 요청했다고 사용자의 모든 소속과 모든 상위 조직이 자동으로 전달된다고 가정하면 안 된다.
|
|
|
|
요청자가 확인한 표시 기준은 다음과 같다. 이 이름은 기대 결과이며 실제 SSO 응답과 UUID를 조회해 검증한 결과는 아니다.
|
|
|
|
| 항목 | 기대 값 |
|
|
| --- | --- |
|
|
| 회사 | 총괄기획실&기술개발센터 |
|
|
| 부서 | IS3 |
|
|
|
|
사용자 추가 확인: `STAGING_ORG_CONTEXT_TENANT_IDS`에는 회사나 IS3가 아닌 **한맥가족 등 최상위 분류 조직**의 UUID를 등록했다. UUID 원문과 실제 조직 응답은 아직 확인하지 않았다.
|
|
|
|
### 실제 세션 응답으로 확인된 상태
|
|
|
|
사용자가 전달한 로그인 후 `/auth/session` 응답의 관련 필드는 다음과 같다. 개인 연락처와 사용자 UUID는 이 문서에 복사하지 않는다.
|
|
|
|
```json
|
|
{
|
|
"authenticated": true,
|
|
"user": {
|
|
"tenantName": "IS3",
|
|
"company": "IS3",
|
|
"companySource": "sso",
|
|
"department": "",
|
|
"orgContextStatus": "unavailable"
|
|
}
|
|
}
|
|
```
|
|
|
|
이 응답으로 **현재 사용자 조직 조회·응답 처리에 실패하여 기존 SSO 값이 유지되고 있음**은 확인됐다. 조직 조회에 성공한 뒤 회사 노드를 잘못 선택했다고 확정할 수 있는 상태는 아니다. 코드상 `unavailable`은 구성 검사, HTTP 호출, 응답 해석·계층 검증 등의 예외를 함께 나타낸다. 사용자 UUID를 찾지 못한 경우의 `not_found`, 복수 소속을 선택하지 못한 경우의 `ambiguous_membership`과도 다르다.
|
|
|
|
[최근 스테이징 배포 로그](https://gitea.hmac.kr/disong/baron_qa_write/actions/runs/223175/jobs/247461)에서는 `org:verify`가 `rootCount=7`, `tenantCount=560`, `userCount=4607`, `membershipCount=5370`으로 성공했다. 배포 당시 CI에서 키·조회 범위·응답 처리 전체가 성공했다는 증거이며 현재 Worker에서도 성공한다는 증거는 아니다.
|
|
|
|
CI 검증은 요청당 15초, 실제 Worker 조회는 요청당 5초 제한이다. Worker의 `org_context_timeout`, `org_context_http_401`, `org_context_http_403`, `org_context_request_failed` 등 정확한 로그 코드를 확인해야 통신·시간 제한·runtime credential 문제를 구분할 수 있다. 세션 응답에 있는 빈 `scope`나 중복 `tenantIds`만으로 조직 API 실패 원인을 단정하지 않는다.
|
|
|
|
사용자가 추가로 전달한 로그는 `/auth/session`의 HTTP 200 요청 기록이며 `outcome=ok`, `wall_time_ms=3`이다. 코드가 조직 예외를 처리한 뒤 정상 HTTP 응답을 반환하므로 이 요청 기록에는 내부 오류가 직접 표시되지 않을 수 있다. 이 시간만으로 타임아웃을 확정할 수 없다.
|
|
|
|
로그 조회만으로 원인을 구분하지 못한 문제를 보완하기 위해 `user.orgContextError`에 허용된 오류 코드만 추가했다. 임의의 upstream 메시지는 숨기고, 성공 시 이전 오류 값은 제거한다. 배포 후 `/auth/session`의 이 필드로 원인을 확인할 수 있으며 실제 사용자 조직 조회 실패 자체를 해결한 변경은 아니다.
|
|
|
|
### 후속 확인: Workers 런타임에서 실패하는 코드 수정
|
|
|
|
진단 보완 배포 후 사용자가 제공한 오류는 `org_context_request_failed`였다. 조직 API 호출에 지정한 `redirect: "error"`가 Workers에서 지원되지 않아 요청 전 TypeError를 발생시키는 결함을 확인했다. Node.js는 이 옵션을 허용하므로 CI의 API 검증만 성공할 수 있었다. [Cloudflare Request 문서](https://developers.cloudflare.com/workers/runtime-apis/request/)에도 이 제한이 명시되어 있다.
|
|
|
|
동일한 배포 compatibility date를 사용한 실제 workerd 테스트에서 수정 전 `/auth/session`이 약 3ms 만에 `org_context_request_failed`와 `unavailable`을 반환하는 것을 재현했다. `redirect: "manual"`로 변경한 뒤에는 모의 조직 응답을 사용한 동일한 실제 런타임 경로에서 회사 `총괄기획실&기술개발센터`, 부서 `IS3`, 상태 `resolved`를 확인했다. 302 응답은 따라가지 않고 `org_context_http_302`로 거부하는 검사도 통과했다.
|
|
|
|
수정 후 전체 테스트는 77개 통과했다. 키·테넌트 설정 변경 없이 요청 옵션을 수정했으며, 실제 사용자에게 발급된 키로 원본 조직 데이터를 조회하거나 운영 배포를 수행한 것은 아니다. 사용자가 push하고 스테이징 배포가 성공한 뒤 실제 `/auth/session`에서 `resolved`와 회사·부서를 확인해야 한다. 과거 잘못 저장된 글의 정정은 여전히 별도 작업이다.
|
|
|
|
## 조회 경로
|
|
|
|
| 경로 | 확인한 필드·범위 | Q&A 적용 시 주의점 |
|
|
| --- | --- | --- |
|
|
| OIDC `/oidc/userinfo` 및 ID token | RP scope·claim 정의와 동의에 따라 전달되는 사용자 claim | 회사·부서·상위 계층과 전체 소속이 항상 있다는 계약은 가이드에서 확인되지 않음 |
|
|
| `GET /api/v1/user/me` | 공개 OpenAPI의 `UserProfileResponse`에 `id`, `department`, `tenant`, `joinedTenants[]`가 있음 | 본인 프로필·가입 테넌트 목록 확인에 사용. `TenantResponse`에는 `id`, `name`, `type`, `parentId`가 있지만 `ancestors`는 정의되어 있지 않음 |
|
|
| `GET /api/v1/integrations/org-context` | 허용된 subtree의 `scope`, `tree`, `tenants[]`, 직접 소속된 `members[]` | 여러 허용 subtree를 조회하고 동일 사용자 UUID의 소속을 모으면 해당 조회 범위 안의 복수 소속을 찾을 수 있음 |
|
|
|
|
`joinedTenants[]`는 가입 테넌트 목록을 받는 필드다. 모든 상위 조직까지 자동으로 포함하는 배열인지, 실제 로그인 사용자 응답에서 어떤 목록을 반환하는지는 별도 확인이 필요하다. 현재 Q&A는 `/user/me`를 호출하지 않고, 서버와 브라우저의 정규화 코드도 `joinedTenants`를 읽지 않는다.
|
|
|
|
`/user/me`는 사용자 인증 문맥으로 호출하는 API다. Integration Key를 이 경로의 인증 수단으로 바꾸지 않는다. 기존 `AUTH_USERINFO_URL`을 무조건 `/user/me`로 교체해서도 안 된다. 기존 OIDC `sub`와 프로필의 canonical `id`를 대조하며 보충 정보를 결합해야 한다.
|
|
|
|
조직 API 호출은 다음 계약을 사용한다.
|
|
|
|
```http
|
|
GET https://sadmin.hmac.kr/api/v1/integrations/org-context?tenantId=<허용된-조회기준-UUID>&exposure=identifiers
|
|
X-Baron-Key-ID: <서버에 등록된 키 ID>
|
|
X-Baron-Key-Secret: <서버에 등록된 시크릿>
|
|
Accept: application/json
|
|
```
|
|
|
|
- OperationID: `baron.org-context.read`.
|
|
- `tenantId`, `tenantSlug` 중 정확히 하나를 지정한다. 조회 기준을 생략한 전체 조직 조회 계약은 없다.
|
|
- credential이 허용한 subtree 및 exposure 범위만 조회할 수 있다. 여러 조직에 겸직 중이어도 조회 범위 밖의 소속까지 확인된 것으로 처리하지 않는다.
|
|
- `schemaVersion` 예시는 `baron.org-context.v2`다.
|
|
- `members[].id`는 canonical 사용자 UUID이며 `members[]`는 해당 테넌트의 직접 소속 사용자 목록이다.
|
|
- `isPrimary`는 대표 소속 선택의 근거다. 복수 소속을 수집한 뒤 현재 테넌트 문맥과 대표 소속을 함께 판단한다.
|
|
- `members` exposure에도 canonical UUID가 포함된다. `identifiers`에는 추가로 전화번호가 포함되므로, 전화번호도 사용하는 현재 구현에서 `identifiers` 요청 자체가 잘못된 것은 아니다.
|
|
- 구성원의 회사 이메일이나 이름으로 사용자 ID를 대신 맞추지 않는다.
|
|
|
|
## 현재 코드에서 재현된 매핑 오류
|
|
|
|
`src/org-context.js`의 `buildOrgDirectory()`는 모든 조회 기준 UUID를 분류 루트로 취급하고, 그 바로 아래 노드를 회사로 선택한다. `src/index.js`의 로그인 claim 정규화에도 같은 가정이 있다.
|
|
|
|
조회 범위를 회사 자체로 설정하는 것은 API 계약상 가능한 요청이다. 하지만 아래처럼 회사에서 시작하는 응답을 현재 코드에 전달하면 회사가 한 단계 밀린다.
|
|
|
|
```text
|
|
조회 기준: 총괄기획실&기술개발센터
|
|
└─ IS3
|
|
└─ 해당 사용자 UUID의 membership
|
|
|
|
기대: 회사=총괄기획실&기술개발센터, 부서=IS3
|
|
현재 코드의 재현 결과: 회사=IS3, 부서="", 상태=resolved
|
|
```
|
|
|
|
이 결과는 합성 UUID를 사용한 로컬 재현이며, 회사 기준 조회에 존재하는 코드 결함이다. 사용자 확인에 따르면 실제 조회 기준은 최상위 분류 조직이므로 **이 사례를 현재 스테이징 문제의 원인으로 확정할 수 없다**.
|
|
|
|
반대로 `한맥가족 → 총괄기획실&기술개발센터 → IS3 → 사용자 membership` 계층을 현재 코드에 전달하면 회사와 부서가 기대대로 매핑된다. 이 경우도 로컬 합성 데이터로 확인했다. 실제 화면의 원인을 구분하려면 다음을 대조해야 한다.
|
|
|
|
| 확인 지점 | 대조할 값 | 구분할 문제 |
|
|
| --- | --- | --- |
|
|
| Q&A 로그인 후 `/auth/session` | `user.orgContextStatus`, `company`, `department`, `companyTenantId`, `departmentTenantId`, `departmentPath` | 조직 조회 실패·사용자 연결 실패와 잘못된 조직 선택을 구분 |
|
|
| 조직 API의 해당 사용자 소속 | 조회 기준부터 membership 노드까지의 `id`, `name`, `parentId`, `type`, `orgUnitType`, `isPrimary` | 실제 계층과 대표 소속을 확인. 회사가 IS3의 상위 노드로 제공되는지 확인 |
|
|
| 해당 글의 API 응답 | `requester_affiliation`, `requester_department`, `tenant_name` | 현재 프로필은 정상이나 과거 저장 값 또는 표시 fallback이 잘못됐는지 구분 |
|
|
|
|
전체 디렉터리, 키, 시크릿, 쿠키, access token을 공유할 필요는 없다. 서버 측 진단은 해당 사용자 UUID에 연결된 소속 경로와 정규화 결과만 대상으로 한다.
|
|
|
|
추가로 확인한 코드 경로:
|
|
|
|
- `normalizeClaims()`는 `tenants`와 `tenantIds`를 읽지만 `joinedTenants`는 읽지 않는다. 첫 번째 테넌트 선택도 대표 소속 보장을 대신할 수 없다.
|
|
- 로그인 claim의 `primaryTenant.ancestors`를 사용하지만 `/user/me`의 `TenantResponse` 계약에는 해당 필드가 없다. 전체 계층이 자동으로 들어온다고 기대할 수 없다.
|
|
- `egbim/app.js`의 `normalizeRemotePost()`는 회사 필드가 없으면 `tenant_name`까지 회사 후보로 사용한다. 그 값이 부서 이름이라면 `IS3`가 회사로 표시될 수 있다.
|
|
- 신규 글 저장 시 `requester_affiliation`과 `requester_department`에 서버가 계산한 값을 저장한다. 이미 잘못 저장된 글은 로그인 사용자 프로필만 고쳐도 자동으로 바로잡히지 않는다.
|
|
|
|
## 필요한 후속 작업
|
|
|
|
1. 사용자가 확인한 최상위 분류 조직 기준을 유지한다. 확인된 Workers 미지원 요청 옵션의 수정을 배포하고 실제 `/auth/session`의 상태·회사·부서를 확인한다. 이후에도 잘못된 값이 있으면 해당 사용자의 canonical UUID 및 회사·IS3 UUID를 원본 조직 응답에서 대조한다.
|
|
2. 조회 범위와 회사 판별 기준을 구분한다. API의 `type`, `orgUnitType`, `parentId`와 실제 계층을 확인하고, 의미를 구분할 수 없는 동일 타입 계층은 별도의 명시적 회사 기준을 사용한다. 조회 기준이라는 이유만으로 그 노드를 회사 후보에서 제외하지 않는다.
|
|
3. 모든 허용 subtree의 membership을 사용자 UUID로 모으고, 현재 인증 테넌트 문맥과 `isPrimary`로 소속을 선택한다. `/user/me`의 `joinedTenants`를 도입할 경우 기존 OIDC 식별자를 유지하면서 보충 정보로 결합한다.
|
|
4. 신규 저장에서 회사 `총괄기획실&기술개발센터`, 부서 `IS3`가 각각 올바른 필드로 나가는지 확인한다. 회사 기준 조회·분류 루트 조회·부서 기준 조회·겸직을 구분해 검증한다.
|
|
5. 기존 글 중 잘못 저장된 항목의 정정 범위를 별도로 확인한다. 현재 조회자의 조직을 다른 작성자의 글에 덮어쓰거나 과거 전보 이력을 현재 소속으로 일괄 대체하지 않는다.
|
|
|
|
초기 확인에서는 조사만 진행했고, 후속 확인에서 안전한 오류 코드 진단과 Workers 요청 옵션 수정을 진행했다. 조직 소속과 기존 글 데이터는 변경하지 않았다. 실제 사용자 응답은 사용자가 제공한 `/auth/session` 결과만 확인했고, 사용자 token이나 Integration Credential로 직접 원본 조직 API를 조회하지 않았다.
|
|
|
|
## 확인 자료
|
|
|
|
- [Org Context API Guide](sso_guides/org-context-api.md)
|
|
- [조직선택기 API 연동 계약](sso_guides/org-picker-integration.md)
|
|
- [Directory API Guide](sso_guides/directory-api.md)
|
|
- [RP Scope·Claims 설정](sso_guides/rp-integration.md)
|
|
- [배포 서버 OpenAPI](https://sadmin.hmac.kr/api/openapi.yaml): `UserProfileResponse`, `TenantResponse`, `OrgContextResponse`, `OrgContextTenant`, `OrgContextMember` 및 각 경로. 2026-10-30 직접 조회.
|