196 lines
7.1 KiB
JavaScript
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))
|
|
}
|
|
}
|