Compare commits

...

17 Commits

Author SHA1 Message Date
Simon Pacotte
7477db7f3b further fixs and improvements 2026-10-20 17:48:45 +02:00
Simon Pacotte
5ae2ad9ec7 fix event test 2026-10-20 09:47:46 +02:00
Simon Pacotte
e871c5e032 refix changelog 2026-10-20 09:44:02 +02:00
Simon Pacotte
8e1fc0c9dd fix changelog 2026-10-20 09:43:25 +02:00
Simon Pacotte
3a39e1f919 fix review MathieuRA 2 2026-10-20 09:08:40 +02:00
Simon Pacotte
258f365b56 fix review MathieuRA 2026-10-20 09:08:26 +02:00
Simon Pacotte
d8dd0554a8 removed [resolve:all] added [some]/[every] 2026-10-20 09:08:01 +02:00
Simon Pacotte
fc37ec557c rework getAnyObject 2026-10-20 09:08:00 +02:00
Simon Pacotte
3257d9d9a9 add back changelog 2026-10-20 09:07:41 +02:00
Simon Pacotte
bc8d7d4cdb improve nested field and task resolution 2026-10-20 09:07:17 +02:00
Simon Pacotte
a5c7df43b7 fix wrong type 2026-10-20 09:07:17 +02:00
Simon Pacotte
d49d74abc4 remove comment 2026-10-20 09:07:17 +02:00
Simon Pacotte
d5a3b6e16a improve deep resolution 2026-10-20 09:07:16 +02:00
Simon Pacotte
9ee440bb7c implement getAnyObject and improve complex-matcher to support non-xapi objects 2026-10-20 09:07:16 +02:00
Simon Pacotte
5c7c9d698f improve resolver implementation and added resolve:all 2026-10-20 09:07:16 +02:00
Simon Pacotte
1be405308a Add recursion test and update complex-matcher calls 2026-10-20 09:07:16 +02:00
Simon Pacotte
a3fda70595 feat(complex-matcher): implement deep resolution matcher 2026-10-20 09:07:16 +02:00
36 changed files with 1435 additions and 223 deletions

View File

