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에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다.
|
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 truthy = (value) => value === true || value === 1 || value === '1' || value === 'true';
|
||||||
const kind = (node) => text(node.orgUnitType || node.type).toUpperCase();
|
const kind = (node) => text(node.orgUnitType || node.type).toUpperCase();
|
||||||
const isCompany = (node) => kind(node) === 'COMPANY' || text(node.type).toUpperCase() === 'COMPANY';
|
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) {
|
export function orgContextConfig(env) {
|
||||||
if (!truthy(env.ORG_CONTEXT_ENABLED)) return null;
|
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) {
|
export async function enrichUserOrganization(user, env, options) {
|
||||||
if (!user || !truthy(env.ORG_CONTEXT_ENABLED)) return user;
|
if (!user || !truthy(env.ORG_CONTEXT_ENABLED)) return user;
|
||||||
|
const profile = { ...user };
|
||||||
|
delete profile.orgContextError;
|
||||||
try {
|
try {
|
||||||
const directory = await getOrgDirectory(env, options);
|
const directory = await getOrgDirectory(env, options);
|
||||||
const organization = resolveUserOrganization(directory, user);
|
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.
|
// Authentication/ownership identifiers and roles are deliberately preserved.
|
||||||
return {
|
return {
|
||||||
...user,
|
...profile,
|
||||||
company: organization.company || user.company || '',
|
company: organization.company || user.company || '',
|
||||||
companySource: organization.company ? 'org-context' : user.companySource,
|
companySource: organization.company ? 'org-context' : user.companySource,
|
||||||
department: organization.department || user.department || '',
|
department: organization.department || user.department || '',
|
||||||
@@ -191,7 +207,8 @@ export async function enrichUserOrganization(user, env, options) {
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Do not log an upstream body, request headers, or a directory containing personal data.
|
// 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');
|
const code = safeErrorCode(error);
|
||||||
return { ...user, orgContextStatus: 'unavailable' };
|
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 {
|
try {
|
||||||
const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => { throw new Error('Secret header: private-contact'); } });
|
const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => { throw new Error('Secret header: private-contact'); } });
|
||||||
assert.equal(result.orgContextStatus, 'unavailable');
|
assert.equal(result.orgContextStatus, 'unavailable');
|
||||||
|
assert.equal(result.orgContextError, 'org_context_request_failed');
|
||||||
assert.equal(result.company, user.company);
|
assert.equal(result.company, user.company);
|
||||||
assert.deepEqual(logs, [['org_context_request_failed']]);
|
assert.deepEqual(logs, [['org_context_request_failed']]);
|
||||||
} finally { console.error = saved; }
|
} 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 () => {
|
test('a failing root is not accepted as a partial complete directory', async () => {
|
||||||
const config = env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, OTHER]) });
|
const config = env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, OTHER]) });
|
||||||
await assert.rejects(getOrgDirectory(config, { fetchImpl: async (url) => {
|
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.authenticated, true);
|
||||||
assert.equal(body.user.requesterId, USER);
|
assert.equal(body.user.requesterId, USER);
|
||||||
assert.equal(body.user.orgContextStatus, 'unavailable');
|
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; }
|
} finally { console.error = original; }
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user