NODEDC_PLATFORM/services/ontology-core/src/assistant-policy.mjs

196 lines
7.1 KiB
JavaScript

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 <path>')
}
console.log(JSON.stringify(await resolveAssistantAccess(input), null, 2))
}
}