import { readJson } from './catalog.mjs' const LAUNCHER_ROOT_GROUPS = new Set(['nodedc:superadmin', 'nodedc:launcher:admin']) const LAUNCHER_ROOT_ROLES = new Set(['root_admin']) const LAUNCHER_CLIENT_ADMIN_ROLES = new Set(['client_owner', 'client_admin']) export async function loadAssistantAccessPolicy() { return readJson('catalog/assistant-access-policy.json') } function normalizeString(value) { return String(value || '').trim() } function normalizeRole(value, policy) { const raw = normalizeString(value).toLowerCase() if (!raw) return '' const roleIds = new Set(policy.roleVocabulary.map((role) => role.id)) if (roleIds.has(raw)) return raw const alias = policy.roleAliases.find((candidate) => candidate.alias.toLowerCase() === raw) return alias?.roleId || raw } function toStringSet(value) { if (!Array.isArray(value)) return new Set() return new Set(value.map((item) => String(item))) } function resolveLauncherAdminScope(input) { const groups = toStringSet(input.groups) const launcherGlobalRole = normalizeString(input.launcherGlobalRole) const membershipRole = normalizeString(input.membershipRole) const membershipStatus = normalizeString(input.membershipStatus || 'active') const isRoot = Boolean(input.isRoot || input.isSuperAdmin || LAUNCHER_ROOT_ROLES.has(launcherGlobalRole) || [...groups].some((group) => LAUNCHER_ROOT_GROUPS.has(group))) const isClientAdmin = membershipStatus === 'active' && LAUNCHER_CLIENT_ADMIN_ROLES.has(membershipRole) return { isRoot, isClientAdmin, present: isRoot || isClientAdmin, reason: isRoot ? 'launcher_root_scope' : isClientAdmin ? 'launcher_client_admin_scope' : 'no_launcher_admin_scope', } } function uniqueSorted(values) { return [...new Set(values)].sort() } export async function resolveAssistantAccess(input, options = {}) { const policy = options.policy || await loadAssistantAccessPolicy() const launcherUserStatus = normalizeString(input.launcherUserStatus || input.globalStatus || 'active') const membershipStatus = normalizeString(input.membershipStatus || 'active') const adminScope = resolveLauncherAdminScope(input) let requestedRole = normalizeRole(input.assistantRole, policy) if (!requestedRole) { requestedRole = adminScope.isRoot ? policy.defaultResolution.superAdminDefaultRole : policy.defaultResolution.activeUserDefaultRole } const forcedBlocked = launcherUserStatus === 'blocked' || membershipStatus === 'disabled' const effectiveRole = forcedBlocked ? policy.defaultResolution.blockedUserEffectiveRole : requestedRole const matrix = policy.roleMatrix.find((entry) => entry.roleId === effectiveRole) if (!matrix) { throw new Error(`Unknown assistant role: ${effectiveRole}`) } const allCapabilities = policy.capabilities.map((capability) => capability.id) let allowedCapabilities = matrix.deniedCapabilities?.includes('*') ? [] : [...(matrix.allowedCapabilities || [])] const deniedCapabilities = new Set(matrix.deniedCapabilities?.includes('*') ? allCapabilities : matrix.deniedCapabilities || []) const limitations = [] for (const conditional of matrix.conditionalCapabilities || []) { if (conditional.requires === 'launcher_admin_scope.root' && adminScope.isRoot) { allowedCapabilities.push(conditional.capabilityId) deniedCapabilities.delete(conditional.capabilityId) } else { deniedCapabilities.add(conditional.capabilityId) limitations.push({ capabilityId: conditional.capabilityId, reason: conditional.requires, }) } } if (effectiveRole === 'assistant_admin' && !adminScope.present) { for (const capabilityId of matrix.deniedWhenNoAdminScope || []) { deniedCapabilities.add(capabilityId) allowedCapabilities = allowedCapabilities.filter((candidate) => candidate !== capabilityId) } limitations.push({ reason: 'assistant_admin_requires_existing_launcher_admin_scope', }) } if (forcedBlocked) { allowedCapabilities = [] for (const capabilityId of allCapabilities) { deniedCapabilities.add(capabilityId) } limitations.push({ reason: launcherUserStatus === 'blocked' ? 'launcher_user_blocked' : 'membership_disabled', }) } allowedCapabilities = uniqueSorted(allowedCapabilities.filter((capabilityId) => !deniedCapabilities.has(capabilityId))) return { ok: true, requestedRole, effectiveRole, visible: Boolean(matrix.visible && !forcedBlocked), canUse: Boolean(matrix.canUse && !forcedBlocked), launcherUserStatus, membershipStatus, adminScope, allowedCapabilities, deniedCapabilities: uniqueSorted([...deniedCapabilities]), limitations, hardDenies: policy.hardDenies.map((deny) => deny.id), } } async function readInputJson() { const inputIndex = process.argv.indexOf('--input-json') if (inputIndex === -1) return null const inputPath = process.argv[inputIndex + 1] if (!inputPath) { throw new Error('--input-json requires a path') } return readJson(inputPath) } function assertSmoke(condition, message) { if (!condition) { throw new Error(message) } } async function runSmoke() { const policy = await loadAssistantAccessPolicy() const member = await resolveAssistantAccess({ assistantRole: 'member', launcherUserStatus: 'active', membershipRole: 'member', }, { policy }) const clientAdmin = await resolveAssistantAccess({ assistantRole: 'admin', launcherUserStatus: 'active', membershipRole: 'client_admin', membershipStatus: 'active', }, { policy }) const blocked = await resolveAssistantAccess({ assistantRole: 'admin', launcherUserStatus: 'blocked', membershipRole: 'client_admin', }, { policy }) const superAdmin = await resolveAssistantAccess({ groups: ['nodedc:superadmin'], launcherUserStatus: 'active', }, { policy }) assertSmoke(member.visible, 'member assistant should be visible') assertSmoke(!member.allowedCapabilities.includes('assistant.manage_users'), 'member assistant must not manage users') assertSmoke(clientAdmin.allowedCapabilities.includes('assistant.manage_users'), 'client assistant admin should manage users inside Launcher scope') assertSmoke(!clientAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'client assistant admin must not get root service catalog capability') assertSmoke(!blocked.visible && !blocked.canUse, 'blocked assistant must be hidden and unusable') assertSmoke(superAdmin.effectiveRole === 'assistant_admin', 'superadmin should default to assistant_admin') assertSmoke(superAdmin.allowedCapabilities.includes('assistant.manage_service_catalog'), 'superadmin should receive root conditional capabilities') return { ok: true, cases: { member, clientAdmin, blocked, superAdmin }, } } if (import.meta.url === `file://${process.argv[1]}`) { if (process.argv.includes('--smoke')) { console.log(JSON.stringify(await runSmoke(), null, 2)) } else { const input = await readInputJson() if (!input) { throw new Error('Use --smoke or --input-json ') } console.log(JSON.stringify(await resolveAssistantAccess(input), null, 2)) } }