// 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 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; 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, rootIds = []) { const tenants = new Map(); const memberships = new Map(); const roots = new Set((rootIds.length ? rootIds : payloads.map((payload) => payload?.scope?.tenantId)).map(text).filter(Boolean).map((id) => id.toLowerCase())); 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); } // The configured roots are classification/group tenants. Their direct child // is the real company, even when every descendant has generic COMPANY type. const rootIndex = chain.findIndex((item) => roots.has(item.id) || ['ROOT', 'COMPANY_GROUP'].includes(text(item.type).toUpperCase())); const company = rootIndex >= 0 ? chain[rootIndex + 1] : chain.find(isCompany); const departmentChain = company ? chain.slice(chain.indexOf(company) + 1) : []; const department = departmentChain.at(-1); const memberDepartment = text(appointment.member.department); const departmentName = text(department?.name) || (memberDepartment !== text(company?.name) && !chain.some((item) => roots.has(item.id) && text(item.name) === memberDepartment) ? memberDepartment : ''); 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: departmentChain.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, timeoutMs = 5000 } = {}) { const config = orgContextConfig(env); if (!config) return null; if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 60000) throw new Error('org_context_invalid_timeout'); // 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(), timeoutMs); 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, // Workers supports manual/follow only. Reject 3xx below without forwarding credentials. redirect: 'manual' }); 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 (controller.signal.aborted) throw new Error('org_context_timeout'); if (/^org_context_/.test(error?.message || '')) throw error; throw new Error('org_context_request_failed'); } finally { clearTimeout(timeout); } } })); const directory = buildOrgDirectory(responses, config.roots); 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; const profile = { ...user }; delete profile.orgContextError; try { const directory = await getOrgDirectory(env, options); const organization = resolveUserOrganization(directory, user); if (organization.status !== 'resolved') return { ...profile, orgContextStatus: organization.status }; // Authentication/ownership identifiers and roles are deliberately preserved. return { ...profile, 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. const code = safeErrorCode(error); console.error(code); return { ...profile, orgContextStatus: 'unavailable', orgContextError: code }; } }