fix: expose safe organization lookup error codes in session responses

This commit is contained in:
root committed 2026-10-30 12:35:49 +09:00
1 parent 250221aecf
commit b7120d448c
4 files changed
+65 -5

No files matched your search

+1 -1
View File
@@ -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`만으로 조직 조회 성공을 판단하지 않습니다.
로컬 검증:
+21 -4
View File
@@ -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 };
}
}
+24
View File
@@ -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) => {
+19
View File
@@ -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; }
});