diff --git a/README.md b/README.md index 9202b36..3a0e509 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런 Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 컨텍스트·대표 소속을 선택하고, 기존 requester ID·tenant ID·권한은 유지합니다. 새 문의는 `requester_affiliation`에 회사, `requester_department`에 부서를 저장합니다. JSON과 첨부 multipart에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다. -조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. +조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 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/src/org-context.js b/src/org-context.js index e9c3103..6eca86d 100644 --- a/src/org-context.js +++ b/src/org-context.js @@ -6,6 +6,20 @@ const text = (value) => typeof value === 'string' ? value.trim() : ''; const truthy = (value) => value === true || value === 1 || value === '1' || value === 'true'; const kind = (node) => text(node.orgUnitType || node.type).toUpperCase(); const isCompany = (node) => kind(node) === 'COMPANY' || text(node.type).toUpperCase() === 'COMPANY'; +const safeErrors = new Set([ + 'org_context_missing_credentials', 'org_context_invalid_endpoint', 'org_context_invalid_mode', + 'org_context_invalid_exposure', 'org_context_invalid_tenants', 'org_context_invalid_ttl', + 'org_context_invalid_timeout', 'org_context_invalid_tenant', 'org_context_invalid_members', + 'org_context_invalid_member', 'org_context_invalid_children', 'org_context_invalid_response', + 'org_context_conflicting_tenant', 'org_context_conflicting_membership', 'org_context_cycle', + 'org_context_scope_mismatch', 'org_context_members_missing', 'org_context_timeout', + 'org_context_request_failed' +]); + +function safeErrorCode(error) { + const code = error?.message; + return safeErrors.has(code) || /^org_context_http_[1-5]\d{2}$/.test(code || '') ? code : 'org_context_unavailable'; +} export function orgContextConfig(env) { if (!truthy(env.ORG_CONTEXT_ENABLED)) return null; @@ -171,13 +185,15 @@ export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now, export async function enrichUserOrganization(user, env, options) { if (!user || !truthy(env.ORG_CONTEXT_ENABLED)) return user; + const profile = { ...user }; + delete profile.orgContextError; try { const directory = await getOrgDirectory(env, options); const organization = resolveUserOrganization(directory, user); - if (organization.status !== 'resolved') return { ...user, orgContextStatus: organization.status }; + if (organization.status !== 'resolved') return { ...profile, orgContextStatus: organization.status }; // Authentication/ownership identifiers and roles are deliberately preserved. return { - ...user, + ...profile, company: organization.company || user.company || '', companySource: organization.company ? 'org-context' : user.companySource, department: organization.department || user.department || '', @@ -191,7 +207,8 @@ export async function enrichUserOrganization(user, env, options) { }; } catch (error) { // Do not log an upstream body, request headers, or a directory containing personal data. - console.error(/^org_context_[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'org_context_unavailable'); - return { ...user, orgContextStatus: 'unavailable' }; + const code = safeErrorCode(error); + console.error(code); + return { ...profile, orgContextStatus: 'unavailable', orgContextError: code }; } } diff --git a/tests/org-context.test.js b/tests/org-context.test.js index cb662c0..199e5dd 100644 --- a/tests/org-context.test.js +++ b/tests/org-context.test.js @@ -180,11 +180,35 @@ test('upstream failure preserves existing profile and emits only a safe error co try { const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => { throw new Error('Secret header: private-contact'); } }); assert.equal(result.orgContextStatus, 'unavailable'); + assert.equal(result.orgContextError, 'org_context_request_failed'); assert.equal(result.company, user.company); assert.deepEqual(logs, [['org_context_request_failed']]); } finally { console.error = saved; } }); +test('error diagnostics reject arbitrary upstream messages even when prefixed with org_context', async () => { + const original = console.error; + const logs = []; + console.error = (...args) => logs.push(args); + try { + const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => { + throw new Error('org_context_private_header_fixture_secret'); + } }); + assert.equal(result.orgContextStatus, 'unavailable'); + assert.equal(result.orgContextError, 'org_context_unavailable'); + assert.deepEqual(logs, [['org_context_unavailable']]); + assert.ok(!JSON.stringify(result).includes('fixture_secret')); + } finally { console.error = original; } +}); + +test('successful organization resolution clears previous failure diagnostics', async () => { + const result = await enrichUserOrganization({ ...user, orgContextStatus: 'unavailable', orgContextError: 'org_context_timeout' }, env(), { fetchImpl: async () => response(payload()) }); + assert.equal(result.orgContextStatus, 'resolved'); + assert.equal(result.company, '실제 회사'); + assert.equal(result.department, '설계부'); + assert.equal(result.orgContextError, undefined); +}); + test('a failing root is not accepted as a partial complete directory', async () => { const config = env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, OTHER]) }); await assert.rejects(getOrgDirectory(config, { fetchImpl: async (url) => { diff --git a/tests/worker-organization.test.js b/tests/worker-organization.test.js index bb72bc8..04cad03 100644 --- a/tests/worker-organization.test.js +++ b/tests/worker-organization.test.js @@ -100,6 +100,25 @@ test('organization service failure does not break an existing authenticated sess assert.equal(body.authenticated, true); assert.equal(body.user.requesterId, USER); assert.equal(body.user.orgContextStatus, 'unavailable'); + assert.equal(body.user.orgContextError, 'org_context_http_503'); + }); + } finally { console.error = original; } +}); +test('session exposes only a safe configuration error when runtime organization credentials are missing', async () => { + const env = configuration({ ORG_CONTEXT_KEY_SECRET: '' }); + const original = console.error; + console.error = () => {}; + try { + await mockedFetch(() => assert.fail('configuration error must precede network access'), async () => { + const response = await worker.fetch(new Request('https://qa.example.test/auth/session', { headers: { cookie: sessionCookie(env) } }), env); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.authenticated, true); + assert.equal(body.user.requesterId, USER); + assert.equal(body.user.orgContextStatus, 'unavailable'); + assert.equal(body.user.orgContextError, 'org_context_missing_credentials'); + assert.ok(!JSON.stringify(body).includes('integration-key')); + assert.ok(!JSON.stringify(body).includes('signed-session-fixture')); }); } finally { console.error = original; } });