@@ -34,7 +34,8 @@
"typescript": "~5.6.3",
"typescript-eslint": "^8.61.0",
"@types/express": "^5.0.0",
"@xen-orchestra/disk-transform": "^1.3.3"
"@xen-orchestra/disk-transform": "^1.3.3",
"complex-matcher": "^1.1.1"
},
"scripts": {
"build": "tsc",

View File

@@ -12,6 +12,7 @@ import type {
XoPool,
XoPoolBackupArchive,
XoProxy,
XoRecord,
XoSchedule,
XoServer,
XoTask,
@@ -23,6 +24,7 @@ import type {
import { VatesTask } from './lib/vates-task.mjs'
import type { PluginRestRouteDefinition } from './lib/rest-api.mjs'
import type { RPU_RECOVERY_STEP_NAME } from './common.mjs'
import type { Node as CmNode, Resolver } from 'complex-matcher'
import {
Xapi,
XapiHostStats,
@@ -475,6 +477,7 @@ export type XoApp = EventEmitter & {
diskId: string
hostId: XoHost['id']
}): Promise<BackupArchiveDiskMount>
getVmBackupArchive(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive>
pingRemote(id: XoBackupRepository['id']): Promise<{ success: true }>
/**
* Record the live mounts a proxy created by itself, while running a restore: they never went
@@ -579,6 +582,10 @@ export type XoApp = EventEmitter & {
): Promise<XoBackupRepository>
getAllXapis(): Record<string, Xapi>
getObjects(opts?: { filter?: Record<string, unknown>; limit?: number }): Record<string, XapiXoRecord>
getAnyObject<T extends XoRecord>(id: T['id']): Promise<T>
/** Can't be narrower as buildResolver will be asked to walk objects */
buildResolver(objects: object | object[], node: CmNode): Promise<Resolver>
xapiObjectResolver: Resolver
getLicenses(params?: { productType?: LicenseProductType }): Promise<License[]>
bindLicense(params: { licenseId: string; boundObjectId: string }): Promise<License>
unbindLicense(params: {

View File

@@ -14,21 +14,24 @@ export class Privilege<T extends SupportedResource> {
#resource: TPrivilege<T>['resource']
#effect: TPrivilege<T>['effect']
constructor({
action,
selector,
resource,
effect,
}: {
action: TPrivilege<T>['action']
selector?: TPrivilege<T>['selector']
resource: TPrivilege<T>['resource']
effect: TPrivilege<T>['effect']
}) {
constructor(
{
action,
selector,
resource,
effect,
}: {
action: TPrivilege<T>['action']
selector?: TPrivilege<T>['selector']
resource: TPrivilege<T>['resource']
effect: TPrivilege<T>['effect']
},
resolver?: CM.Resolver
) {
Privilege.checkActionIsValid(resource, action)
this.#action = action
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate() : undefined
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate(resolver) : undefined
this.#resource = resource
this.#effect = effect
}

View File

@@ -16,6 +16,7 @@ export { SUPPORTED_ACTIONS_BY_RESOURCE }
export type * from './generated/privilege-types.mjs'
import { AnyPrivilege } from './generated/privilege-types.mjs'
import type { Resolver } from 'complex-matcher'
export type AnyPrivilegeOnParam = {
[Resource in SupportedResource]: {
@@ -26,19 +27,22 @@ export type AnyPrivilegeOnParam = {
}
}[SupportedResource]
export function hasPrivilegeOn<T extends SupportedResource>({
user,
action,
resource,
objects,
userPrivileges,
}: {
user: XoUser
resource: T
action: SupportedActions<T>
objects: object | object[]
userPrivileges: AnyPrivilege[]
}) {
export function hasPrivilegeOn<T extends SupportedResource>(
{
user,
action,
resource,
objects,
userPrivileges,
}: {
user: XoUser
resource: T
action: SupportedActions<T>
objects: object | object[]
userPrivileges: AnyPrivilege[]
},
resolver?: Resolver
) {
// Function that will be called outside of the module
// We cannot be sure types are respected
if (typeof user?.permission !== 'string') {
@@ -71,7 +75,7 @@ export function hasPrivilegeOn<T extends SupportedResource>({
}
for (const userPrivilege of userPrivileges) {
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>)
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>, resolver)
for (const [object, effects] of effectsByObject.entries()) {
if (!privilege.match({ action, resource, object })) {
@@ -91,17 +95,24 @@ export function hasPrivilegeOn<T extends SupportedResource>({
return true
}
export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
export function getMissingPrivileges(
params: AnyPrivilegeOnParam[],
userPrivileges: AnyPrivilege[],
resolver?: Resolver
) {
return params
.filter(
param =>
!hasPrivilegeOn({
user: param.user,
resource: param.resource,
action: param.action as SupportedActions<typeof param.resource>,
objects: param.objects,
userPrivileges,
})
!hasPrivilegeOn(
{
user: param.user,
resource: param.resource,
action: param.action as SupportedActions<typeof param.resource>,
objects: param.objects,
userPrivileges,
},
resolver
)
)
.map(({ action, objects, resource }) => {
let objectIds: unknown[] | undefined
@@ -132,18 +143,21 @@ export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileg
})
}
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
return getMissingPrivileges(params, userPrivileges).length === 0
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[], resolver?: Resolver) {
return getMissingPrivileges(params, userPrivileges, resolver).length === 0
}
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(param: {
user: XoUser
resource: Resource
action: SupportedActions<Resource>
objects: Object[]
userPrivileges: AnyPrivilege[]
}) {
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }))
export function filterObjectsWithPrivilege<Resource extends SupportedResource, Object extends object>(
param: {
user: XoUser
resource: Resource
action: SupportedActions<Resource>
objects: Object[]
userPrivileges: AnyPrivilege[]
},
resolver?: Resolver
) {
return param.objects.filter(obj => hasPrivilegeOn({ ...param, objects: obj }, resolver))
}
// Utils

View File

@@ -91,6 +91,9 @@ suite('ACL V2 behavior', async () => {
const templateDeny = { id: 'template-deny-id', $pool: '2' }
const vdis = [{ $SR: '2' }, { $SR: '2' }]
const vdisMultipleSrs = [{ $SR: '1' }, { $SR: '2' }]
const vdisOnTaggedSr = [{ $SR: '4' }]
const vdisOnUntaggedSr = [{ $SR: '5' }]
const srs = { '4': { tags: ['tag'] } }
const vifs = [{ $network: '1' }]
const iso = { id: 'iso-vdi-id', $SR: '3' }
const hostAffinity = { id: 'affinity-host-id', $pool: '1' }
@@ -121,6 +124,14 @@ suite('ACL V2 behavior', async () => {
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const vdiCreateResolve = {
id: privilegeId,
action: 'create',
resource: 'vdi',
selector: '$SR:[resolve]:tags:tag',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const vifCreate = {
id: privilegeId,
action: 'create',
@@ -171,6 +182,11 @@ suite('ACL V2 behavior', async () => {
hostVm,
]
const resolvePrivileges = [vdiCreateResolve]
// === Resolvers
const srTagResolver = (id: string) => srs[id]
// === Test ===
suite('hasPrivilegeOn behavior', () => {
test('Should always return true for admin', () => {
@@ -258,6 +274,35 @@ suite('ACL V2 behavior', async () => {
false
)
})
test('should allow create on vdi whose SR has tag', () => {
assert.strictEqual(
hasPrivilegeOn(
{
user,
action: 'create',
resource: 'vdi',
objects: vdisOnTaggedSr,
userPrivileges: resolvePrivileges,
},
srTagResolver
),
true
)
assert.strictEqual(
hasPrivilegeOn(
{
user,
action: 'create',
resource: 'vdi',
objects: vdisOnUntaggedSr,
userPrivileges: resolvePrivileges,
},
srTagResolver
),
false
)
})
})
suite('getMissingPrivileges behavior', () => {

View File

@@ -57,6 +57,12 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
: []
) as AnyPrivilege[]
const privilegeResolver = await this.restApi.buildPrivilegeResolver(
objects,
userPrivileges,
opts?.privilege?.resource
)
let limit = opts?.limit ?? Infinity
for (const object of objects) {
if (limit === 0) {
@@ -65,7 +71,7 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
if (
opts?.privilege !== undefined &&
!hasPrivilegeOn({ user, userPrivileges, objects: object, ...opts.privilege })
!hasPrivilegeOn({ user, userPrivileges, objects: object, ...opts.privilege }, privilegeResolver)
) {
continue
}
@@ -105,15 +111,20 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
const objectFilter = (task: XoTask) =>
task.properties.objectId === object.id || task.properties.params?.id === object.id
let userFilter: (task: XoTask) => boolean = () => true
if (filter !== undefined) {
userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
const objectTasks: XoTask[] = []
for await (const task of this.restApi.tasks.list({ filter: objectFilter })) {
objectTasks.push(task)
}
for await (const task of this.restApi.tasks.list({ filter: objectFilter })) {
if (limit === 0) {
break
}
let userFilter: (task: XoTask) => boolean = () => true
if (filter !== undefined) {
const parsedFilter = safeParseComplexMatcher(filter)
userFilter = parsedFilter.createPredicate(await this.restApi.buildResolver(objectTasks, parsedFilter))
}
for (const task of objectTasks) {
if (limit === 0) break
if (userFilter(task)) {
tasks[task.id] = task
limit--

View File

@@ -1,7 +1,7 @@
import { createLogger } from '@xen-orchestra/log'
import type { EventEmitter } from 'node:events'
import { AnyPrivilege, hasPrivilegeOn, SupportedResource } from '@xen-orchestra/acl'
import { XAPI_TYPES, type XapiXoRecord, type XoUser } from '@vates/types'
import { XAPI_TYPES, XoRecord, type XapiXoRecord, type XoUser } from '@vates/types'
import type { CollectionEventType, EventType, NonXapiListenerType, XoListenerType } from '../events/event.type.mjs'
import { iocContainer } from '../ioc/ioc.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
@@ -124,8 +124,8 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
userId,
}: {
event: CollectionEventType
object: object | undefined
previousObject: object | undefined
object: Partial<XoRecord> | undefined
previousObject: Partial<XoRecord> | undefined
userId: XoUser['id']
}): Promise<CollectionEventType | undefined> {
if (this.type === undefined || (object === undefined && previousObject === undefined)) {
@@ -142,7 +142,6 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
return event
}
const userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
let resource: SupportedResource | undefined
if (!XAPI_TYPES.includes(this.type)) {
@@ -156,36 +155,60 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
;[resource] = resourceXapiType as [keyof typeof XAPI_TYPE_BY_ACL_RESOURCE, XapiXoRecord['type']]
}
const userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
const privilegeResolver = await restApi.buildPrivilegeResolver(
[object, previousObject].filter(o => o !== undefined),
userPrivileges,
resource
)
switch (event) {
case 'add':
if (object === undefined) {
return
}
if (!hasPrivilegeOn({ user, userPrivileges, action: 'read', objects: object, resource })) {
if (
!hasPrivilegeOn(
{
user,
userPrivileges,
action: 'read',
objects: object,
resource,
},
privilegeResolver
)
) {
return
}
return 'add'
case 'update': {
const canSeeObject =
object !== undefined &&
hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: object,
resource,
})
hasPrivilegeOn(
{
user,
userPrivileges,
action: 'read',
objects: object,
resource,
},
privilegeResolver
)
const canSeePreviousObject =
previousObject !== undefined &&
hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: previousObject,
resource,
})
hasPrivilegeOn(
{
user,
userPrivileges,
action: 'read',
objects: previousObject,
resource,
},
privilegeResolver
)
if (canSeeObject && canSeePreviousObject) {
return 'update'
@@ -203,13 +226,16 @@ export abstract class Listener<Type extends XoListenerType | undefined = undefin
}
case 'remove':
if (
!hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: object ?? previousObject!,
resource,
})
!hasPrivilegeOn(
{
user,
userPrivileges,
action: 'read',
objects: object ?? previousObject!,
resource,
},
privilegeResolver
)
) {
return
}

View File

@@ -18,7 +18,7 @@ export abstract class XoController<T extends NonXapiXoRecord> extends BaseContro
): Promise<Record<T['id'], T>> {
let objects = await this.getAllCollectionObjects(opts)
objects = limitAndFilterArray(objects, opts)
objects = await limitAndFilterArray(objects, { filter: opts.filter, limit: opts.limit }, this.restApi)
const objectById = {} as Record<T['id'], T>

View File

@@ -316,7 +316,8 @@ export class AclRoleController extends XoController<XoAclRole> {
@Query() limit?: number
): SendObjects<Partial<Unbrand<AnyPrivilege>>> {
const privileges = (await this.restApi.xoApp.getAclV2RolePrivileges(id as XoAclRole['id'])) as AnyPrivilege[]
return this.sendObjects(limitAndFilterArray(privileges, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(privileges, { filter }, this.restApi), req, {
path: 'acl-privileges',
limit,
privilege: { resource: 'acl-privilege', action: 'read' },
@@ -488,7 +489,7 @@ export class AclRoleController extends XoController<XoAclRole> {
)
)
return this.sendObjects(limitAndFilterArray(users, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(users, { filter }, this.restApi), req, {
path: 'users',
limit,
privilege: { resource: 'user', action: 'read' },
@@ -534,7 +535,7 @@ export class AclRoleController extends XoController<XoAclRole> {
)
)
return this.sendObjects(limitAndFilterArray(groups, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(groups, { filter }, this.restApi), req, {
path: 'groups',
limit,
privilege: { resource: 'group', action: 'read' },

View File

@@ -60,7 +60,8 @@ export class AlarmService {
let userFilter: (obj: XoAlarm) => boolean = () => true
if (filter !== undefined) {
userFilter = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
userFilter =
typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate(this.#restApi.resolver) : filter
}
const alarms: Record<XoAlarm['id'], XoAlarm> = {}
for (const id in rawAlarms) {

View File

@@ -42,9 +42,8 @@ import {
} from '../open-api/oa-examples/backup-archive.oa-example.mjs'
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import { SendObjects } from '../helpers/helper.type.mjs'
import { BackupArchiveService } from './backup-archive.service.mjs'
import type { MountLiveDiskBody } from './backup-archive.type.mjs'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
@Route('backup-archives')
@Security('*')
@@ -53,14 +52,8 @@ import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
@Tags('backup-archives')
@provide(BackupArchiveController)
export class BackupArchiveController extends XoController<XoVmBackupArchive> {
#backupArchiveService: BackupArchiveService
constructor(
@inject(RestApi) restApi: RestApi,
@inject(BackupArchiveService) backupArchiveService: BackupArchiveService
) {
constructor(@inject(RestApi) restApi: RestApi) {
super('backup-archive', restApi)
this.#backupArchiveService = backupArchiveService
}
async getAllCollectionObjects({
@@ -88,7 +81,7 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
}
getCollectionObject(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive> {
return this.#backupArchiveService.getBackupArchive(id)
return this.restApi.xoApp.getVmBackupArchive(id)
}
/**
@@ -138,7 +131,7 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
resource: 'backup-archive',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(BackupArchiveService, 'getBackupArchive'),
getObject: ({ restApi }) => restApi.xoApp.getVmBackupArchive,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)

View File

@@ -1,31 +0,0 @@
import { noSuchObject } from 'xo-common/api-errors.js'
import type { XoBackupRepository, XoVm, XoVmBackupArchive } from '@vates/types'
import type { RestApi } from '../rest-api/rest-api.mjs'
// BR uuid/xo-vm-backups/VM uuid/(ISO 8601 compact).json
const BACKUP_ARCHIVE_ID_REGEX = /^([0-9a-fA-F-]{36})\/+xo-vm-backups\/+([0-9a-fA-F-]{36})\/+(\d{8}T\d{6}Z)\.json$/
export class BackupArchiveService {
#restApi: RestApi
constructor(restApi: RestApi) {
this.#restApi = restApi
}
async getBackupArchive(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive> {
const match = id.match(BACKUP_ARCHIVE_ID_REGEX)
if (match === null) {
throw noSuchObject(id, 'backup-archive')
}
const [, brId, vmId] = match as [XoVmBackupArchive['id'], XoBackupRepository['id'], XoVm['id'], string]
const backupArchive = (await this.#restApi.xoApp.listVmBackupsNg([brId]))[brId]?.[vmId]?.find(ba => ba.id === id)
if (backupArchive === undefined) {
throw noSuchObject(id, 'backup-archive')
}
return backupArchive
}
}

View File

@@ -38,7 +38,7 @@ import {
Unbrand,
} from '../open-api/common/response.common.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { BASE_URL, limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import { BASE_URL, limitAndFilterArray } from '../helpers/utils.helper.mjs'
import type {
UnbrandAnyXoBackupJob,
UnbrandXoMetadataBackupJob,
@@ -218,7 +218,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() limit?: number
): SendObjects<Partial<UnbrandXoVmBackupJob>> {
const vmBackupJobs = await this.restApi.xoApp.getAllJobs('backup')
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vmBackupJobs, { filter }, this.restApi), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
@@ -272,7 +273,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() limit?: number
): SendObjects<Partial<UnbrandXoMetadataBackupJob>> {
const metadataBackupJobs = await this.restApi.xoApp.getAllJobs('metadataBackup')
return this.sendObjects(limitAndFilterArray(metadataBackupJobs, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(metadataBackupJobs, { filter }, this.restApi), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
@@ -316,7 +318,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() limit?: number
): SendObjects<Partial<UnbrandXoMirrorBackupJob>> {
const mirrorBackupJobs = await this.restApi.xoApp.getAllJobs('mirrorBackup')
return this.sendObjects(limitAndFilterArray(mirrorBackupJobs, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(mirrorBackupJobs, { filter }, this.restApi), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
@@ -359,17 +362,11 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoBackupLog>>> {
const userFilter = filter === undefined ? () => true : safeParseComplexMatcher(filter).createPredicate()
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({
filter: (log: AnyXoLog) => this.#backupLogService.isBackupLog(log),
})) as XoBackupLog[]
const predicate = (log: AnyXoLog) => {
if (!this.#backupLogService.isBackupLog(log)) {
return false
}
return userFilter(log)
}
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({ filter: predicate })) as XoBackupLog[]
return this.sendObjects(logs, req, {
return this.sendObjects(await limitAndFilterArray(logs, { filter }, this.restApi), req, {
path: 'backup-logs',
limit,
privilege: { action: 'read', resource: 'backup-log' },

View File

@@ -50,6 +50,8 @@ function bindRestApi(user: Partial<XoUser>, userPrivileges: AnyPrivilege[] = [])
getUser: async () => _user,
getAclV2UserPrivileges: async () => userPrivileges,
},
// none of the selectors uses [resolve], so the resolver is never called
buildPrivilegeResolver: async () => undefined,
} as unknown as RestApi)
return _user

View File

@@ -285,7 +285,8 @@ export class GroupController extends XoController<XoGroup> {
): SendObjects<Partial<Unbrand<XoUser>>> {
const group = await this.getObject(id as XoGroup['id'])
const users = await Promise.all(group.users.map(id => this.restApi.xoApp.getUser(id, { obfuscatePassword: true })))
return this.sendObjects(limitAndFilterArray(users, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(users, { filter }, this.restApi), req, {
path: 'users',
limit,
privilege: { action: 'read', resource: 'user' },
@@ -354,7 +355,7 @@ export class GroupController extends XoController<XoGroup> {
const group = await this.getObject(id as XoGroup['id'])
const roles = await Promise.all(group.aclRoleIds.map(roleId => this.restApi.xoApp.getAclV2Role(roleId)))
return this.sendObjects(limitAndFilterArray(roles, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(roles, { filter }, this.restApi), req, {
path: 'acl-roles',
limit,
privilege: { action: 'read', resource: 'acl-role' },

View File

@@ -8,6 +8,7 @@ import { Writable } from 'node:stream'
import { ApiError } from './error.helper.mjs'
import { XMLParser } from 'fast-xml-parser'
import type { RestApi } from '../rest-api/rest-api.mjs'
export const BASE_URL = '/rest/v0'
export { default as forEach } from 'lodash/forEach.js'
export const NDJSON_CONTENT_TYPE = 'application/x-ndjson'
@@ -142,13 +143,18 @@ export function safeParseComplexMatcher(string: string) {
}
}
export function limitAndFilterArray<T>(
export async function limitAndFilterArray<T>(
array: T[],
{ filter, limit = Infinity }: { filter?: string | ((obj: T) => boolean); limit?: number } = {}
): T[] {
if (filter !== undefined) {
const predicate = typeof filter === 'string' ? safeParseComplexMatcher(filter).createPredicate() : filter
array = array.filter(predicate)
{ filter, limit = Infinity }: { filter?: string | ((obj: T) => boolean); limit?: number },
restApi: RestApi
): Promise<T[]> {
if (typeof filter === 'string') {
const parsedFilter = safeParseComplexMatcher(filter)
const userFilter = parsedFilter.createPredicate(await restApi.buildResolver(array, parsedFilter))
array = array.filter(userFilter)
} else if (filter !== undefined) {
array = array.filter(filter)
}
if (limit < array.length) {

View File

@@ -14,7 +14,6 @@ import { BackupJobService } from '../backup-jobs/backup-job.service.mjs'
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
import { EventService } from '../events/event.service.mjs'
import { NetworkService } from '../networks/network.service.mjs'
import { BackupArchiveService } from '../backup-archives/backup-archive.service.mjs'
import { SrService } from '../srs/sr.service.mjs'
import { LicenseService } from '../licenses/license.service.mjs'
import { BackupRepositoryService } from '../backup-repositories/backup-repository.service.mjs'
@@ -114,14 +113,6 @@ export function setupContainer(xoApp: XoApp) {
})
.inSingletonScope()
iocContainer
.bind(BackupArchiveService)
.toDynamicValue(ctx => {
const restApi = ctx.container.get(RestApi)
return new BackupArchiveService(restApi)
})
.inSingletonScope()
iocContainer
.bind(SrService)
.toDynamicValue(ctx => {

View File

@@ -52,7 +52,7 @@ export class MessageController extends XapiXoController<XoMessage> {
> {
let userfilter: (obj: XoMessage) => boolean = () => true
if (filter !== undefined) {
userfilter = safeParseComplexMatcher(filter).createPredicate()
userfilter = safeParseComplexMatcher(filter).createPredicate(this.restApi.resolver)
}
const messagePredicate = (obj: XoMessage) => !alarmPredicate(obj) && userfilter(obj)

View File

@@ -13,6 +13,7 @@ import { iocContainer } from '../ioc/ioc.mjs'
import type { Branded, NonXapiXoRecord, XapiXoRecord, XoRecord } from '@vates/types'
import { ServiceIdentifier, ValidateError } from 'tsoa'
import { ApiError } from '../helpers/error.helper.mjs'
import type { Resolver } from 'complex-matcher'
export const ACL_MIDDLEWARE_NAME = '_aclMiddleware'
@@ -206,6 +207,7 @@ export function acl(acls: AclEntry | AclEntry[]) {
}
const invalidFields: { [key: string]: { message: string; value: unknown } } = {}
const missingPrivilegeParams: AnyPrivilegeOnParam[] = []
const objectsToResolve: object[] = []
for (const acl of _acls) {
let objects: object[] = []
@@ -262,6 +264,8 @@ export function acl(acls: AclEntry | AclEntry[]) {
}
}
objectsToResolve.push(...objects)
// We cast here to restore the discriminated union correlation between `resource` and `action`.
// When rebuilding an object from individual properties of a discriminated union, TypeScript transform it into an union type
// { resource: SupportedResource, action: SupportedAction<SupportedResource> }
@@ -293,7 +297,14 @@ export function acl(acls: AclEntry | AclEntry[]) {
return next(error)
}
const missingPrivileges = getMissingPrivileges(missingPrivilegeParams, userPrivileges)
let privilegeResolver: Resolver | undefined
try {
privilegeResolver = await restApi.buildPrivilegeResolver(objectsToResolve, userPrivileges)
} catch (error) {
return next(error)
}
const missingPrivileges = getMissingPrivileges(missingPrivilegeParams, userPrivileges, privilegeResolver)
if (missingPrivileges.length > 0) {
return next(
new ApiError('not enough privileges', 403, {

View File

@@ -4,6 +4,8 @@ import type { XapiXoRecord, XoApp, XoUser } from '@vates/types'
import type { Container } from 'inversify'
import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import * as CM from 'complex-matcher'
import type { AnyPrivilege, SupportedResource } from '@xen-orchestra/acl'
const log = createLogger('xo:rest-api:error-handler')
@@ -28,6 +30,10 @@ export class RestApi {
return this.#ioc
}
get resolver(): XoApp['xapiObjectResolver'] {
return this.#xoApp.xapiObjectResolver
}
authenticateUser(...args: Parameters<XoApp['authenticateUser']>) {
return this.#xoApp.authenticateUser(...args)
}
@@ -50,12 +56,35 @@ export class RestApi {
return this.#xoApp.getObject(id, type)
}
buildResolver(...args: Parameters<XoApp['buildResolver']>) {
return this.#xoApp.buildResolver(...args)
}
async buildPrivilegeResolver(
objects: object[],
userPrivileges: AnyPrivilege[],
resource?: SupportedResource
): Promise<CM.Resolver | undefined> {
const nodes: CM.Node[] = []
userPrivileges.forEach(userPrivilege => {
if (userPrivilege.selector && (resource === undefined || userPrivilege.resource === resource)) {
nodes.push(CM.parse(userPrivilege.selector))
}
})
if (nodes.length === 0) {
return undefined
}
return this.buildResolver(objects, new CM.And(nodes))
}
getObjectsByType<T extends XapiXoRecord>(
type: T['type'],
{ filter, ...opts }: { filter?: string | ((obj: T) => boolean); limit?: number } = {}
): Record<T['id'], T> {
if (filter !== undefined && typeof filter === 'string') {
filter = safeParseComplexMatcher(filter).createPredicate()
filter = safeParseComplexMatcher(filter).createPredicate(this.resolver)
}
return this.#xoApp.getObjectsByType(type, { filter, ...opts }) ?? ({} as Record<T['id'], T>)
}

View File

@@ -96,7 +96,7 @@ export class TaskController extends XoController<XoTask> {
throw new ApiError('watch=true requires ndjson=true', 400)
}
const userFilter = filter === undefined ? undefined : safeParseComplexMatcher(filter).createPredicate()
const complexMatcher = filter === undefined ? undefined : safeParseComplexMatcher(filter)
const mapper = makeObjectMapper(req)
const stream = new Transform({
objectMode: true,
@@ -123,6 +123,14 @@ export class TaskController extends XoController<XoTask> {
}
}
// these handlers run on an event emitter which does not await them, so a
// rejection would otherwise be unhandled and silently lose events for
// this client: destroy it instead, it reconnects and refetches
const onError = (error: unknown) => {
warn('cannot decide whether to send this task, the connection is going to be destroyed', { error })
req.destroy()
}
const onSigTerm = () => {
req.destroy()
}
@@ -139,7 +147,8 @@ export class TaskController extends XoController<XoTask> {
process.on('SIGTERM', onSigTerm)
const userId = this.restApi.getCurrentUser().id
const update = async (task: XoTask) => {
const shouldSend = async (task: XoTask): Promise<boolean> => {
const user = await this.restApi.xoApp.getUser(userId)
const userPrivileges =
user.permission === 'admin'
@@ -147,24 +156,40 @@ export class TaskController extends XoController<XoTask> {
: ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
if (
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
(userFilter === undefined || userFilter(task))
hasPrivilegeOn(
{ user, userPrivileges, action: 'read', resource: 'task', objects: task },
await this.restApi.buildPrivilegeResolver([task], userPrivileges, 'task')
)
) {
safeWrite(['update', task])
if (complexMatcher === undefined) {
return true
} else {
const resolver = await this.restApi.buildResolver([task], complexMatcher)
if (complexMatcher.createPredicate(resolver)(task)) {
return true
}
}
}
return false
}
const update = async (task: XoTask) => {
try {
if (await shouldSend(task)) {
safeWrite(['update', task])
}
} catch (error) {
onError(error)
}
}
const remove = async (task: XoTask) => {
const user = await this.restApi.xoApp.getUser(userId)
const userPrivileges =
user.permission === 'admin'
? []
: ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
if (
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
(userFilter === undefined || userFilter(task))
) {
safeWrite(['remove', { id: task.id }])
try {
if (await shouldSend(task)) {
safeWrite(['remove', { id: task.id }])
}
} catch (error) {
onError(error)
}
}
@@ -229,9 +254,18 @@ export class TaskController extends XoController<XoTask> {
const userPrivileges =
user.permission === 'admin' ? [] : ((await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[])
const deletePromises: Promise<void>[] = []
const tasks: XoTask[] = []
for await (const task of this.restApi.tasks.list()) {
if (hasPrivilegeOn({ user, userPrivileges, resource: 'task', action: 'delete', objects: task })) {
tasks.push(task)
}
const privilegeResolver = await this.restApi.buildPrivilegeResolver(tasks, userPrivileges, 'task')
const deletePromises: Promise<void>[] = []
for (const task of tasks) {
if (
hasPrivilegeOn({ user, userPrivileges, resource: 'task', action: 'delete', objects: task }, privilegeResolver)
) {
deletePromises.push(this.restApi.tasks.deleteLog(task.id))
}
}

View File

@@ -286,7 +286,7 @@ export class UserController extends XoController<XoUser> {
const user = await this.getObject(id as XoUser['id'])
const groups = await Promise.all(user.groups.map(group => this.restApi.xoApp.getGroup(group)))
return this.sendObjects(limitAndFilterArray(groups, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(groups, { filter }, this.restApi), req, {
path: 'groups',
limit,
privilege: { action: 'read', resource: 'group' },
@@ -320,8 +320,7 @@ export class UserController extends XoController<XoUser> {
}
const tokens = await this.restApi.xoApp.getAuthenticationTokensForUser(user.id)
return limitAndFilterArray(tokens, { filter, limit })
return limitAndFilterArray(tokens, { filter, limit }, this.restApi)
}
/**
@@ -459,8 +458,7 @@ export class UserController extends XoController<XoUser> {
const currentUser = this.restApi.getCurrentUser()
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
return this.sendObjects(limitAndFilterArray(userPrivileges, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(userPrivileges, { filter }, this.restApi), req, {
path: 'acl-privileges',
limit,
privilege: currentUser.id === user.id ? undefined : { action: 'read', resource: 'acl-privilege' },

View File

@@ -159,7 +159,7 @@ export class VmControllerController extends XapiXoController<XoVmController> {
@Security('*', ['acl'])
@Tags('vdis')
@Response(notFoundResp.status, notFoundResp.description)
getVmControllerVdis(
async getVmControllerVdis(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@@ -167,9 +167,9 @@ export class VmControllerController extends XapiXoController<XoVmController> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoVdi>>> {
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
const vdis = this.#vmService.getVmVdis(id as XoVmController['id'], 'VM-controller')
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
path: obj => obj.type.toLowerCase() + 's',
limit,
privilege: { action: 'read', resource: 'vdi' },

View File

@@ -212,7 +212,7 @@ export class VmSnapshotController extends XapiXoController<XoVmSnapshot> {
@Security('*', ['acl'])
@Tags('vdis')
@Response(notFoundResp.status, notFoundResp.description)
getVmSnapshotVdis(
async getVmSnapshotVdis(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@@ -220,9 +220,9 @@ export class VmSnapshotController extends XapiXoController<XoVmSnapshot> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoVdiSnapshot>>> {
): Promise<SendObjects<Partial<Unbrand<XoVdiSnapshot>>>> {
const vdis = this.#vmService.getVmVdis(id as XoVmSnapshot['id'], 'VM-snapshot')
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
path: obj => obj.type.toLowerCase() + 's',
limit,
privilege: { action: 'read', resource: 'vdi' },

View File

@@ -213,7 +213,7 @@ export class VmTemplateController extends XapiXoController<XoVmTemplate> {
@Security('*', ['acl'])
@Tags('vdis')
@Response(notFoundResp.status, notFoundResp.description)
getVmTemplateVdis(
async getVmTemplateVdis(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@@ -221,9 +221,9 @@ export class VmTemplateController extends XapiXoController<XoVmTemplate> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoVdi>>> {
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
const vdis = this.#vmService.getVmVdis(id as XoVmTemplate['id'], 'VM-template')
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
path: obj => obj.type.toLowerCase() + 's',
limit,
privilege: { action: 'read', resource: 'vdi' },

View File

@@ -864,7 +864,7 @@ export class VmController extends XapiXoController<XoVm> {
@Security('*', ['acl'])
@Tags('vdis')
@Response(notFoundResp.status, notFoundResp.description)
getVmVdis(
async getVmVdis(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@@ -872,9 +872,9 @@ export class VmController extends XapiXoController<XoVm> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoVdi>>> {
): Promise<SendObjects<Partial<Unbrand<XoVdi>>>> {
const vdis = this.#vmService.getVmVdis(id as XoVm['id'], 'VM')
return this.sendObjects(limitAndFilterArray(vdis, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vdis, { filter }, this.restApi), req, {
path: obj => obj.type.toLowerCase() + 's',
limit,
privilege: { action: 'read', resource: 'vdi' },
@@ -915,7 +915,7 @@ export class VmController extends XapiXoController<XoVm> {
}
}
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter }), req, {
return this.sendObjects(await limitAndFilterArray(vmBackupJobs, { filter }, this.restApi), req, {
path: '/backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },

View File

@@ -58,6 +58,7 @@
- [XO5] Improve event handling (PR [#10568](https://github.com/vatesfr/xen-orchestra/pull/10568))
- [Netbox] Support Netbox v4.7.x [#10565](https://github.com/vatesfr/xen-orchestra/issues/10565) (PR [#10574](https://github.com/vatesfr/xen-orchestra/pull/10574))
- [V2V] Import VMs from ESXi 6.x hosts: their disks are read one request at a time, so more slowly than from ESXi 7 and 8 (PR [#10590](https://github.com/vatesfr/xen-orchestra/pull/10590))
- [Complex-matcher] Add `[resolve]` for deep resolution, with `[some]` and `[every]` quantifiers (PR [#9911](https://github.com/vatesfr/xen-orchestra/pull/9911))
### Bug fixes
@@ -124,6 +125,7 @@
- @xen-orchestra/web minor
- @xen-orchestra/web-core minor
- @xen-orchestra/xapi minor
- complex-matcher minor
- vhd-lib minor
- xen-api minor
- xo-acl-resolver minor

View File

@@ -1,17 +1,57 @@
```js
import * as CM from 'complex-matcher'
const places = {
gotham: { name: 'Gotham City', planet: 'Earth' },
krypton: { name: 'Krypton', planet: 'Krypton', destroyed: true },
themyscira: { name: 'Themyscira', planet: 'Earth' },
}
const characters = [
{ name: 'Catwoman', costumeColor: 'black' },
{ name: 'Superman', costumeColor: 'blue', hasCape: true },
{ name: 'Wonder Woman', costumeColor: 'blue' },
{ name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
{ name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
{ name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
]
const predicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
// [resolve] requires a resolver, calling createPredicate() without one throws.
// [resolve] can be nested up to a depth of 5, beyond it throws at parse time.
const resolver = id => places[id]
characters.filter(predicate)
// --------------------------------------------
const costumeColorPredicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
characters.filter(costumeColorPredicate)
// [
// { name: 'Superman', costumeColor: 'blue', hasCape: true },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const originPlanetPredicate = CM.parse('originId:[resolve]:planet:Earth').createPredicate(resolver)
characters.filter(originPlanetPredicate)
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
// ]
const earthPredicate = CM.parse('visitedIds:[resolve]:planet:Earth').createPredicate(resolver)
characters.filter(earthPredicate)
// No quantifier defaults to [some].
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const someEarthPredicate = CM.parse('visitedIds:[resolve]:[some]:planet:Earth').createPredicate(resolver)
characters.filter(someEarthPredicate)
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const everyEarthPredicate = CM.parse('visitedIds:[resolve]:[every]:planet:Earth').createPredicate(resolver)
characters.filter(everyEarthPredicate)
// Wonder Woman is excluded because [every] requires at least one resolved object.
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// ]
new CM.String('foo').createPredicate()

View File

@@ -19,17 +19,57 @@ npm install --save complex-matcher
```js
import * as CM from 'complex-matcher'
const places = {
gotham: { name: 'Gotham City', planet: 'Earth' },
krypton: { name: 'Krypton', planet: 'Krypton', destroyed: true },
themyscira: { name: 'Themyscira', planet: 'Earth' },
}
const characters = [
{ name: 'Catwoman', costumeColor: 'black' },
{ name: 'Superman', costumeColor: 'blue', hasCape: true },
{ name: 'Wonder Woman', costumeColor: 'blue' },
{ name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
{ name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
{ name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
]
const predicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
// [resolve] requires a resolver, calling createPredicate() without one throws.
// [resolve] can be nested up to a depth of 5, beyond it throws at parse time.
const resolver = id => places[id]
characters.filter(predicate)
// --------------------------------------------
const costumeColorPredicate = CM.parse('costumeColor:blue hasCape?').createPredicate()
characters.filter(costumeColorPredicate)
// [
// { name: 'Superman', costumeColor: 'blue', hasCape: true },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const originPlanetPredicate = CM.parse('originId:[resolve]:planet:Earth').createPredicate(resolver)
characters.filter(originPlanetPredicate)
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Wonder Woman', costumeColor: 'blue', originId: 'themyscira', visitedIds: [] },
// ]
const earthPredicate = CM.parse('visitedIds:[resolve]:planet:Earth').createPredicate(resolver)
characters.filter(earthPredicate)
// No quantifier defaults to [some].
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const someEarthPredicate = CM.parse('visitedIds:[resolve]:[some]:planet:Earth').createPredicate(resolver)
characters.filter(someEarthPredicate)
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// { name: 'Superman', costumeColor: 'blue', hasCape: true, originId: 'krypton', visitedIds: ['gotham', 'krypton'] },
// ]
const everyEarthPredicate = CM.parse('visitedIds:[resolve]:[every]:planet:Earth').createPredicate(resolver)
characters.filter(everyEarthPredicate)
// Wonder Woman is excluded because [every] requires at least one resolved object.
// [
// { name: 'Catwoman', costumeColor: 'black', originId: 'gotham', visitedIds: ['gotham'] },
// ]
new CM.String('foo').createPredicate()

View File

@@ -1,8 +1,10 @@
export type ComparisonOperator = '>' | '>=' | '<' | '<='
export type Resolver = (id: string) => object | undefined
declare class BaseNode {
createPredicate(): (value: any) => boolean
match(value: any): boolean
createPredicate(resolver?: Resolver): (value: any) => boolean
match(value: any, resolver?: Resolver): boolean
toString(isNested?: boolean): string
}
@@ -74,10 +76,24 @@ export class TruthyProperty extends BaseNode {
name: string
}
export class Quantifier extends BaseNode {
constructor(child: BaseNode, mode?: 'some' | 'every', implicit?: boolean)
child: BaseNode
mode: 'some' | 'every'
implicit: boolean
}
export class Resolve extends BaseNode {
constructor(child: Quantifier)
child: Quantifier
}
export function parse(input: string): Node
export function getPropertyClausesStrings(node: Node): {
[key: string]: string[]
}
export function getResolveFields(node: Node): { path: string[]; resolveNode: Resolve }[]
export function setPropertyClause(node: Node | undefined, name: string, child: Node | string): Node

View File

@@ -2,7 +2,8 @@
const CM = require('./')
exports.pattern = 'foo !"\\\\ \\"" name:|(wonderwoman batman) hasCape? age:32 chi*go /^foo\\/bar\\./i'
exports.pattern =
'foo !"\\\\ \\"" name:|(wonderwoman batman) hasCape? age:32 chi*go /^foo\\/bar\\./i object:[resolve]:tags:tag'
exports.ast = new CM.And([
new CM.String('foo'),
@@ -12,4 +13,8 @@ exports.ast = new CM.And([
new CM.Property('age', new CM.NumberOrStringNode('32')),
new CM.GlobPattern('chi*go'),
new CM.RegExp('^foo/bar\\.', 'i'),
new CM.Property(
'object',
new CM.Resolve(new CM.Quantifier(new CM.Property('tags', new CM.String('tag')), 'some', true))
),
])

View File

@@ -14,6 +14,8 @@ const RAW_STRING_SYMBOLS = {
$: true,
}
const MAX_RESOLVE_DEPTH = 5
const isRawStringChar = c =>
(c >= '0' && c <= '9') || c in RAW_STRING_SYMBOLS || !(c === c.toUpperCase() && c === c.toLowerCase())
@@ -27,8 +29,8 @@ const isRawString = string => {
// -------------------------------------------------------------------
class Node {
createPredicate() {
return value => this.match(value)
createPredicate(resolver) {
return value => this.match(value, resolver)
}
}
@@ -55,8 +57,8 @@ class And extends Node {
this.children = children
}
match(value) {
return this.children.every(child => child.match(value))
match(value, resolver) {
return this.children.every(child => child.match(value, resolver))
}
toString(isNested) {
@@ -105,8 +107,8 @@ class Or extends Node {
this.children = children
}
match(value) {
return this.children.some(child => child.match(value))
match(value, resolver) {
return this.children.some(child => child.match(value, resolver))
}
toString() {
@@ -122,8 +124,8 @@ class Not extends Node {
this.child = child
}
match(value) {
return !this.child.match(value)
match(value, resolver) {
return !this.child.match(value, resolver)
}
toString() {
@@ -188,8 +190,8 @@ class Property extends Node {
this.child = child
}
match(value) {
return value != null && this.child.match(value[this.name])
match(value, resolver) {
return value != null && this.child.match(value[this.name], resolver)
}
toString() {
@@ -314,6 +316,66 @@ class TruthyProperty extends Node {
}
exports.TruthyProperty = TruthyProperty
class Resolve extends Node {
constructor(child) {
super()
this.child = child
}
match(value, resolver) {
if (resolver === undefined) {
throw new Error('[resolve] requires a resolver')
}
if (value == null) {
return false
}
const ids = Array.isArray(value) ? value : [value]
const objects = []
for (const id of ids) {
objects.push(resolver(id))
}
return this.child.match(objects, resolver)
}
toString() {
return `[resolve]:${this.child.toString(true)}`
}
}
exports.Resolve = Resolve
class Quantifier extends Node {
constructor(child, mode = 'some', implicit = false) {
super()
this.child = child
this.mode = mode
this.implicit = implicit
}
match(values, resolver) {
const array = Array.isArray(values) ? values : [values]
if (this.mode === 'some') {
return array.some(value => value != null && this.child.match(value, resolver))
} else if (this.mode === 'every') {
// nothing resolved means nothing matches
return array.length > 0 && array.every(value => value != null && this.child.match(value, resolver))
}
throw new Error(`${this.mode} quantifier not supported`)
}
toString(isNested) {
// an implicit 'some' is not re-emitted so that toString still round-trips
return this.implicit ? this.child.toString(isNested) : `[${this.mode}]:${this.child.toString(true)}`
}
}
exports.Quantifier = Quantifier
// -------------------------------------------------------------------
// https://gist.github.com/yelouafi/556e5159e869952335e01f6b473c4ec1
@@ -533,6 +595,11 @@ const parser = P.grammar({
P.seq(P.regex(/[<>]=?/), r.rawString).map(([op, val]) => {
return new Comparison(op, +val)
}),
P.seq(P.text('[resolve]'), P.text(':'), r.ws, r.term).map(
_ => new Resolve(_[3] instanceof Quantifier ? _[3] : new Quantifier(_[3], 'some', true))
),
P.seq(P.text('[some]'), P.text(':'), r.ws, r.term).map(_ => new Quantifier(_[3], 'some')),
P.seq(P.text('[every]'), P.text(':'), r.ws, r.term).map(_ => new Quantifier(_[3], 'every')),
P.seq(r.property, r.ws, P.text(':'), r.ws, r.term).map(_ => new Property(_[0], _[4])),
P.seq(r.property, P.text('?')).map(_ => new TruthyProperty(_[0])),
r.value
@@ -548,7 +615,48 @@ const parser = P.grammar({
),
ws: P.regex(/\s*/),
}).default
exports.parse = parser.parse.bind(parser)
const _checkResolveConstraints = (node, parent) => {
let depth = 0
if (node instanceof Resolve && !(parent instanceof Property)) {
throw new Error(`[resolve] must follow a property`)
}
if (node instanceof Quantifier && !(parent instanceof Resolve)) {
throw new Error(`[some]/[every] must follow a [resolve]`)
}
if (node.children !== undefined) {
for (const child of node.children) {
const childDepth = _checkResolveConstraints(child, node)
if (childDepth > depth) {
depth = childDepth
}
}
} else if (node.child !== undefined) {
depth = _checkResolveConstraints(node.child, node)
}
if (node instanceof Resolve) {
depth += 1
if (depth > MAX_RESOLVE_DEPTH) {
throw new Error(`[resolve] cannot be nested more than ${MAX_RESOLVE_DEPTH} levels`)
}
}
return depth
}
const _parse = parser.parse.bind(parser)
exports.parse = function parse(input) {
const node = _parse(input)
_checkResolveConstraints(node)
return node
}
// -------------------------------------------------------------------
@@ -622,6 +730,26 @@ exports.getPropertyClausesStrings = function getPropertyClausesStrings(node) {
// -------------------------------------------------------------------
exports.getResolveFields = function getResolveFields(node, prefix = []) {
const result = []
if (node instanceof Property) {
if (node.child instanceof Resolve) {
result.push({ path: [...prefix, node.name], resolveNode: node.child })
} else {
result.push(...getResolveFields(node.child, [...prefix, node.name]))
}
} else if (node instanceof And || node instanceof Or) {
node.children.forEach(childNode => {
result.push(...getResolveFields(childNode, prefix))
})
} else if (node instanceof Not || node instanceof Quantifier) {
result.push(...getResolveFields(node.child, prefix))
}
return result
}
exports.setPropertyClause = function setPropertyClause(node, name, child) {
const property = child && new Property(name, typeof child === 'string' ? new StringNode(child) : child)

View File

@@ -7,12 +7,14 @@ const { ast, pattern } = require('./index.fixtures')
const {
getPropertyClausesStrings,
Comparison,
getResolveFields,
GlobPattern,
Null,
NumberNode,
NumberOrStringNode,
parse,
Property,
Resolve,
setPropertyClause,
StringNode,
} = require('./')
@@ -103,6 +105,70 @@ describe('NumberOrStringNode', () => {
})
})
describe('getResolveFields', () => {
it('returns field name and resolve node for a single property', () => {
const resolveFields = getResolveFields(parse('object:[resolve]:tags:tag'))
assert.deepEqual(resolveFields[0].path, ['object'])
assert(resolveFields[0].resolveNode instanceof Resolve)
})
it('returns empty array when no [resolve] is present', () => {
const resolveFields = getResolveFields(parse('tags:tag'))
assert.equal(resolveFields.length, 0)
})
it('returns all field names and resolve nodes for an And node', () => {
const resolveFields = getResolveFields(parse('object1:[resolve]:tags:tag object2:[resolve]:tags:tag'))
assert.deepEqual(resolveFields[0].path, ['object1'])
assert(resolveFields[0].resolveNode instanceof Resolve)
assert.deepEqual(resolveFields[1].path, ['object2'])
assert(resolveFields[1].resolveNode instanceof Resolve)
})
it('only returns properties with [resolve] in a mixed And node', () => {
const resolveFields2 = getResolveFields(parse('object3:[resolve]:tags:tag tags:tag'))
assert.equal(resolveFields2.length, 1)
assert.deepEqual(resolveFields2[0].path, ['object3'])
assert(resolveFields2[0].resolveNode instanceof Resolve)
})
it('returns all field names and resolve nodes for an Or node', () => {
const resolveFields = getResolveFields(parse('|(object1:[resolve]:tags:tag object2:[resolve]:tags:tag)'))
assert.equal(resolveFields.length, 2)
assert.deepEqual(resolveFields[0].path, ['object1'])
assert(resolveFields[0].resolveNode instanceof Resolve)
assert.deepEqual(resolveFields[1].path, ['object2'])
assert(resolveFields[1].resolveNode instanceof Resolve)
})
it('returns field names and resolve nodes inside a Not node', () => {
const resolveFields = getResolveFields(parse('!(object:[resolve]:tags:tag)'))
assert.equal(resolveFields.length, 1)
assert.deepEqual(resolveFields[0].path, ['object'])
assert(resolveFields[0].resolveNode instanceof Resolve)
})
it('returns the full path and resolve node for a nested property', () => {
const resolveFields = getResolveFields(parse('properties:userId:[resolve]:tags:tag'))
assert.equal(resolveFields.length, 1)
assert.deepEqual(resolveFields[0].path, ['properties', 'userId'])
assert(resolveFields[0].resolveNode instanceof Resolve)
})
it('prefixes paths for resolves inside a grouped property', () => {
const resolveFields = getResolveFields(parse('object1:(object2:[resolve]:tags:tag object3:[resolve]:tags:tag)'))
assert.equal(resolveFields.length, 2)
assert.deepEqual(resolveFields[0].path, ['object1', 'object2'])
assert.deepEqual(resolveFields[1].path, ['object1', 'object3'])
})
it('ignores non-resolve terms in a group', () => {
const resolveFields = getResolveFields(parse('object1:(object2:[resolve]:tags:tag property)'))
assert.equal(resolveFields.length, 1)
assert.deepEqual(resolveFields[0].path, ['object1', 'object2'])
})
})
describe('setPropertyClause', () => {
it('creates a node if none passed', () => {
assert.equal(setPropertyClause(undefined, 'foo', 'bar').toString(), 'foo:bar')
@@ -128,3 +194,219 @@ describe('setPropertyClause', () => {
it('toString', () => {
assert.equal(ast.toString(), pattern)
})
describe('resolve', () => {
const target = { objects: 'object' }
const targetArray = { objects: ['object'] }
const store = { object: { tags: ['tag'], nested: 'nested-object' }, 'nested-object': { tags: ['nested-tag'] } }
const notFoundStore = { 'not-object': { tags: ['tag'] } }
it('match a single ID', () => {
assert(parse('objects:[resolve]:tags:tag').createPredicate(id => store[id])(target))
})
it('match an array of IDs', () => {
assert(parse('objects:[resolve]:tags:tag').createPredicate(id => store[id])(targetArray))
})
it('match with recursive resolution', () => {
assert(parse('objects:[resolve]:nested:[resolve]:tags:nested-tag').createPredicate(id => store[id])(target))
})
it("doesn't match when resolved object does not satisfy the predicate", () => {
assert(!parse('objects:[resolve]:tags:not-found-tag').createPredicate(id => store[id])(target))
})
it("doesn't match when resolved object not found", () => {
assert(!parse('objects:[resolve]:tags:tag').createPredicate(id => notFoundStore[id])(target))
})
it("doesn't match recursively when resolved object does not satisfy the predicate", () => {
assert(!parse('objects:[resolve]:nested:[resolve]:tags:not-found-tag').createPredicate(id => store[id])(target))
})
it('matches through a grouped property', () => {
const filter = 'outer:(objects:[resolve]:tags:tag other:x)'
const predicate = parse(filter).createPredicate(id => store[id])
assert(predicate({ outer: { objects: 'object', other: 'x' } }))
// the resolved object matches but the sibling term of the group does not
assert(!predicate({ outer: { objects: 'object', other: 'nope' } }))
// the sibling matches but the id resolves to nothing
assert(!predicate({ outer: { objects: 'ghost', other: 'x' } }))
})
it('throws when no resolver is provided', () => {
assert.throws(() => parse('objects:[resolve]:tags:tag').createPredicate()(target), {
message: '[resolve] requires a resolver',
})
})
it('toString round-trips correctly', () => {
assert.equal(parse('objects:[resolve]:tags:tag').toString(), 'objects:[resolve]:tags:tag')
})
})
describe('resolve quantifiers', () => {
const store = {
'object-1': { tags: ['tag'] },
'object-2': { tags: ['tag'] },
'object-3': { tags: ['other'] },
}
const resolver = id => store[id]
const allResolve = { objects: ['object-1', 'object-2'] }
const someFail = { objects: ['object-1', 'object-3'] }
const oneUnresolvable = { objects: ['object-1', 'object-2', 'ghost'] }
const allUnresolvable = { objects: ['ghost-1', 'ghost-2'] }
const emptyArray = { objects: [] }
const scalar = { objects: 'object-1' }
const every = 'objects:[resolve]:[every]:tags:tag'
const some = 'objects:[resolve]:[some]:tags:tag'
it('[every] matches when all resolved objects satisfy the predicate', () => {
assert(parse(every).createPredicate(resolver)(allResolve))
})
it("[every] doesn't match when only some resolved objects satisfy the predicate", () => {
assert(!parse(every).createPredicate(resolver)(someFail))
})
it("[every] doesn't match when an id cannot be resolved", () => {
assert(!parse(every).createPredicate(resolver)(oneUnresolvable))
})
it("[every] doesn't match when nothing could be resolved", () => {
assert(!parse(every).createPredicate(resolver)(allUnresolvable))
})
it("[every] doesn't match an empty collection", () => {
assert(!parse(every).createPredicate(resolver)(emptyArray))
})
it('[some] matches when at least one resolved object satisfies the predicate', () => {
assert(parse(some).createPredicate(resolver)(someFail))
})
it("[some] doesn't match when nothing could be resolved", () => {
assert(!parse(some).createPredicate(resolver)(allUnresolvable))
})
it("[some] doesn't match an empty collection", () => {
assert(!parse(some).createPredicate(resolver)(emptyArray))
})
it('both quantifiers agree on a single id', () => {
assert(parse(every).createPredicate(resolver)(scalar))
assert(parse(some).createPredicate(resolver)(scalar))
})
it('[resolve] without a quantifier behaves as [some]', () => {
assert(parse('objects:[resolve]:tags:tag').createPredicate(resolver)(someFail))
})
it('toString round-trips an explicit quantifier', () => {
assert.equal(parse(every).toString(), every)
})
it('toString does not emit the implicit [some]', () => {
assert.equal(parse('objects:[resolve]:tags:tag').toString(), 'objects:[resolve]:tags:tag')
})
it('getResolveFields walks through a quantifier', () => {
const [field] = getResolveFields(parse('a:[resolve]:[every]:b:[resolve]:c:d'))
assert.deepEqual(field.path, ['a'])
assert.deepEqual(
getResolveFields(field.resolveNode.child).map(_ => _.path),
[['b']]
)
})
})
describe('resolve quantifiers with a negated condition', () => {
const store = {
'object-1': { tags: ['tag'] },
'object-3': { tags: ['other'] },
}
const resolver = id => store[id]
// an unresolvable id must never match, whatever the condition: without a guard
// in the quantifier, `!tags:tag` would be *satisfied* by an unresolved object
const notEvery = 'objects:[resolve]:[every]:!tags:tag'
const notSome = 'objects:[resolve]:[some]:!tags:tag'
it('[some] matches a resolved object which satisfies the negation', () => {
assert(parse(notSome).createPredicate(resolver)({ objects: ['object-3'] }))
})
it("[some] doesn't match when only an unresolvable id would satisfy the negation", () => {
assert(!parse(notSome).createPredicate(resolver)({ objects: ['object-1', 'ghost'] }))
})
it('[every] matches when every resolved object satisfies the negation', () => {
assert(parse(notEvery).createPredicate(resolver)({ objects: ['object-3'] }))
})
it("[every] doesn't match when an id cannot be resolved", () => {
assert(!parse(notEvery).createPredicate(resolver)({ objects: ['object-3', 'ghost'] }))
})
it("[every] doesn't match when nothing could be resolved", () => {
assert(!parse(notEvery).createPredicate(resolver)({ objects: ['ghost-1', 'ghost-2'] }))
})
it('a negation outside the resolve is unaffected', () => {
const filter = '!(objects:[resolve]:[some]:tags:tag)'
assert(!parse(filter).createPredicate(resolver)({ objects: ['object-1', 'ghost'] }))
assert(parse(filter).createPredicate(resolver)({ objects: ['ghost'] }))
})
})
describe('resolve depth limit', () => {
// mirrors MAX_RESOLVE_DEPTH in index.js: update both together
const maxDepth = 5
// `a:[resolve]:b:[resolve]:…:tags:tag`
const nested = depth =>
Array.from({ length: depth }, (_, i) => `${String.fromCharCode(97 + i)}:[resolve]`).join(':') + ':tags:tag'
it('parses a filter nested up to the maximum depth', () => {
assert(parse(nested(maxDepth)) instanceof Property)
})
it('throws beyond the maximum depth', () => {
assert.throws(() => parse(nested(maxDepth + 1)), {
message: '[resolve] cannot be nested more than 5 levels',
})
})
it('counts nesting, not the number of [resolve]', () => {
const siblings = Array.from({ length: maxDepth + 1 }, (_, i) => `${String.fromCharCode(97 + i)}:[resolve]:tags:tag`)
assert.doesNotThrow(() => parse(siblings.join(' ')))
})
it('counts nesting through a quantifier', () => {
assert.throws(
() => parse('a:[resolve]:[every]:b:[resolve]:[some]:c:[resolve]:d:[resolve]:e:[resolve]:f:[resolve]:tags:tag'),
{ message: '[resolve] cannot be nested more than 5 levels' }
)
})
it('throws if resolve does not follow a property', () => {
assert.throws(() => parse('[resolve]:tags:tag'), {
message: '[resolve] must follow a property',
})
})
it('throws if some/every does not follow a resolve', () => {
assert.throws(() => parse('a:[some]:tags:tag'), {
message: '[some]/[every] must follow a [resolve]',
})
assert.throws(() => parse('a:[every]:tags:tag'), {
message: '[some]/[every] must follow a [resolve]',
})
})
})

View File

@@ -0,0 +1,330 @@
import assert from 'assert/strict'
import test from 'node:test'
import * as CM from 'complex-matcher'
import { featureUnauthorized, noSuchObject } from 'xo-common/api-errors.js'
import ObjectResolver from './object-resolver.mjs'
const { describe, it } = test
describe('ObjectResolver', function () {
describe('buildResolver', function () {
// only XAPI objects are reachable through `getObject`, the others are only
// reachable through the asynchronous `getAnyObject`
const xapiObjects = {
// `creation` is written by xo-server on the XAPI object itself, so a XAPI
// object can reference a non-XAPI one
vm1: { $SR: 'sr1', creation: { user: 'user1' } },
sr1: { tags: ['prod'] },
}
const anyObjects = { ...xapiObjects, user1: { email: 'test@test.com' } }
const makeApp = (over = {}) => ({
// `getObject` is synchronous and throws when the id is unknown
getObject: id => {
const object = xapiObjects[id]
if (object === undefined) {
throw noSuchObject(id)
}
return object
},
getAnyObject: async id => {
const object = anyObjects[id]
if (object === undefined) {
throw noSuchObject(id)
}
return object
},
...over,
})
it('resolves a single referenced id', async function () {
const objectResolver = new ObjectResolver(makeApp())
const node = CM.parse('$SR:[resolve]:tags:prod')
const resolver = await objectResolver.buildResolver({ $SR: 'sr1' }, node)
assert.deepEqual(resolver('sr1'), { tags: ['prod'] })
})
it('skips an unresolvable id (noSuchObject) instead of throwing', async function () {
const objectResolver = new ObjectResolver(makeApp())
const node = CM.parse('$SR:[resolve]:tags:prod')
await assert.doesNotReject(() => objectResolver.buildResolver({ $SR: 'missing' }, node))
})
it('propagates a genuine (non-noSuchObject) error', async function () {
const objectResolver = new ObjectResolver(
makeApp({
getAnyObject: async () => {
throw new Error('db down')
},
})
)
const node = CM.parse('$SR:[resolve]:tags:prod')
await assert.rejects(() => objectResolver.buildResolver({ $SR: 'sr1' }, node), /db down/)
})
it('resolves a nested referenced id via getAnyObject prefetch', async function () {
const objectResolver = new ObjectResolver(makeApp())
const node = CM.parse('properties:userId:[resolve]:email:"test@test.com"')
const resolver = await objectResolver.buildResolver({ properties: { userId: 'user1' } }, node)
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
})
it('prefetches and matches through a grouped property', async function () {
const objectResolver = new ObjectResolver(makeApp())
// the resolve sits inside a group, so its path has two segments
const node = CM.parse('properties:(userId:[resolve]:email:"test@test.com" method:vm.start)')
const object = { properties: { userId: 'user1', method: 'vm.start' } }
const resolver = await objectResolver.buildResolver([object], node)
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
assert(node.createPredicate(resolver)(object))
})
it('resolves a non-XAPI object referenced by a XAPI object', async function () {
const objectResolver = new ObjectResolver(makeApp())
const node = CM.parse('$VM:[resolve]:creation:user:[resolve]:email:"test@test.com"')
const resolver = await objectResolver.buildResolver({ $VM: 'vm1' }, node)
// first hop is a XAPI object, second one is only reachable through `getAnyObject`
assert.deepEqual(resolver('vm1'), xapiObjects.vm1)
assert.deepEqual(resolver('user1'), { email: 'test@test.com' })
})
describe('when the same id is reached from two branches', function () {
// both branches dereference `shared`, but each one continues through a
// different field of it
const store = {
shared: { x: 'xobj', y: 'yobj' },
xobj: { tags: ['tx'] },
yobj: { tags: ['ty'] },
}
const sharedApp = {
getObject: id => {
throw noSuchObject(id)
},
getAnyObject: async id => {
const object = store[id]
if (object === undefined) {
throw noSuchObject(id)
}
return object
},
}
const object = { a: 'shared', b: 'shared' }
const filter = 'a:[resolve]:x:[resolve]:tags:tx b:[resolve]:y:[resolve]:tags:ty'
it('prefetches both branches', async function () {
const node = CM.parse(filter)
const resolver = await new ObjectResolver(sharedApp).buildResolver([object], node)
assert.deepEqual(resolver('shared'), store.shared)
assert.deepEqual(resolver('xobj'), store.xobj)
// only reachable by walking `shared` a second time, for the other branch
assert.deepEqual(resolver('yobj'), store.yobj)
})
it('matches on both branches', async function () {
const node = CM.parse(filter)
const resolver = await new ObjectResolver(sharedApp).buildResolver([object], node)
assert(node.createPredicate(resolver)(object))
})
it('looks a shared dangling id up only once', async function () {
// an id which resolves to nothing must be remembered too, otherwise the
// second branch walks the whole getter chain again
let lookups = 0
const objectResolver = new ObjectResolver({
...sharedApp,
getAnyObject: async id => {
lookups++
return sharedApp.getAnyObject(id)
},
})
const node = CM.parse('a:[resolve]:x:y b:[resolve]:x:z')
await objectResolver.buildResolver([{ a: 'ghost', b: 'ghost' }], node)
assert.equal(lookups, 1)
})
it('fetches the shared object only once', async function () {
let fetches = 0
const node = CM.parse(filter)
const objectResolver = new ObjectResolver({
...sharedApp,
getAnyObject: async id => {
if (id === 'shared') {
fetches++
}
return sharedApp.getAnyObject(id)
},
})
await objectResolver.buildResolver([object], node)
assert.equal(fetches, 1)
})
})
it('matches through a XAPI object onto a non-XAPI one', async function () {
const objectResolver = new ObjectResolver(makeApp())
const node = CM.parse('$VM:[resolve]:creation:user:[resolve]:email:"test@test.com"')
const object = { $VM: 'vm1' }
const resolver = await objectResolver.buildResolver(object, node)
assert(node.createPredicate(resolver)(object))
})
})
describe('getAnyObject', function () {
const notFound = async () => {
throw noSuchObject()
}
// `getAnyObject` looks an id up in three tiers, ordered by cost:
// 1. `getObject`, synchronous, every XAPI object
// 2. the cheap store reads, raced against each other
// 3. the costly backup archive and log lookups
const makeApp = (over = {}) => ({
getObject: id => {
if (id !== 'vm') {
throw noSuchObject(id)
}
return { id, type: 'VM' }
},
// an installation without an RBAC licence rejects these
getAclV2Privilege: async () => {
throw featureUnauthorized({ featureCode: 'RBAC' })
},
getAclV2Role: async () => {
throw featureUnauthorized({ featureCode: 'RBAC' })
},
getGroup: notFound,
getJob: notFound,
getProxy: notFound,
getRemote: notFound,
getSchedule: notFound,
tasks: { get: notFound },
getUser: async id => {
if (id !== 'user') {
throw noSuchObject(id)
}
return { id, email: 'test@test.com' }
},
getXenServer: notFound,
getVmBackupArchive: async id => {
if (id !== 'archive') {
throw noSuchObject(id)
}
return { id, kind: 'archive' }
},
getBackupNgLogs: async id => {
if (id !== 'log') {
throw noSuchObject(id)
}
return { id, message: 'backup' }
},
...over,
})
it('returns an XAPI object', async function () {
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('vm'), { id: 'vm', type: 'VM' })
})
it('returns an object found by one of the cheap getters', async function () {
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('user'), {
id: 'user',
email: 'test@test.com',
})
})
it('obfuscates the password of a resolved user', async function () {
let opts
const objectResolver = new ObjectResolver(
makeApp({
getUser: async (id, _opts) => {
opts = _opts
return { id, pw_hash: 'secret' }
},
})
)
await objectResolver.getAnyObject('user')
assert.deepEqual(opts, { obfuscatePassword: true })
})
it('ignores featureUnauthorized raised by the RBAC getters', async function () {
// `user` is only found once the RBAC rejections have been ignored
assert.deepEqual(await new ObjectResolver(makeApp()).getAnyObject('user'), {
id: 'user',
email: 'test@test.com',
})
})
it('falls through to the costly getters', async function () {
const objectResolver = new ObjectResolver(makeApp())
assert.deepEqual(await objectResolver.getAnyObject('archive'), { id: 'archive', kind: 'archive' })
assert.deepEqual(await objectResolver.getAnyObject('log'), { id: 'log', message: 'backup' })
})
it('does not reach the costly getters when a cheaper one matches', async function () {
let costlyCalls = 0
const costly = async () => {
costlyCalls++
throw noSuchObject()
}
const objectResolver = new ObjectResolver(makeApp({ getVmBackupArchive: costly, getBackupNgLogs: costly }))
await objectResolver.getAnyObject('vm')
await objectResolver.getAnyObject('user')
assert.equal(costlyCalls, 0)
})
it('throws noSuchObject when no collection holds the id', async function () {
await assert.rejects(
() => new ObjectResolver(makeApp()).getAnyObject('ghost'),
error => noSuchObject.is(error)
)
})
it('propagates a genuine error raised by a cheap getter', async function () {
const objectResolver = new ObjectResolver(
makeApp({
getGroup: async () => {
throw new Error('db down')
},
})
)
await assert.rejects(() => objectResolver.getAnyObject('ghost'), /db down/)
})
it('propagates a genuine error raised by a costly getter', async function () {
const objectResolver = new ObjectResolver(
makeApp({
getBackupNgLogs: async () => {
throw new Error('remote unreachable')
},
})
)
await assert.rejects(() => objectResolver.getAnyObject('ghost'), /remote unreachable/)
})
})
})

View File

@@ -10,7 +10,7 @@ import { hasLiveMountTarget } from '@xen-orchestra/backups/_vdiRestoreTargets.mj
import { HealthCheckVmBackup } from '@xen-orchestra/backups/HealthCheckVmBackup.mjs'
import { ImportVmBackup } from '@xen-orchestra/backups/ImportVmBackup.mjs'
import { createRunner } from '@xen-orchestra/backups/Backup.mjs'
import { invalidParameters, noMatchingVm } from 'xo-common/api-errors.js'
import { invalidParameters, noMatchingVm, noSuchObject } from 'xo-common/api-errors.js'
import { timeout } from 'promise-toolbox'
import { runBackupWorker } from '@xen-orchestra/backups/runBackupWorker.mjs'
import { Task } from '@vates/task'
@@ -22,6 +22,9 @@ import { serveVmBackups, VmBackupsCache } from './_vmBackupsCache.mjs'
import { VmBackupsSource } from './_vmBackupsSource.mjs'
import { waitAll } from '../../_waitAll.mjs'
// BR uuid/xo-vm-backups/VM uuid/(ISO 8601 compact).json
const BACKUP_ARCHIVE_ID_REGEX = /^([0-9a-fA-F-]{36})\/+xo-vm-backups\/+([0-9a-fA-F-]{36})\/+(\d{8}T\d{6}Z)\.json$/
const logger = createLogger('xo:xo-mixins:backups-ng')
/**
@@ -904,4 +907,24 @@ export default class BackupNg {
this.#vmBackupsCache.remove(remoteId)
this.#resetVmBackupsListingState(remoteId)
}
/**
* @param {XoVmBackupArchive['id']} id
* @returns {Promise<XoVmBackupArchive>}
*/
async getVmBackupArchive(id) {
const match = id.match(BACKUP_ARCHIVE_ID_REGEX)
if (match === null) {
throw noSuchObject(id, 'backup-archive')
}
const [, brId, vmId] = match
const backupArchive = (await this.listVmBackupsNg([brId]))[brId]?.[vmId]?.find(backup => backup.id === id)
if (backupArchive === undefined) {
throw noSuchObject(id, 'backup-archive')
}
return backupArchive
}
}

View File

@@ -0,0 +1,206 @@
import * as CM from 'complex-matcher'
import { asyncEach } from '@vates/async-each'
import { featureUnauthorized, noSuchObject } from 'xo-common/api-errors.js'
/**
* @typedef {import('@vates/types').XoApp} XoApp
* @typedef {import('@vates/types').XoRecord} XoRecord
* @typedef {import('complex-matcher').Node} CmNode
* @typedef {import('complex-matcher').Resolver} CmResolver
*/
const getAnyObjectsConcurrency = 10
export default class ObjectResolver {
/**
* @param {XoApp} app
*/
constructor(app) {
this._app = app
}
/**
* Synchronous resolver, limited to XAPI objects: they are the only ones held
* in memory and therefore the only ones reachable without awaiting.
*
* @template {import('@vates/types').XapiXoRecord} Record
* @param {Record['id']} id
* @returns {Record | undefined} `undefined` when the id is unknown
*/
xapiObjectResolver(id) {
try {
return this._app.getObject(id)
} catch {
return undefined
}
}
/**
* Build a resolver for a specific filter, by prefetching every object that
* filter will dereference.
*
* complex-matcher cannot do this itself: `match()` is synchronous, because the
* predicate it produces is handed to `Array#filter`. Yet only XAPI objects can
* be read synchronously, every other record is fetched from a database by the
* asynchronous `getAnyObject()`. So the awaiting is lifted out of matching and
* done up front and what matching gets is a plain synchronous lookup.
*
* Resolution is walked level by level because a level's ids are only known
* once the previous one has been fetched, and each id is fetched at most once
* for the whole filter run.
*
* @param {XoRecord | XoRecord[]} objects the objects the filter will be matched against
* @param {CmNode} filterNode
* @returns {Promise<CmResolver>}
*/
async buildResolver(objects, filterNode) {
/** @type {XoRecord[]} */
let objectArray
if (!Array.isArray(objects)) {
objectArray = [objects]
} else {
objectArray = objects
}
/** @type {Map<XoRecord['id'], XoRecord>} */
const anyObjects = new Map()
// ids which `getAnyObject()` could not resolve, remembered so that a filter
// reaching the same dangling id from another branch does not walk the whole
// getter chain again
/** @type {Set<XoRecord['id']>} */
const unresolvableIds = new Set()
/**
* @param {XoRecord[]} objectsToProcess
* @param {CmNode} node
* @returns {Promise<void>}
*/
const process = async (objectsToProcess, node) => {
const fields = CM.getResolveFields(node)
for (const field of fields) {
/** @type {Set<XoRecord['id']>} */
const objectIds = new Set()
for (const objectToProcess of objectsToProcess) {
const ids = field.path.reduce((object, path) => object?.[path], objectToProcess)
if (ids !== undefined) {
for (const id of Array.isArray(ids) ? ids : [ids]) {
objectIds.add(id)
}
}
}
await asyncEach(
objectIds,
async objectId => {
if (!anyObjects.has(objectId) && !unresolvableIds.has(objectId)) {
let fetchedObject
try {
fetchedObject = await this._app.getAnyObject(objectId)
} catch (error) {
if (!noSuchObject.is(error)) {
throw error
}
}
if (fetchedObject !== undefined) {
anyObjects.set(objectId, fetchedObject)
} else {
unresolvableIds.add(objectId)
}
}
},
{ concurrency: getAnyObjectsConcurrency }
)
/** @type {XoRecord[]} */
const resolvedObjects = []
for (const objectId of objectIds) {
const resolvedObject = anyObjects.get(objectId)
if (resolvedObject !== undefined) {
resolvedObjects.push(resolvedObject)
}
}
if (resolvedObjects.length > 0) {
await process(resolvedObjects, field.resolveNode.child)
}
}
}
await process(objectArray, filterNode)
return id => anyObjects.get(id)
}
/**
* Look an id up in every collection which can hold an `XoRecord`.
*
* Ordered by cost and parallelized where pertinent.
*
* @template {XoRecord} Record
* @param {Record['id']} id
* @returns {Promise<Record>}
* @throws `noSuchObject` when no collection holds this id
*/
async getAnyObject(id) {
// We do getObject first because it covers all XAPI object
try {
return await this._app.getObject(id)
} catch (error) {
if (!noSuchObject.is(error)) {
throw error
}
}
// Then the cheap getters in parallel
const parallelGetters = [
() => this._app.getAclV2Privilege(id),
() => this._app.getAclV2Role(id),
() => this._app.getGroup(id),
() => this._app.getJob(id),
() => this._app.getProxy(id),
() => this._app.getRemote(id),
() => this._app.getSchedule(id),
() => this._app.tasks.get(id),
() => this._app.getUser(id, { obfuscatePassword: true }),
() => this._app.getXenServer(id),
]
const parallelPromises = parallelGetters.map(async getter => getter())
try {
return await new Promise((resolve, reject) => {
let pending = parallelPromises.length
const errors = []
for (const promise of parallelPromises) {
promise.then(resolve, error => {
errors.push(error)
if (--pending === 0) {
reject(errors)
}
})
}
})
} catch (errors) {
for (const error of errors) {
if (!noSuchObject.is(error) && !featureUnauthorized.is(error)) {
throw error
}
}
}
// Then the more costly logs and archives
for (const getter of [() => this._app.getVmBackupArchive(id), () => this._app.getBackupNgLogs(id)]) {
try {
return await getter()
} catch (error) {
if (!noSuchObject.is(error) && !featureUnauthorized.is(error)) {
throw error
}
}
}
throw noSuchObject(id)
}
}