This commit is contained in:
1 parent
e4843e7d1d
commit
1808f14cc6
17 files changed
+711
-23
No files matched your search
+13
-5
@@ -1,3 +1,5 @@
|
||||
import { enrichUserOrganization } from './org-context.js';
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -223,7 +225,8 @@ function normalizeClaims(claims) {
|
||||
const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {};
|
||||
const tenantName = claims.tenant_name || claims.tenantName || custom.tenant_name || custom.tenantName || (typeof primaryTenant === 'object' ? (primaryTenant.name || primaryTenant.tenantName || primaryTenant.displayName || primaryTenant.label || '') : '');
|
||||
const email = claims.email || profile.email || '';
|
||||
const company = companyNameFromEmail(email) || custom.company || claims.company || claims.organization || profile.company || '';
|
||||
const explicitCompany = custom.company || claims.company || claims.organization || profile.company || claims.affiliation || '';
|
||||
const company = explicitCompany || companyNameFromEmail(email);
|
||||
const subject = claims.sub || claims.subject || claims.userId || claims.user_id || '';
|
||||
return {
|
||||
userUuid: claims.userId || claims.user_id || subject,
|
||||
@@ -240,8 +243,9 @@ function normalizeClaims(claims) {
|
||||
phone: claims.phone || claims.phone_number || claims.phoneNumber || claims.mobile || claims.mobilePhone || customPhone || profilePhone,
|
||||
tenantName,
|
||||
company,
|
||||
companySource: explicitCompany ? 'sso' : 'email',
|
||||
familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '',
|
||||
department: custom.team || custom.department || claims.department || primaryTenant.name || '',
|
||||
department: custom.team || custom.department || claims.department || profile.department || (!['COMPANY', 'COMPANY_GROUP', 'ROOT'].includes(String(primaryTenant.type || primaryTenant.orgUnitType || '').toUpperCase()) ? primaryTenant.name : '') || '',
|
||||
position: custom.position || claims.position || profile.position || primaryTenant.position || primaryTenant.title || '',
|
||||
grade: custom.grade || claims.grade || profile.grade || primaryTenant.grade || ''
|
||||
};
|
||||
@@ -323,14 +327,14 @@ async function getSession(request, env) {
|
||||
}
|
||||
|
||||
async function sessionResponse(request, env) {
|
||||
const user = await getSession(request, env);
|
||||
const user = await enrichUserOrganization(await getSession(request, env), env);
|
||||
return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' });
|
||||
}
|
||||
|
||||
async function createFeedback(request, env) {
|
||||
if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' });
|
||||
const user = await getSession(request, env);
|
||||
if (!user) return json({ error: 'unauthenticated' }, 401);
|
||||
const sessionUser = await getSession(request, env);
|
||||
if (!sessionUser) return json({ error: 'unauthenticated' }, 401);
|
||||
if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503);
|
||||
if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503);
|
||||
|
||||
@@ -362,6 +366,7 @@ async function createFeedback(request, env) {
|
||||
if (!title || !contents || !category) return json({ error: 'title_contents_category_required' }, 400);
|
||||
if (!['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].includes(category)) return json({ error: 'invalid_category' }, 400);
|
||||
|
||||
const user = await enrichUserOrganization(sessionUser, env);
|
||||
const feedbackId = isUuidv7(input.feedbackId) ? input.feedbackId : uuidv7();
|
||||
const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || '');
|
||||
const requesterTenantId = String(user.requesterTenantId || user.tenantId || '');
|
||||
@@ -380,6 +385,9 @@ async function createFeedback(request, env) {
|
||||
requester_email: String(user.email || ''),
|
||||
requester_name: String(user.name || ''),
|
||||
requester_department: String(user.department || ''),
|
||||
requester_affiliation: String(user.companySource === 'email' ? '' : (user.company || user.familyCompany || '')),
|
||||
requester_position: String(user.position || ''),
|
||||
requester_grade: String(user.grade || ''),
|
||||
requester_phone_number: requesterPhone,
|
||||
is_secret: input.is_secret === true || input.is_secret === 'true' || input.is_secret === 1 || input.is_secret === '1' ? 'true' : 'false'
|
||||
};
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
// Server-only organization lookup. Never return the directory or credentials to a browser.
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const cache = new Map();
|
||||
const pending = new Map();
|
||||
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 isDepartment = (node) => ['DEPARTMENT', 'DIVISION', 'TEAM', 'ORG_UNIT', 'BUSINESS_UNIT'].includes(kind(node)) || text(node.type).toUpperCase() === 'DEPARTMENT';
|
||||
|
||||
export function orgContextConfig(env) {
|
||||
if (!truthy(env.ORG_CONTEXT_ENABLED)) return null;
|
||||
const endpoint = new URL(env.ORG_CONTEXT_API_URL || 'https://sadmin.hmac.kr/api/v1/integrations/org-context');
|
||||
if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) {
|
||||
throw new Error('org_context_invalid_endpoint');
|
||||
}
|
||||
if ((env.ORG_CONTEXT_SCOPE_MODE || 'SCOPED') !== 'SCOPED') throw new Error('org_context_invalid_mode');
|
||||
if ((env.ORG_CONTEXT_EXPOSURE || 'identifiers') !== 'identifiers') throw new Error('org_context_invalid_exposure');
|
||||
let roots;
|
||||
try { roots = JSON.parse(env.ORG_CONTEXT_TENANT_IDS || '[]'); } catch { throw new Error('org_context_invalid_tenants'); }
|
||||
if (!Array.isArray(roots) || !roots.length || roots.length > 50 || roots.some((id) => typeof id !== 'string' || !UUID.test(id))) {
|
||||
throw new Error('org_context_invalid_tenants');
|
||||
}
|
||||
roots = [...new Set(roots.map((id) => id.toLowerCase()))].sort();
|
||||
const keyId = text(env.ORG_CONTEXT_KEY_ID);
|
||||
const keySecret = text(env.ORG_CONTEXT_KEY_SECRET);
|
||||
if (!keyId || !keySecret) throw new Error('org_context_missing_credentials');
|
||||
const ttl = Number(env.ORG_CONTEXT_CACHE_TTL_SECONDS || 300);
|
||||
if (!Number.isFinite(ttl) || ttl < 1 || ttl > 3600) throw new Error('org_context_invalid_ttl');
|
||||
return { endpoint: endpoint.toString(), roots, keyId, keySecret, ttl: ttl * 1000 };
|
||||
}
|
||||
|
||||
export function buildOrgDirectory(payloads) {
|
||||
const tenants = new Map();
|
||||
const memberships = new Map();
|
||||
function visit(node, parentId) {
|
||||
if (!node || typeof node !== 'object' || !text(node.id)) throw new Error('org_context_invalid_tenant');
|
||||
const id = text(node.id).toLowerCase();
|
||||
const parent = text(node.parentId || parentId).toLowerCase();
|
||||
const existing = tenants.get(id);
|
||||
const normalized = { ...node, id, parentId: parent, members: undefined, children: undefined };
|
||||
if (existing && (text(existing.name) !== text(node.name) || (existing.parentId && parent && existing.parentId !== parent))) {
|
||||
throw new Error('org_context_conflicting_tenant');
|
||||
}
|
||||
tenants.set(id, { ...existing, ...normalized, parentId: parent || existing?.parentId || '' });
|
||||
if (node.members !== undefined && !Array.isArray(node.members)) throw new Error('org_context_invalid_members');
|
||||
for (const member of node.members || []) {
|
||||
if (!member || !UUID.test(text(member.id))) throw new Error('org_context_invalid_member');
|
||||
const userId = text(member.id).toLowerCase();
|
||||
const key = userId + ':' + id;
|
||||
const previous = memberships.get(key);
|
||||
// Conflicting copies must not silently select the last root's version.
|
||||
if (previous && ['isPrimary', 'department', 'phone'].some((field) =>
|
||||
previous.member[field] !== undefined && member[field] !== undefined && String(previous.member[field]) !== String(member[field]))) {
|
||||
throw new Error('org_context_conflicting_membership');
|
||||
}
|
||||
memberships.set(key, { tenantId: id, member: { ...previous?.member, ...member, id: userId } });
|
||||
}
|
||||
if (node.children !== undefined && !Array.isArray(node.children)) throw new Error('org_context_invalid_children');
|
||||
for (const child of node.children || []) visit(child, id);
|
||||
}
|
||||
for (const payload of payloads) {
|
||||
if (!payload || typeof payload !== 'object' || (!payload.tree && !Array.isArray(payload.tenants))) {
|
||||
throw new Error('org_context_invalid_response');
|
||||
}
|
||||
if (payload.tree) visit(payload.tree, '');
|
||||
for (const node of payload.tenants || []) visit(node, '');
|
||||
}
|
||||
const byUser = new Map();
|
||||
for (const appointment of memberships.values()) {
|
||||
const chain = [];
|
||||
const seen = new Set();
|
||||
let node = tenants.get(appointment.tenantId);
|
||||
while (node) {
|
||||
if (seen.has(node.id)) throw new Error('org_context_cycle');
|
||||
seen.add(node.id);
|
||||
chain.unshift(node);
|
||||
node = tenants.get(node.parentId);
|
||||
}
|
||||
const company = [...chain].reverse().find(isCompany);
|
||||
const department = [...chain].reverse().find(isDepartment);
|
||||
const departmentName = text(department?.name) || text(appointment.member.department);
|
||||
const value = {
|
||||
userId: appointment.member.id,
|
||||
tenantId: appointment.tenantId,
|
||||
ancestorIds: chain.map((item) => item.id),
|
||||
companyTenantId: company?.id || '',
|
||||
company: text(company?.name),
|
||||
departmentTenantId: department?.id || '',
|
||||
department: departmentName,
|
||||
departmentPath: chain.filter(isDepartment).map((item) => text(item.name)).filter(Boolean).join(' / '),
|
||||
isPrimary: truthy(appointment.member.isPrimary),
|
||||
phone: text(appointment.member.phone),
|
||||
position: text(appointment.member.position || appointment.member.jobTitle),
|
||||
grade: text(appointment.member.grade)
|
||||
};
|
||||
const list = byUser.get(value.userId) || [];
|
||||
list.push(value);
|
||||
byUser.set(value.userId, list);
|
||||
}
|
||||
return { byUser, tenantCount: tenants.size, membershipCount: memberships.size };
|
||||
}
|
||||
|
||||
export function resolveUserOrganization(directory, user) {
|
||||
const identities = [...new Set([user.userUuid, user.ssoSubject, user.requesterId].map(text).filter(Boolean).map((id) => id.toLowerCase()))];
|
||||
const matched = identities.filter((id) => directory.byUser.has(id));
|
||||
if (matched.length !== 1) return { status: matched.length ? 'ambiguous_identity' : 'not_found' };
|
||||
const appointments = directory.byUser.get(matched[0]);
|
||||
const tenantId = text(user.requesterTenantId || user.tenantId).toLowerCase();
|
||||
const exact = appointments.filter((item) => item.tenantId === tenantId);
|
||||
const contextual = exact.length ? exact : appointments.filter((item) => item.ancestorIds.includes(tenantId));
|
||||
const candidates = contextual.length ? contextual : appointments;
|
||||
const primary = candidates.filter((item) => item.isPrimary);
|
||||
const selected = candidates.length === 1 ? candidates[0] : primary.length === 1 ? primary[0] : null;
|
||||
return selected ? { status: 'resolved', ...selected } : { status: 'ambiguous_membership' };
|
||||
}
|
||||
|
||||
export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now } = {}) {
|
||||
const config = orgContextConfig(env);
|
||||
if (!config) return null;
|
||||
// Include credential rotation without storing the secret in cache keys or diagnostic output.
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.keySecret)));
|
||||
const fingerprint = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
const cacheKey = JSON.stringify([config.endpoint, config.roots, config.keyId, fingerprint, config.ttl]);
|
||||
const previous = cache.get(cacheKey);
|
||||
if (previous && previous.expiresAt > now()) return previous.directory;
|
||||
if (pending.has(cacheKey)) return pending.get(cacheKey);
|
||||
const promise = (async () => {
|
||||
const responses = new Array(config.roots.length);
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: Math.min(3, config.roots.length) }, async () => {
|
||||
while (next < config.roots.length) {
|
||||
const index = next++;
|
||||
const url = new URL(config.endpoint);
|
||||
url.searchParams.set('tenantId', config.roots[index]);
|
||||
url.searchParams.set('exposure', 'identifiers');
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
headers: { accept: 'application/json', 'X-Baron-Key-ID': config.keyId, 'X-Baron-Key-Secret': config.keySecret },
|
||||
signal: controller.signal,
|
||||
redirect: 'error'
|
||||
});
|
||||
if (!response.ok) throw new Error('org_context_http_' + response.status);
|
||||
const payload = await response.json();
|
||||
if (text(payload?.scope?.tenantId).toLowerCase() !== config.roots[index]) throw new Error('org_context_scope_mismatch');
|
||||
responses[index] = payload;
|
||||
} catch (error) {
|
||||
if (/^org_context_/.test(error?.message || '')) throw error;
|
||||
throw new Error('org_context_request_failed');
|
||||
} finally { clearTimeout(timeout); }
|
||||
}
|
||||
}));
|
||||
const directory = buildOrgDirectory(responses);
|
||||
if (directory.byUser.size === 0) throw new Error('org_context_members_missing');
|
||||
if (cache.size >= 8) cache.delete(cache.keys().next().value);
|
||||
cache.set(cacheKey, { directory, expiresAt: now() + config.ttl });
|
||||
return directory;
|
||||
})();
|
||||
pending.set(cacheKey, promise);
|
||||
try { return await promise; } finally { pending.delete(cacheKey); }
|
||||
}
|
||||
|
||||
export async function enrichUserOrganization(user, env, options) {
|
||||
if (!user || !truthy(env.ORG_CONTEXT_ENABLED)) return user;
|
||||
try {
|
||||
const directory = await getOrgDirectory(env, options);
|
||||
const organization = resolveUserOrganization(directory, user);
|
||||
if (organization.status !== 'resolved') return { ...user, orgContextStatus: organization.status };
|
||||
// Authentication/ownership identifiers and roles are deliberately preserved.
|
||||
return {
|
||||
...user,
|
||||
company: organization.company || user.company || '',
|
||||
companySource: organization.company ? 'org-context' : user.companySource,
|
||||
department: organization.department || user.department || '',
|
||||
companyTenantId: organization.companyTenantId,
|
||||
departmentTenantId: organization.departmentTenantId,
|
||||
departmentPath: organization.departmentPath,
|
||||
phone: user.phone || organization.phone || '',
|
||||
position: organization.position || user.position || '',
|
||||
grade: organization.grade || user.grade || '',
|
||||
orgContextStatus: 'resolved'
|
||||
};
|
||||
} 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' };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user