fix: expose safe organization lookup error codes in session responses
This commit is contained in:
1 parent
250221aecf
commit
b7120d448c
4 files changed
+65
-5
No files matched your search
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user