From bed970a17577a9b755b7379dbd274728803cdb06 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 11 Nov 2026 11:35:36 +0900 Subject: [PATCH] fix(ci): identify denied organization roots in access checks Log the requested tenant, endpoint, operation and exposure for organization HTTP 401/403 responses. Keep credentials and upstream response bodies out of diagnostics and retain the deployment gate. Validation: source checks and 118 tests pass, including forbidden-root identification and diagnostic privacy coverage. --- README.md | 2 ++ scripts/verify-org-context.mjs | 15 ++++++++++++++- tests/org-verification.test.js | 30 ++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b07b61c..49aa527 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,8 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런 기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다. +배포 전 검증에서 HTTP 401/403이 발생하면 요청한 API 주소·기준 tenant UUID·OperationID `baron.org-context.read`·exposure `identifiers`를 함께 출력합니다. 키 원문과 API 응답 본문은 출력하지 않습니다. 403이면 발급 서버의 API 키 관리에서 이 작업·노출 단계·해당 tenant subtree 조회 범위를 확인합니다. 여러 기준 tenant 중 하나만 거부돼도 전체 검증은 실패하며, 권한 밖 조직을 임의로 제외하거나 검증을 건너뛰지 않습니다. + Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 컨텍스트·대표 소속을 선택하고, 기존 requester ID·tenant ID·권한은 유지합니다. 새 문의는 `requester_affiliation`에 회사, `requester_department`에 부서를 저장합니다. JSON과 첨부 multipart에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다. 조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. 로그인 후 `/auth/session`의 `user.orgContextStatus=unavailable`이면 `user.orgContextError`에서 허용된 오류 코드만 확인할 수 있습니다. `org_context_missing_credentials`는 Worker의 조직 API 키 누락, `org_context_http_401`은 API 인증 거부, `org_context_http_403`은 API 접근 거부, `org_context_timeout`은 요청 시간 초과를 뜻합니다. 알 수 없는 오류는 `org_context_unavailable`로 표시하며 키·원본 오류 메시지·조직 디렉터리는 응답에 넣지 않습니다. 조회가 정상화되면 이전 `orgContextError`는 제거됩니다. HTTP 200이나 요청 로그의 `outcome=ok`만으로 조직 조회 성공을 판단하지 않습니다. diff --git a/scripts/verify-org-context.mjs b/scripts/verify-org-context.mjs index 2f6398d..cdb49f1 100644 --- a/scripts/verify-org-context.mjs +++ b/scripts/verify-org-context.mjs @@ -9,10 +9,23 @@ export async function verifyOrganization(env = process.env, { fetchImpl = fetch, log('Organization integration disabled; existing behavior retained.'); return; } + const checkedFetch = async (url, options) => { + const response = await fetchImpl(url, options); + if (response.status === 401 || response.status === 403) { + const request = new URL(url); + log(JSON.stringify({ + status: 'organization_access_denied', httpStatus: response.status, + endpoint: request.origin + request.pathname, + tenantId: request.searchParams.get('tenantId'), + operation: 'baron.org-context.read', exposure: 'identifiers' + })); + } + return response; + }; for (let attempt = 1; attempt <= 3; attempt++) { try { // CI can wait for a slow upstream without extending the page request timeout. - const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl, timeoutMs: 15000 }); + const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl: checkedFetch, timeoutMs: 15000 }); const summary = { status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length, tenantCount: directory.tenantCount, userCount: directory.byUser.size, diff --git a/tests/org-verification.test.js b/tests/org-verification.test.js index 165d1c9..aa3d104 100644 --- a/tests/org-verification.test.js +++ b/tests/org-verification.test.js @@ -84,3 +84,33 @@ test('disabled verification makes no upstream requests', async () => { fetchImpl: async () => assert.fail('disabled lookup'), log() {} }); }); + +for (const status of [401, 403]) { + test(`HTTP ${status} diagnostics identify the requested tenant without exposing credentials or upstream data`, async () => { + const env = settings(); + const logs = []; + await assert.rejects(verifyOrganization(env, { + fetchImpl: async () => response({ error: 'private-upstream-message', name: 'private-user', phone: '+8201000000000' }, status), + log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial') + }), new RegExp('org_context_http_' + status)); + const diagnostic = JSON.parse(logs[0]); + assert.equal(diagnostic.httpStatus, status); + assert.equal(diagnostic.endpoint, 'https://sadmin.hmac.kr/api/v1/integrations/org-context'); + assert.equal(diagnostic.tenantId, ROOT); + assert.equal(diagnostic.operation, 'baron.org-context.read'); + assert.equal(diagnostic.exposure, 'identifiers'); + for (const hidden of [env.ORG_CONTEXT_KEY_ID, env.ORG_CONTEXT_KEY_SECRET, 'private-upstream-message', 'private-user', '+8201000000000']) { + assert.ok(!JSON.stringify(logs).includes(hidden)); + } + }); +} + +test('a partial directory fails and diagnostics point to the forbidden root instead of a permitted root', async () => { + const deniedRoot = '9951229e-eea5-4376-96f4-78989bc3a506'; + const logs = []; + await assert.rejects(verifyOrganization({ ...settings(), ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, deniedRoot]) }, { + fetchImpl: async url => new URL(url).searchParams.get('tenantId') === deniedRoot ? response({}, 403) : response(directory()), + log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial') + }), /org_context_http_403/); + assert.deepEqual(logs.map(line => JSON.parse(line).tenantId), [deniedRoot]); +});