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.
This commit is contained in:
1 parent
7c09c89da5
commit
bed970a175
3 files changed
+46
-1
No files matched your search
@@ -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`만으로 조직 조회 성공을 판단하지 않습니다.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in new issue
Block a user