diff --git a/README.md b/README.md index 49aa527..38abf5c 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런 배포 전 검증에서 HTTP 401/403이 발생하면 요청한 API 주소·기준 tenant UUID·OperationID `baron.org-context.read`·exposure `identifiers`를 함께 출력합니다. 키 원문과 API 응답 본문은 출력하지 않습니다. 403이면 발급 서버의 API 키 관리에서 이 작업·노출 단계·해당 tenant subtree 조회 범위를 확인합니다. 여러 기준 tenant 중 하나만 거부돼도 전체 검증은 실패하며, 권한 밖 조직을 임의로 제외하거나 검증을 건너뛰지 않습니다. +서버가 제공하는 오류 중 문서에 정의된 코드만 `upstreamCode`로 출력합니다. `credential_operation_mismatch`는 허용 작업, `credential_scope_exceeded`는 조직 범위, `credential_exposure_exceeded`는 노출 단계 거절입니다. 서버가 반환한 허용/요청 노출 단계도 정해진 enum에 해당할 때만 표시합니다. HTML 응답이나 Cloudflare challenge 여부는 `responseKind`, `proxyChallenge`로 구분합니다. 알 수 없는 코드·오류 메시지·HTML 원문은 출력하지 않습니다. + 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 cdb49f1..f464c7a 100644 --- a/scripts/verify-org-context.mjs +++ b/scripts/verify-org-context.mjs @@ -3,6 +3,32 @@ import { deploymentOrgSettings } from './org-context-settings.mjs'; import { setTimeout as wait } from 'node:timers/promises'; import { pathToFileURL } from 'node:url'; +// Only documented machine codes and enum values may be copied from an upstream error. +const accessErrorCodes = new Set([ + 'credential_operation_not_available', 'credential_operation_mismatch', + 'credential_scope_exceeded', 'credential_exposure_exceeded', + 'integration_credential_not_allowed', 'integration_credential_required', + 'invalid_integration_credential', 'integration_credential_expired', 'credential_conflict' +]); +const exposures = new Set(['none', 'structure', 'members', 'identifiers']); + +async function accessResponseDiagnostic(response) { + const type = response.headers.get('content-type') || ''; + const responseKind = /json/i.test(type) ? 'json' : /html/i.test(type) ? 'html' : 'other'; + let body; + if (responseKind === 'json') { + try { body = await response.clone().json(); } catch {} + } + const code = body?.code || body?.error; + return { + responseKind, + upstreamCode: accessErrorCodes.has(code) ? code : 'unrecognized', + grantedExposure: exposures.has(body?.details?.grantedExposure) ? body.details.grantedExposure : undefined, + requiredExposure: exposures.has(body?.details?.requiredExposure) ? body.details.requiredExposure : undefined, + proxyChallenge: response.headers.get('cf-mitigated') === 'challenge' + }; +} + export async function verifyOrganization(env = process.env, { fetchImpl = fetch, pause = wait, log = console.log } = {}) { const { vars, secrets } = deploymentOrgSettings(env); if (vars.ORG_CONTEXT_ENABLED !== 'true') { @@ -17,7 +43,8 @@ export async function verifyOrganization(env = process.env, { fetchImpl = fetch, status: 'organization_access_denied', httpStatus: response.status, endpoint: request.origin + request.pathname, tenantId: request.searchParams.get('tenantId'), - operation: 'baron.org-context.read', exposure: 'identifiers' + operation: 'baron.org-context.read', exposure: 'identifiers', + ...await accessResponseDiagnostic(response) })); } return response; diff --git a/tests/org-verification.test.js b/tests/org-verification.test.js index aa3d104..2da3e1c 100644 --- a/tests/org-verification.test.js +++ b/tests/org-verification.test.js @@ -114,3 +114,51 @@ test('a partial directory fails and diagnostics point to the forbidden root inst }), /org_context_http_403/); assert.deepEqual(logs.map(line => JSON.parse(line).tenantId), [deniedRoot]); }); + +for (const code of ['credential_operation_mismatch', 'credential_scope_exceeded', 'credential_exposure_exceeded']) { + test(`access diagnostics preserve the documented ${code} reason while excluding upstream messages`, async () => { + const logs = []; + await assert.rejects(verifyOrganization(settings(), { + fetchImpl: async () => response({ code, message: 'private-server-message', details: { + grantedExposure: 'members', requiredExposure: 'identifiers', secret: 'private-value' + } }, 403), + log: line => logs.push(line) + }), /org_context_http_403/); + const diagnostic = JSON.parse(logs[0]); + assert.equal(diagnostic.upstreamCode, code); + assert.equal(diagnostic.grantedExposure, 'members'); + assert.equal(diagnostic.requiredExposure, 'identifiers'); + assert.ok(!logs[0].includes('private-server-message')); + assert.ok(!logs[0].includes('private-value')); + }); +} + +test('unrecognized codes and invalid enum values cannot expose private upstream strings', async () => { + const logs = []; + await assert.rejects(verifyOrganization(settings(), { + fetchImpl: async () => response({ code: 'private-code', details: { + grantedExposure: 'private-granted', requiredExposure: 'private-required' + } }, 403), + log: line => logs.push(line) + }), /org_context_http_403/); + const diagnostic = JSON.parse(logs[0]); + assert.equal(diagnostic.upstreamCode, 'unrecognized'); + assert.equal(diagnostic.grantedExposure, undefined); + assert.equal(diagnostic.requiredExposure, undefined); + assert.ok(!logs[0].includes('private-')); +}); + +test('HTML proxy challenges are distinguishable without logging HTML or bypassing the failure', async () => { + const logs = []; + await assert.rejects(verifyOrganization(settings(), { + fetchImpl: async () => new Response('private-challenge-body', { + status: 403, headers: { 'content-type': 'text/html', 'cf-mitigated': 'challenge' } + }), + log: line => logs.push(line) + }), /org_context_http_403/); + const diagnostic = JSON.parse(logs[0]); + assert.equal(diagnostic.responseKind, 'html'); + assert.equal(diagnostic.proxyChallenge, true); + assert.equal(diagnostic.upstreamCode, 'unrecognized'); + assert.ok(!logs[0].includes('private-challenge-body')); +});