feat(rest-api): ACL V2 integration (#9774)

This commit is contained in:
Mathieu
2026-04-28 11:03:49 +02:00
committed by GitHub
parent 00560dd418
commit 63d4334837
119 changed files with 5386 additions and 406 deletions
+1
View File
@@ -3,4 +3,5 @@ export * from './xen-api.mjs'
export * from './xo.mjs'
export * from './xo-app.mjs'
export * from './lib/xen-orchestra-xapi.mjs'
export * from './lib/xen-orchestra-acl.mjs'
export * from './lib/complex-matcher.mjs'
@@ -0,0 +1,78 @@
import { Branded } from '../common.mjs'
import type { XoGroup, XoUser } from '../xo.mjs'
export type XoAclRole =
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
}
| {
id: Branded<'acl-v2-role'>
name: string
description?: string
isTemplate: true
roleTemplateId: number
}
export type XoAclSupportedActionsByResource = {
[resource: string]: Record<string, unknown>
}
/**
* E.g
* vms {
* shutdown: {
* clean: true,
* hard: true,
* }
* }
*
* `shutdown | shutdown:clean | shutdown:hard`
*/
export type GetKeysRecursively<T, Prefix extends string = ''> = {
[K in keyof T]: T[K] extends object
? K extends string
? `${Prefix}${K}` | GetKeysRecursively<T[K], `${Prefix}${K}:`>
: never
: K extends string
? `${Prefix}${K}`
: never
}[keyof T]
export type XoAclSupportedResource<TActionsByResource extends XoAclSupportedActionsByResource> =
keyof TActionsByResource
export type XoAclSupportedActions<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = (GetKeysRecursively<TActionsByResource[TResource]> & string) | '*'
export type XoAclBasePrivilege = {
id: Branded<'acl-v2-privilege'>
resource: string
action: string
selector?: string
effect: 'allow' | 'deny'
roleId: XoAclRole['id']
}
export type XoAclPrivilege<
TActionsByResource extends XoAclSupportedActionsByResource,
TResource extends XoAclSupportedResource<TActionsByResource>,
> = XoAclBasePrivilege & {
resource: TResource
action: XoAclSupportedActions<TActionsByResource, TResource>
}
export type XoUserRole = {
id: string
roleId: XoAclRole['id']
userId: XoUser['id']
}
export type XoGroupRole = {
id: string
roleId: XoAclRole['id']
groupId: XoGroup['id']
}
+9 -1
View File
@@ -25,6 +25,7 @@ import type {
} from '../common.mjs'
import type { PassThrough, Readable } from 'node:stream'
import type {
XapiXoRecord,
XoGpuGroup,
XoHost,
XoNetwork,
@@ -72,12 +73,19 @@ export interface Xapi {
callAsync: <ReturnType>(...args: unknown[]) => Promise<ReturnType>
barrier<T extends XenApiRecord>(ref: T['$ref']): Promise<Extract<WrappedXenApiRecord, { $ref: T['$ref'] }>>
connectVif(vifId: XoVif['id']): Promise<void>
getField<T extends XenApiRecord, K extends keyof T>(
type: Extract<WrappedXenApiRecord, T>['$type'],
ref: T['$ref'],
field: K
): Promise<T[K]>
connectVif(vifId: XoVif['id']): Promise<void>
getObject: <
XoRecord extends XapiXoRecord,
WrappedRecord extends WrappedXenApiRecord = Extract<WrappedXenApiRecord, { $type: XoRecord['type'] }>,
>(
idOrUuidOrRef: XoRecord['id'] | WrappedRecord['$ref'] | WrappedRecord['uuid'],
defaultValue?: WrappedRecord
) => WrappedRecord
createNetwork(
params:
| {
+42
View File
@@ -44,6 +44,10 @@ import {
XenApiVifWrapped,
XenApiVmWrapped,
XenApiVtpmWrapped,
XoAclBasePrivilege,
XoAclRole,
XoGroupRole,
XoUserRole,
} from './index.mjs'
export type XapiConnection = Xapi & {
@@ -128,6 +132,8 @@ export type XoApp = {
}
// methods ------------
addAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<XoGroupRole>
addAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<XoUserRole>
addUserToGroup: (userId: XoUser['id'], groupId: XoGroup['id']) => Promise<void>
authenticateUser: (
credentials: { token?: string; username?: string; password?: string },
@@ -138,6 +144,18 @@ export type XoApp = {
checkFeatureAuthorization(featureCode: string): Promise<void>
/* connect a server (XCP-ng/XenServer) */
connectXenServer(id: XoServer['id']): Promise<void>
// TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union)
createAclV2Privilege(
privilege: Omit<XoAclBasePrivilege, 'id'>,
options?: {
force?: boolean
}
): Promise<XoAclBasePrivilege>
copyAclV2Role(
id: XoAclRole['id'],
params?: { name?: XoAclRole['name']; description?: XoAclRole['description'] }
): Promise<XoAclRole['id']>
createAclV2Role(role: { name: XoAclRole['name']; description?: XoAclRole['description'] }): Promise<XoAclRole>
createAuthenticationToken(opts: {
client?: {
id?: string
@@ -148,11 +166,21 @@ export type XoApp = {
userId: XoUser['id']
}): Promise<XoAuthenticationToken>
createUser(params: { name?: string; password?: string; [key: string]: unknown }): Promise<XoUser>
deleteAclV2GroupRole(groupId: XoGroup['id'], roleId: XoAclRole['id']): Promise<boolean>
deleteAclV2Privilege(privilegeId: XoAclBasePrivilege['id'], options?: { force?: boolean }): Promise<boolean>
deleteAclV2Role(roleId: XoAclRole['id'], options?: { force?: boolean }): Promise<boolean>
deleteGroup(id: XoGroup['id']): Promise<void>
deleteUser(id: XoUser['id']): Promise<void>
createGroup(params: { name: string; provider?: string; providerGroup?: string }): Promise<XoGroup>
/* disconnect a server (XCP-ng/XenServer) */
disconnectXenServer(id: XoServer['id']): Promise<void>
getAclV2Privilege(id: XoAclBasePrivilege['id']): Promise<XoAclBasePrivilege>
getAclV2Privileges(): Promise<XoAclBasePrivilege[]>
getAclV2RolePrivileges(roleId: XoAclRole['id']): Promise<XoAclBasePrivilege[]>
getAclV2Role(id: XoAclRole['id']): Promise<XoAclRole>
deleteAclV2UserRole(userId: XoUser['id'], roleId: XoAclRole['id']): Promise<boolean>
getAclV2Roles(): Promise<XoAclRole[]>
getAclV2UserPrivileges(userId: XoUser['id']): Promise<XoAclBasePrivilege[]>
getAllGroups(): Promise<XoGroup[]>
getAllProxies(): Promise<XoProxy[]>
getAllJobs<T extends AnyXoBackupJob['type']>(type: T): Promise<Extract<AnyXoBackupJob, { type: T }>[]>
@@ -246,6 +274,20 @@ export type XoApp = {
preferences?: Record<string, string>
}
): Promise<void>
updateAclV2Privilege(
privilegeId: XoAclBasePrivilege['id'],
privilege: XoAclBasePrivilege
): Promise<XoAclBasePrivilege>
updateAclV2Role(
roleId: XoAclRole['id'],
role: {
name?: XoAclRole['name']
description?: XoAclRole['description'] | null
},
options?: {
force?: boolean
}
): Promise<XoAclRole>
updateGroup(
id: XoGroup['id'],
updates: {
+13 -2
View File
@@ -19,6 +19,7 @@ import type {
VM_POWER_STATE,
} from './common.mjs'
import type * as CMType from './lib/complex-matcher.mjs'
import { XoAclPrivilege, XoAclRole, XoAclSupportedActionsByResource } from './lib/xen-orchestra-acl.mjs'
import type { XenApiHost, XenApiPool } from './xen-api.mjs'
type BaseXapiXo = {
@@ -656,6 +657,8 @@ export type XoTask = {
| 'server'
| 'task'
| 'user'
| 'acl-privilege'
| 'acl-role'
params?: Record<string, unknown>
progress?: number
type?: string
@@ -850,7 +853,10 @@ export type XapiXoRecord =
| XoVtpm
| XoSm
export type NonXapiXoRecord =
export type NonXapiXoRecord<
TActionsByResource extends XoAclSupportedActionsByResource = never,
TResource extends string = never,
> =
| AnyXoBackupArchive
| AnyXoJob
| AnyXoLog
@@ -864,8 +870,13 @@ export type NonXapiXoRecord =
| XoServer
| XoTask
| XoUser
| XoAclRole
| XoAclPrivilege<TActionsByResource, TResource>
export type XoRecord = XapiXoRecord | NonXapiXoRecord
export type XoRecord<
TActionsByResource extends XoAclSupportedActionsByResource = never,
TResource extends string = never,
> = XapiXoRecord | NonXapiXoRecord<TActionsByResource, TResource>
export type AnyXoVm = XoVm | XoVmSnapshot | XoVmTemplate | XoVmController
+1
View File
@@ -0,0 +1 @@
../../scripts/npmignore
+16
View File
@@ -0,0 +1,16 @@
// @ts-check
import eslint from '@eslint/js'
import tseslint from 'typescript-eslint'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
export default tseslint.config(eslint.configs.recommended, tseslint.configs.recommended, {
languageOptions: {
parserOptions: {
tsconfigRootDir: __dirname,
},
}
})
+47
View File
@@ -0,0 +1,47 @@
{
"author": {
"name": "Vates SAS",
"url": "https://vates.fr"
},
"main": "dist/index.mjs",
"name": "@xen-orchestra/acl",
"homepage": "https://github.com/vatesfr/xen-orchestra/tree/master/@xen-orchestra/acl",
"version": "0.0.0",
"license": "AGPL-3.0-or-later",
"private": false,
"type": "module",
"engines": {
"node": ">= 20.18"
},
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsc && node scripts/generate-types.mjs",
"clean": "rimraf dist/",
"dev": "tsc --watch",
"postversion": "npm publish --access public",
"test": "npm run build && node --test ./dist/**/*.test.mjs",
"prebuild": "npm run clean",
"prepublishOnly": "npm run build"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@vates/types": "^1.16.0",
"rimraf": "^6.0.1",
"typescript": "~5.6",
"typescript-eslint": "^8.23.0"
},
"bugs": "https://github.com/vatesfr/xen-orchestra/issues",
"repository": {
"directory": "@xen-orchestra/acl",
"type": "git",
"url": "https://github.com/vatesfr/xen-orchestra.git"
},
"dependencies": {
"complex-matcher": "^1.0.0"
}
}
@@ -0,0 +1,55 @@
#!/usr/bin/env node
// AUTO-GENERATES dist/generated/privilege-types.d.mts
// Run via `npm run build` (after tsc).
import { mkdirSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../dist/actions/index.mjs'
import { getActionStrings } from '../dist/index.mjs'
function toPascalCase(str) {
return str
.split(/[-_]/)
.map(s => s.charAt(0).toUpperCase() + s.slice(1))
.join('')
}
const resources = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE)
const typeBlocks = resources.map(resource => {
const actionUnion = getActionStrings(resource)
.map(action => `'${action}'`)
.join(' | ')
return `export type ${toPascalCase(resource)}Privilege = {
id: XoAclBasePrivilege['id']
resource: '${resource}'
action: ${actionUnion}
selector?: XoAclBasePrivilege['selector']
effect: XoAclBasePrivilege['effect']
roleId: XoAclBasePrivilege['roleId']
}`
})
const anyUnion = resources.map(resource => ` | ${toPascalCase(resource)}Privilege`).join('\n')
const types = `\
// -----------------------------------------------------------------------
// AUTO-GENERATED — do not edit manually.
// Regenerated on every \`npm run build\`.
// -----------------------------------------------------------------------
import type { XoAclBasePrivilege } from '@vates/types'
${typeBlocks.join('\n\n')}
export type AnyPrivilege =
${anyUnion}
`
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const outDir = join(root, 'dist/generated')
mkdirSync(outDir, { recursive: true })
writeFileSync(join(outDir, 'privilege-types.d.mts'), types, 'utf-8')
@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
action: true,
effect: true,
resource: true,
selector: true,
},
}
@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
description: true,
groups: true,
name: true,
users: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
+9
View File
@@ -0,0 +1,9 @@
export default {
create: true,
delete: true,
read: true,
update: {
name: true,
users: true,
},
}
+10
View File
@@ -0,0 +1,10 @@
export default {
'allow-vm': true,
export: {
logs: true,
},
read: true,
update: {
tags: true,
},
}
+80
View File
@@ -0,0 +1,80 @@
import { default as aclPrivilege } from './acl-privilege.mjs'
import { default as aclRole } from './acl-role.mjs'
import { default as alarm } from './alarm.mjs'
import { default as backupArchive } from './backup-archive.mjs'
import { default as backupJob } from './backup-job.mjs'
import { default as backupLog } from './backup-log.mjs'
import { default as backupRepository } from './backup-repository.mjs'
import { default as gpuGroup } from './gpuGroup.mjs'
import { default as group } from './group.mjs'
import { default as host } from './host.mjs'
import { default as message } from './message.mjs'
import { default as network } from './network.mjs'
import { default as pbd } from './pbd.mjs'
import { default as pci } from './pci.mjs'
import { default as pgpu } from './pgpu.mjs'
import { default as pif } from './pif.mjs'
import { default as pool } from './pool.mjs'
import { default as proxy } from './proxy.mjs'
import { default as restoreLog } from './restore-log.mjs'
import { default as schedule } from './schedule.mjs'
import { default as server } from './server.mjs'
import { default as sm } from './sm.mjs'
import { default as sr } from './sr.mjs'
import { default as task } from './task.mjs'
import { default as user } from './user.mjs'
import { default as vbd } from './vbd.mjs'
import { default as vdiSnapshot } from './vdi-snapshot.mjs'
import { default as vdiUnmanaged } from './vdi-unmanaged.mjs'
import { default as vdi } from './vdi.mjs'
import { default as vgpu } from './vgpu.mjs'
import { default as vgpuType } from './vgpuType.mjs'
import { default as vif } from './vif.mjs'
import { default as vmController } from './vm-controller.mjs'
import { default as vmSnapshot } from './vm-snapshot.mjs'
import { default as vmTemplate } from './vm-template.mjs'
import { default as vm } from './vm.mjs'
import { default as vtpm } from './vtpm.mjs'
/**
* value without sub action like 'shutdown' mean: 'shutdown:*' (so shutdown mean you can shutdown:clean and shutdown:hard)
*/
export const SUPPORTED_ACTIONS_BY_RESOURCE = {
'acl-privilege': aclPrivilege,
'acl-role': aclRole,
alarm,
'backup-archive': backupArchive,
'backup-job': backupJob,
'backup-log': backupLog,
'backup-repository': backupRepository,
group,
gpuGroup,
host,
message,
network,
pbd,
pci,
pgpu,
pif,
pool,
proxy,
'restore-log': restoreLog,
schedule,
server,
sm,
sr,
task,
user,
vbd,
'vdi-snapshot': vdiSnapshot,
'vdi-unmanaged': vdiUnmanaged,
vdi,
vgpu,
vgpuType,
vif,
'vm-controller': vmController,
'vm-snapshot': vmSnapshot,
'vm-template': vmTemplate,
vm,
vtpm,
} as const
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,8 @@
export default {
create: true,
delete: true,
read: true,
update: {
tags: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+13
View File
@@ -0,0 +1,13 @@
export default {
create: {
network: true,
vm: true,
},
'emergency-shutdown': true,
read: true,
'rolling-reboot': true,
'rolling-update': true,
update: {
tags: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,4 @@
export default {
read: true,
run: true,
}
@@ -0,0 +1,7 @@
export default {
connect: true,
create: true,
delete: true,
disconnect: true,
read: true,
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+10
View File
@@ -0,0 +1,10 @@
export default {
import: {
vdi: true,
vm: true,
},
read: true,
update: {
tags: true,
},
}
+5
View File
@@ -0,0 +1,5 @@
export default {
abort: true,
delete: true,
read: true,
}
+11
View File
@@ -0,0 +1,11 @@
export default {
create: true,
delete: true,
read: true,
update: {
name: true,
password: true,
permission: true,
preferences: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
+11
View File
@@ -0,0 +1,11 @@
export default {
boot: true,
create: true,
delete: true,
'export-content': true,
'import-content': true,
read: true,
update: {
tags: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
@@ -0,0 +1,3 @@
export default {
read: true,
}
+4
View File
@@ -0,0 +1,4 @@
export default {
read: true,
create: true,
}
@@ -0,0 +1,6 @@
export default {
read: true,
update: {
tags: true,
},
}
@@ -0,0 +1,8 @@
export default {
delete: true,
export: true,
read: true,
update: {
tags: true,
},
}
@@ -0,0 +1,9 @@
export default {
delete: true,
export: true,
instantiate: true,
read: true,
update: {
tags: true,
},
}
+23
View File
@@ -0,0 +1,23 @@
export default {
delete: true,
export: true,
pause: true,
read: true,
reboot: {
clean: true,
hard: true,
},
resume: true,
shutdown: {
clean: true,
hard: true,
},
snapshot: true,
start: true,
suspend: true,
unpause: true,
update: {
datasources: true,
tags: true,
},
}
+3
View File
@@ -0,0 +1,3 @@
export default {
read: true,
}
+119
View File
@@ -0,0 +1,119 @@
import * as CM from 'complex-matcher'
import type { XoAclPrivilege, XoAclSupportedActions, XoAclSupportedResource } from '@vates/types/lib/xen-orchestra/acl'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from '../actions/index.mjs'
export type SupportedActionsByResource = typeof SUPPORTED_ACTIONS_BY_RESOURCE
export type SupportedResource = XoAclSupportedResource<SupportedActionsByResource>
export type SupportedActions<T extends SupportedResource> = XoAclSupportedActions<SupportedActionsByResource, T>
export type TPrivilege<T extends SupportedResource> = XoAclPrivilege<SupportedActionsByResource, T>
export class Privilege<T extends SupportedResource> {
#action: TPrivilege<T>['action']
#selector?: (object: object) => boolean
#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']
}) {
Privilege.checkActionIsValid(resource, action)
this.#action = action
this.#selector = selector !== undefined ? CM.parse(selector).createPredicate() : undefined
this.#resource = resource
this.#effect = effect
}
get effect() {
return this.#effect
}
#matchAction<Resource extends SupportedResource = T>(action: SupportedActions<Resource>) {
// read:name_label - read:name_label
if (this.#action === action) {
return true
}
const [namespaceToMatch, actionToMatch] = action.split(':')
const [thisNamespace, thisAction] = this.#action.split(':')
if (thisNamespace === '*') {
return true
}
// update:vm - read:name_label
if (thisNamespace !== namespaceToMatch) {
return false
}
// read - read:name_label
if (thisAction === undefined) {
return true
}
// read:name_decription - read:name_label
if (thisAction !== actionToMatch) {
return false
}
throw new Error(`Unable to verify if ${this.#action} match ${action} `)
}
#matchSelector(object: object) {
if (this.#selector === undefined) {
return true
}
return this.#selector(object)
}
#matchResource(resource: string): resource is SupportedResource {
return resource === this.#resource
}
match<Resource extends SupportedResource = T>(constraint: {
action: SupportedActions<Resource>
resource: Resource
object: object
}): boolean {
return (
this.#matchResource(constraint.resource) &&
this.#matchAction(constraint.action) &&
this.#matchSelector(constraint.object)
)
}
/**
* Throw if action not supported
*/
static checkActionIsValid<T extends SupportedResource>(resource: T, action: SupportedActions<T>) {
const supportedActions = SUPPORTED_ACTIONS_BY_RESOURCE[resource]
if (supportedActions === undefined) {
throw new Error(`${resource} resource not supported`)
}
if (action === '*') {
return
}
const segments = action.split(':')
let _action: object | undefined = undefined
segments.forEach(segment => {
_action = (_action ?? supportedActions)[segment]
if (_action === undefined) {
throw new Error(
`${action} action not supported for the resource: ${resource}. See ${JSON.stringify(supportedActions)}`
)
}
})
}
}
@@ -0,0 +1,10 @@
// DO NOT EDIT
// Overwritten by scripts/generate-types.mjs after running npm run build
// scripts/generate-types.mjs will replace this file to automatically generate and export all privileged types.
// This makes external use easier because it's not necessary to resolve the type every time.
import type { Privilege, SupportedResource } from '../index.mjs'
export type AnyPrivilege = {
[Resource in SupportedResource]: Privilege<Resource>
}[SupportedResource]
+167
View File
@@ -0,0 +1,167 @@
import assert from 'node:assert'
import type { XoUser } from '@vates/types/xo'
import { SUPPORTED_ACTIONS_BY_RESOURCE } from './actions/index.mjs'
import {
Privilege as CPrivilege,
TPrivilege as Privilege,
SupportedActions,
SupportedActionsByResource,
SupportedResource,
} from './class/privilege.mjs'
// Export all types constructed using SUPPORTED_ACTIONS_BY_RESOURCE
export type { Privilege, SupportedActions, SupportedActionsByResource, SupportedResource }
export { SUPPORTED_ACTIONS_BY_RESOURCE }
export type * from './generated/privilege-types.mjs'
import { AnyPrivilege } from './generated/privilege-types.mjs'
export type AnyPrivilegeOnParam = {
[Resource in SupportedResource]: {
user: XoUser
resource: Resource
action: SupportedActions<Resource>
objects: object | object[]
}
}[SupportedResource]
export function hasPrivilegeOn<T extends SupportedResource>({
user,
action,
resource,
objects,
userPrivileges,
}: {
user: XoUser
resource: T
action: SupportedActions<T>
objects: object | object[]
userPrivileges: AnyPrivilege[]
}) {
// Function that will be called outside of the module
// We cannot be sure types are respected
assert.strictEqual(typeof user?.permission, 'string')
assert.strictEqual(typeof action, 'string')
assert.strictEqual(typeof resource, 'string')
assert.strictEqual(objects === undefined, false)
CPrivilege.checkActionIsValid(resource, action as SupportedActions<typeof resource>)
if (user.permission === 'admin') {
return true
}
const effectsByObject = new Map<object, Privilege<T>['effect'][]>()
if (Array.isArray(objects)) {
objects.forEach(obj => effectsByObject.set(obj, []))
} else {
effectsByObject.set(objects, [])
}
for (const userPrivilege of userPrivileges) {
const privilege = new CPrivilege(userPrivilege as Privilege<typeof userPrivilege.resource>)
for (const [object, effects] of effectsByObject.entries()) {
if (!privilege.match({ action, resource, object })) {
continue
}
effects.push(privilege.effect)
}
}
if (
Array.from(effectsByObject.values()).some(
effects => effects.length === 0 || effects.some(effect => effect === 'deny')
)
) {
return false
}
return true
}
export function getMissingPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
return params
.filter(
param =>
!hasPrivilegeOn({
user: param.user,
resource: param.resource,
action: param.action as SupportedActions<typeof param.resource>,
objects: param.objects,
userPrivileges,
})
)
.map(({ action, objects, resource }) => {
let objectIds: unknown[] | undefined
let objectId: unknown | undefined
if (Array.isArray(objects)) {
if (objects.length > 1) {
objectIds = []
objects.forEach(obj => {
if ('id' in obj) {
objectIds!.push(obj.id)
}
})
} else {
objects = objects?.[0] ?? {}
}
}
if (!Array.isArray(objects)) {
objectId = 'id' in objects ? objects.id : undefined
}
return {
objectId,
objectIds,
action,
resource,
}
})
}
export function hasPrivileges(params: AnyPrivilegeOnParam[], userPrivileges: AnyPrivilege[]) {
return getMissingPrivileges(params, userPrivileges).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 }))
}
// Utils
/**
* Mirror GetKeysRecursively from @vates/types
*/
export function getActionStrings<Resource extends SupportedResource, Actions = SupportedActions<Resource>>(
resource: Resource
): Actions[] {
type NestedActions = { [key: string]: boolean | NestedActions }
function collectActions(obj: NestedActions, prefix?: Actions): Actions[] {
const actions: Actions[] = ['*' as Actions]
for (const [key, value] of Object.entries(obj)) {
const action = (prefix !== undefined ? `${prefix}:${key}` : key) as Actions
actions.push(action)
if (typeof value === 'boolean') {
continue
}
actions.push(...collectActions(value, action))
}
return actions
}
return collectActions(SUPPORTED_ACTIONS_BY_RESOURCE[resource])
}
+378
View File
@@ -0,0 +1,378 @@
import { test, suite } from 'node:test'
import assert from 'node:assert'
import type { Branded } from '@vates/types/common'
import type { XoUser } from '@vates/types/xo'
import { Privilege } from '../class/privilege.mjs'
import type { Privilege as TPrivilege } from '../index.mjs'
import {
filterObjectsWithPrivilege,
getActionStrings,
getMissingPrivileges,
hasPrivilegeOn,
hasPrivileges,
} from '../index.mjs'
suite('Privilege.checkActionIsValid behavior', () => {
test('throw on invalid resource', () => {
// @ts-expect-error test invalid action
assert.throws(() => Privilege.checkActionIsValid('foo', 'read'))
})
test('throw on invalid action', () => {
// @ts-expect-error test invalid action
assert.throws(() => Privilege.checkActionIsValid('vm', 'foo:vm'))
})
test('should work', () => {
assert.doesNotThrow(() => Privilege.checkActionIsValid('vm', 'read'))
})
})
suite('Privilege.match behavior', () => {
const privilege = new Privilege({ action: 'read', resource: 'vm', effect: 'allow' })
const object = { id: 1, name_label: 'foo' }
test('Should not match because of the action', () => {
assert.strictEqual(privilege.match({ resource: 'vm', action: 'start', object }), false)
})
test('Should not match because of the resource', () => {
assert.strictEqual(privilege.match({ resource: 'host', action: 'read', object }), false)
})
test('Should match', () => {
assert.strictEqual(privilege.match({ resource: 'vm', action: 'read', object }), true)
})
test('Should match even if the action is not equal to the action privilege', () => {
const shutdownPrivilege = new Privilege({ action: 'shutdown', resource: 'vm', effect: 'allow' })
assert.strictEqual(shutdownPrivilege.match({ resource: 'vm', action: 'shutdown:clean', object }), true)
})
test('Should match because the action is *', () => {
const vmPrivilege = new Privilege({ action: '*', resource: 'vm', effect: 'allow' })
assert.strictEqual(vmPrivilege.match({ action: 'reboot:hard', resource: 'vm', object: object }), true)
})
test('Should not match due to insufficient privilege', () => {
const cleanShutdownPrivilege = new Privilege({ action: 'shutdown:clean', resource: 'vm', effect: 'allow' })
assert.strictEqual(cleanShutdownPrivilege.match({ resource: 'vm', action: 'shutdown', object }), false)
})
})
suite('ACL V2 behavior', async () => {
// === Setup ===
const admin = {
id: '1' as XoUser['id'],
groups: [],
permission: 'admin',
email: '[email protected]',
preferences: {},
}
const user = {
id: '2' as XoUser['id'],
groups: [],
permission: 'user',
email: '[email protected]',
preferences: {},
}
const privilegeId = 'fake-priv-test-id' as Branded<'acl-v2-privilege'>
const roleId = 'fake-role-test-id' as Branded<'acl-v2-role'>
// === Objects
const poolWithoutPrivilege = { id: 'pool-id' }
const alpineVm = { id: 'alpine', creation: { creator: admin.id } }
const almaVm = { id: 'alma', creation: { creator: user.id } }
const xoaVm = { id: 'xoa' }
const template = { id: 'template-id', $pool: '1' }
const templateDeny = { id: 'template-deny-id', $pool: '2' }
const vdis = [{ $SR: '2' }, { $SR: '2' }]
const vdisMultipleSrs = [{ $SR: '1' }, { $SR: '2' }]
const vifs = [{ $network: '1' }]
const iso = { id: 'iso-vdi-id', $SR: '3' }
const hostAffinity = { id: 'affinity-host-id', $pool: '1' }
// === DB Privileges
const vmRead = { id: privilegeId, action: 'read', resource: 'vm', effect: 'allow', roleId } satisfies TPrivilege<'vm'>
const vmCleanShutdown = {
id: privilegeId,
action: 'shutdown:clean',
resource: 'vm',
selector: `creation:creator:${user.id}`,
effect: 'allow',
roleId,
} satisfies TPrivilege<'vm'>
const xoaVmDenyRead = {
id: privilegeId,
action: 'read',
resource: 'vm',
effect: 'deny',
selector: `id: ${xoaVm.id}`,
roleId,
} satisfies TPrivilege<'vm'>
const vdiCreate = {
id: privilegeId,
action: 'create',
resource: 'vdi',
selector: '$SR:2',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const vifCreate = {
id: privilegeId,
action: 'create',
resource: 'vif',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vif'>
const templateInstantiate = {
id: privilegeId,
action: 'instantiate',
resource: 'vm-template',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vm-template'>
const templateInstantiateDeny = {
id: privilegeId,
action: 'instantiate',
resource: 'vm-template',
selector: '$pool:2',
effect: 'deny',
roleId,
} satisfies TPrivilege<'vm-template'>
const isoVdiUse = {
id: privilegeId,
action: 'boot',
resource: 'vdi',
selector: '$SR:3',
effect: 'allow',
roleId,
} satisfies TPrivilege<'vdi'>
const hostVm = {
id: privilegeId,
action: 'allow-vm',
resource: 'host',
effect: 'allow',
roleId,
} satisfies TPrivilege<'host'>
const allPrivileges = [
vmRead,
vmCleanShutdown,
xoaVmDenyRead,
vdiCreate,
vifCreate,
templateInstantiate,
templateInstantiateDeny,
isoVdiUse,
hostVm,
]
// === Test ===
suite('hasPrivilegeOn behavior', () => {
test('Should always return true for admin', () => {
assert.strictEqual(
hasPrivilegeOn<'pool'>({
user: admin,
action: 'read',
resource: 'pool',
objects: poolWithoutPrivilege,
userPrivileges: allPrivileges,
}),
true
)
})
test('Should return false by default for non admin', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'pool',
objects: poolWithoutPrivilege,
userPrivileges: allPrivileges,
}),
false
)
})
test('Should return true if user has privilege', () => {
assert.strictEqual(
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: alpineVm, userPrivileges: allPrivileges }),
true
)
})
test('Should return false if user have a both deny and allow privileges', () => {
assert.strictEqual(
hasPrivilegeOn({ user, action: 'read', resource: 'vm', objects: xoaVm, userPrivileges: allPrivileges }),
false
)
})
test('Should handle multiple and single object', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'vm',
objects: [alpineVm, almaVm],
userPrivileges: allPrivileges,
}),
true
)
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'read',
resource: 'vm',
objects: [alpineVm, almaVm, xoaVm],
userPrivileges: allPrivileges,
}),
false
)
})
test('Should allow clean shutdown only own VMs', () => {
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'shutdown:clean',
resource: 'vm',
objects: almaVm,
userPrivileges: allPrivileges,
}),
true
)
assert.strictEqual(
hasPrivilegeOn({
user,
action: 'shutdown:clean',
resource: 'vm',
objects: [almaVm, alpineVm],
userPrivileges: allPrivileges,
}),
false
)
})
})
suite('getMissingPrivileges behavior', () => {
test('Should return empty array if no missing privileges', () => {
const missingPrivileges = getMissingPrivileges(
[
{ user, action: 'shutdown:clean', resource: 'vm', objects: almaVm },
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 0)
})
test('Should return only missing privileges', () => {
const missingPrivileges = getMissingPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege },
{ user, action: '*', resource: 'pool', objects: poolWithoutPrivilege },
],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 2)
})
test('Should return objectId if only one object in the missing privilege', () => {
const missingPrivileges = getMissingPrivileges(
[{ user, action: 'read', resource: 'pool', objects: poolWithoutPrivilege }],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 1)
assert.notStrictEqual(missingPrivileges[0]!.objectId, undefined)
})
test('Should return objectIds if multiple objects in the missing privilege', () => {
const missingPrivileges = getMissingPrivileges(
[{ user, action: 'read', resource: 'pool', objects: [poolWithoutPrivilege, poolWithoutPrivilege] }],
allPrivileges
)
assert.strictEqual(missingPrivileges.length, 1)
assert.notStrictEqual(missingPrivileges[0]!.objectIds, undefined)
})
})
suite('hasPrivileges behavior', () => {
test('Should return false if at least one privilege deny', () => {
const result = hasPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: templateDeny },
{ user, action: 'create', resource: 'vdi', objects: vdis },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(result, false)
})
test("Should return false if at least one privilege didn't match", () => {
const result = hasPrivileges(
[
{ user, action: 'instantiate', resource: 'vm-template', objects: template },
{ user, action: 'create', resource: 'vdi', objects: vdisMultipleSrs },
{ user, action: 'create', resource: 'vif', objects: vifs },
{ user, action: 'boot', resource: 'vdi', objects: iso },
{ user, action: 'allow-vm', resource: 'host', objects: hostAffinity },
],
allPrivileges
)
assert.strictEqual(result, false)
})
})
suite('filterObjectsWithPrivilege behavior', () => {
test('Should return only objects that match privileges', () => {
let vms = [almaVm, alpineVm, xoaVm, xoaVm]
assert.strictEqual(vms.length, 4)
vms = filterObjectsWithPrivilege({
user,
action: 'read',
resource: 'vm',
objects: vms,
userPrivileges: allPrivileges,
})
assert.strictEqual(vms.length, 2)
})
})
})
suite('getActionStrings', () => {
test('returns flat actions for a resource without nesting', () => {
const actions = getActionStrings('host')
assert.ok(actions.includes('read'))
// @ts-ignore A TypeScript error is thrown because `foo` is not of type `SupportedActions<'vm'>`.
// To test for a negative value, the error is ignored.
assert.strictEqual(actions.includes('foo'), false)
})
test('parent action is included alongside its children', () => {
const actions = getActionStrings('vm')
assert.ok(actions.includes('shutdown'))
assert.ok(actions.includes('shutdown:clean'))
assert.ok(actions.includes('shutdown:hard'))
})
})
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"module": "nodenext",
"lib": ["ESNext"],
"moduleResolution": "nodenext",
"target": "ES2022",
"skipLibCheck": true,
"types": ["node"], // Only use types from Node.js and packages that are explicitly imported
"noImplicitAny": false, // otherwise it will throw an error when importing packages without type definition
"noUncheckedIndexedAccess": true,
"declaration": true
},
"include": ["./src/**/*"],
"exclude": ["./node_modules", "./dist"]
}
+108 -1
View File
@@ -60,7 +60,114 @@ class Foo extends Controller {
### Examples
In order not to pollute important decorators, all example structures should be in a separate file. `src/open-api/examples/<resource>.example.mts`
In order not to pollute important decorators, all example structures should be in a separate file. `src/open-api/oa-examples/<resource>.oa-example.mts`
### ACLs
To define an ACL for an endpoint, simply add the `acl` middleware and pass the required ACL(s).
If an endpoint does not have a middleware ACL, it will be accessible **ONLY** to administrators.
It is sometimes necessary to check ACLs based on the body of the request sent by the user (for example, for a PATCH endpoint). For this, you can use `actions` (which allows you to pass multiple actions) and `actionsFromBody` (a function exported from `acl.middleware.mts`).
`actionsFromBody(['update:name_label', 'update:name_description'])` checks if `name_label` is present in the request body, and then applies the ACL check. The same applies to `name_description`.
`actionIfNotSelfUser('read')` returns the given action only if the current user is **not** the target user. If the current user is the target (self), no action is returned and the ACL check is skipped entirely.
#### Guidelines
- **JSDoc Documentation**: Always document the required privileges in the JSDoc annotation so users know which permissions are needed. Use the format: `Required privilege: - ...`
- **Error Handling**: If you define an ACL for an endpoint, you **must** add a `@Response(403)` decorator.
- **Non XAPI objects**: When dealing with non XAPI XO Record, you must define the `getObject` function.
##### Example: ACL on an existing resource
```ts
/**
* Start a VM
*
* Required privilege:
* - resource: vm, action: start
*/
@Post('{id}/actions/start')
@Middlewares(acl({resource: 'vm', action: 'start', objectId: 'params.id'}))
@Response(403)
getVm(@Path() id: string) {
const action = async () => {
const vm = await this.getObject(id)
// ...
}
}
```
##### Example: Resource creation
When creating a resource (which doesn't exist yet), pass the object being created as the target:
```ts
/**
* Create a new VDI
*
* Required privilege:
* - resource: vdi, action: create
*/
@Post('/')
@Middlewares(acl({resource: 'vdi', action: 'create', object: ({req}) => {
const {srId,...rest} = req.body
return {$SR: srId, ...rest}
}}))
@Response(403)
createVdi(@Body() body: VdiConfig) {
const {srId, ...rest}
const bodyParam = {$SR: srId, ...rest}
await VDI_create(bodyParam)
// ...
}
```
##### Example: Resource update
When creating a resource (which doesn't exist yet), pass the object being created as the target:
```ts
/**
* Update a VM
*
* Required privileges:
* - resource: vm, action: update (grants all fields)
* - resource: vm, action: update:name_label (if name_label is passed)
* - resource: vm, action: update:name_description (if name_description is passed)
*/
@Patch('{id}')
@Middlewares(acl({resource: 'vm', actions: actionsFromBody(['update:name_label', 'update:name_description']), objectId: 'params.id'}))
@Response(403)
createVdi(@Path() id: string, @Body() body: patchBody) {
updateVm(id, body)
// ...
}
```
##### Example: Self-bypass ACL
```ts
/**
* Get a user
*
* Required privilege:
* - resource: user, action: read (if not self)
*/
@Get('{id}')
@Middlewares(acl({
resource: 'user',
actions: actionsIfNotSelfUser(['read']),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getUser,
}))
@Response(403)
getUser(@Path() id: string) { ... }
```
If you need to use a privilege that doesn't exist yet (e.g., `resource: 'vm', action: 'foo'`), you must register it in ACL Definition: here `@xen-orchestra/acl/src/actions/vm.mts`, add: `foo: true`.
## Contributions
@@ -15,6 +15,7 @@ export default tseslint.config(eslint.configs.recommended, tseslint.configs.reco
},
rules: {
'no-unused-vars': ['off'], // In order to define the OpenAPI specification, we sometimes need to declare variables that will not be used.
'@typescript-eslint/no-unused-vars': ['off'],
'@typescript-eslint/no-empty-object-type': ['off'], // It can be useful to return an empty object instead of `undefined` (to differentiate between "no data" and an error case).
},
})
+1
View File
@@ -36,6 +36,7 @@
"@vates/async-each": "^1.0.3",
"@vates/task": "^0.7.0",
"@vates/types": "^1.23.0",
"@xen-orchestra/acl": "^0.0.0",
"@xen-orchestra/backups": "^0.72.0",
"@xen-orchestra/log": "^0.7.2",
"@xen-orchestra/xapi": "^8.7.2",
@@ -3,6 +3,13 @@ import { createGzip } from 'node:zlib'
import { pipeline } from 'node:stream/promises'
import { Readable, type Transform } from 'node:stream'
import { Request } from 'express'
import {
type AnyPrivilege,
hasPrivilegeOn,
type SupportedActions,
type SupportedActionsByResource,
type SupportedResource,
} from '@xen-orchestra/acl'
import type { VatesTask } from '@vates/types/lib/vates/task'
import type { XapiXoRecord, XoRecord, XoTask } from '@vates/types/xo'
import type { Xapi } from '@vates/types/lib/xen-orchestra/xapi'
@@ -19,13 +26,14 @@ import { NDJSON_CONTENT_TYPE, safeParseComplexMatcher } from '../helpers/utils.h
const noop = () => {}
export type BaseControllerType<T extends XoRecord> = T extends XapiXoRecord
export type BaseControllerType<T extends RestXoRecord> = T extends XapiXoRecord
? T['type']
: NonNullable<XoTask['properties']['objectType']>
export type CreateActionReturnType<CbType> = Promise<{ taskId: string } | CbType>
export type RestXoRecord = XoRecord<SupportedActionsByResource, SupportedResource> | AnyPrivilege
export abstract class BaseController<T extends XoRecord, IsSync extends boolean> extends Controller {
export abstract class BaseController<T extends RestXoRecord, IsSync extends boolean> extends Controller {
abstract getObjects(): IsSync extends false ? Promise<Record<T['id'], T>> : Record<T['id'], T>
abstract getObject(id: T['id']): IsSync extends false ? Promise<T> : T
@@ -38,13 +46,39 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
this.restApi = restApi
}
sendObjects<Objects extends XoRecord = T>(
async sendObjects<Resource extends SupportedResource, Objects extends RestXoRecord = T>(
objects: Objects[],
req: Request,
path?: string | ((obj: Objects) => string)
opts?: {
path?: string | ((obj: Objects) => string)
privilege?: { action: SupportedActions<Resource>; resource: Resource }
limit?: number
}
): SendObjects<Objects> {
const mapper = makeObjectMapper(req, path)
const mappedObjects = objects.map(mapper) as string[] | WithHref<Objects>[]
const mapper = makeObjectMapper(req, opts?.path)
const mappedObjects: (string | WithHref<Partial<Objects>>)[] = []
const user = this.restApi.getCurrentUser()
const userPrivileges = (
opts?.privilege !== undefined ? await this.restApi.xoApp.getAclV2UserPrivileges(user.id) : []
) as AnyPrivilege[]
let limit = opts?.limit ?? Infinity
for (const object of objects) {
if (limit === 0) {
break
}
if (
opts?.privilege !== undefined &&
!hasPrivilegeOn({ user, userPrivileges, objects: object, ...opts.privilege })
) {
continue
}
mappedObjects.push(mapper(object))
limit--
}
if (req.query.ndjson === 'true' && req.query.markdown === 'true') {
throw invalidParameters('Cannot use both ndjson and markdown output formats simultaneously')
@@ -63,7 +97,7 @@ export abstract class BaseController<T extends XoRecord, IsSync extends boolean>
return Readable.from([makeMarkdownTable(mappedObjects)])
} else {
return mappedObjects
return mappedObjects as Awaited<SendObjects<Objects>>
}
}
@@ -1,8 +1,16 @@
import { createLogger } from '@xen-orchestra/log'
import type { EventEmitter } from 'node:events'
import { AnyPrivilege, hasPrivilegeOn, SupportedResource } from '@xen-orchestra/acl'
import type { XapiXoRecord, XoUser } from '@vates/types'
import type { CollectionEventType, EventType, XoListenerType } from '../events/event.type.mjs'
import { iocContainer } from '../ioc/ioc.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { Subscriber } from '../events/event.class.mjs'
import type { EventType } from '../events/event.type.mjs'
import { XAPI_TYPE_BY_ACL_RESOURCE } from '../middlewares/acl.middleware.mjs'
export abstract class Listener {
const log = createLogger('xo:rest-api:listener')
export abstract class Listener<Type extends XoListenerType | undefined = undefined> {
#subscribers: Map<
Subscriber['id'],
{ fields: '*' | string[]; subscriber: Subscriber; unregisterOnClear: () => void }
@@ -10,10 +18,13 @@ export abstract class Listener {
#eventEmitter: EventEmitter
#eventCallbacks: Map<string, (...args: unknown[]) => void> = new Map()
#watchedEvent: EventType[]
#type: Type
constructor(eventEmitter: EventEmitter, watchedEvent: EventType[]) {
constructor(eventEmitter: EventEmitter, watchedEvent: EventType[], type?: Type) {
this.#eventEmitter = eventEmitter
this.#watchedEvent = watchedEvent
// cast needed because Type !== Type | undefined (even if Type extends undefined...)
this.#type = type as Type
}
get subscribers() {
@@ -24,6 +35,10 @@ export abstract class Listener {
return this.#eventEmitter
}
get type() {
return this.#type
}
addSubscriber(subscriber: Subscriber, fields: '*' | string[] = '*') {
const unregisterOnClear = subscriber.onClear(() => this.removeSubscriber(subscriber.id))
@@ -65,27 +80,145 @@ export abstract class Listener {
return
}
const broadcastAllSubscriber = (...args: unknown[]) => {
this.#subscribers.forEach(conf => {
if (!conf.subscriber.isAlive) {
this.removeSubscriber(conf.subscriber.id)
return
}
const broadcastAllSubscriber = async (...args: unknown[]) => {
try {
await Promise.all(
[...this.#subscribers.values()].map(async conf => {
if (!conf.subscriber.isAlive) {
this.removeSubscriber(conf.subscriber.id)
return
}
const data = this.handleData({ ...conf, event }, ...args)
if (data === undefined) {
return
}
let data: Awaited<ReturnType<Listener['handleData']>>
try {
data = await this.handleData({ ...conf, event }, ...args)
} catch (error) {
log.error(
`cannot handle data for ${this.type} listener on subscriber: ${conf.subscriber.id} (user: ${conf.subscriber.userId}). Removing it from the subscriber list.`,
{ error }
)
this.removeSubscriber(conf.subscriber.id)
conf.subscriber.clear()
return
}
if (data === undefined) {
return
}
const { event: overridenEvent, ...dataToBroadcast } = data
conf.subscriber.broadcast(event, data)
})
conf.subscriber.broadcast(overridenEvent ?? event, dataToBroadcast)
})
)
} catch (error) {
log.warn('unexpected error broadcasting to subscribers', { error })
}
}
this.#eventCallbacks.set(event, broadcastAllSubscriber)
this.#eventEmitter.on(event, broadcastAllSubscriber)
}
async getAclEvent({
event,
object,
previousObject,
userId,
}: {
event: CollectionEventType
object: object | undefined
previousObject: object | undefined
userId: XoUser['id']
}): Promise<CollectionEventType | undefined> {
if (this.type === undefined || (object === undefined && previousObject === undefined)) {
return
}
const restApi = iocContainer.get(RestApi)
const user = await restApi.xoApp.getUser(userId)
if (user.permission === 'admin') {
return event
}
const userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
let resource: SupportedResource | undefined
// alarm and task are not real `XAPI` type
if (this.type === 'alarm' || this.type === 'task') {
resource = this.type
} else {
const resourceXapiType = Object.entries(XAPI_TYPE_BY_ACL_RESOURCE).find(([, xapiType]) => xapiType === this.type)
if (resourceXapiType === undefined) {
throw new Error(`No resource found for ${this.type} listener type`)
}
// cast is necessary because `Object.entries` loses inference on the key (it transforms it into a simple string).
;[resource] = resourceXapiType as [keyof typeof XAPI_TYPE_BY_ACL_RESOURCE, XapiXoRecord['type']]
}
switch (event) {
case 'add':
if (object === undefined) {
return
}
if (!hasPrivilegeOn({ user, userPrivileges, action: 'read', objects: object, resource })) {
return
}
return 'add'
case 'update': {
const canSeeObject =
object !== undefined &&
hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: object,
resource,
})
const canSeePreviousObject =
previousObject !== undefined &&
hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: previousObject,
resource,
})
if (canSeeObject && canSeePreviousObject) {
return 'update'
}
if (canSeeObject) {
return 'add'
}
if (canSeePreviousObject) {
return 'remove'
}
return
}
case 'remove':
if (
!hasPrivilegeOn({
user,
userPrivileges,
action: 'read',
objects: object ?? previousObject!,
resource,
})
) {
return
}
return 'remove'
default:
// Not supposed to be here
throw new Error(`${event} event unhandled on ${this.type} listener`)
}
}
abstract handleData(
conf: { fields: '*' | string[]; subscriber: Subscriber; event: EventType },
...args: unknown[]
): object | undefined
): Promise<(object & { event?: CollectionEventType }) | undefined>
}
@@ -1,11 +1,14 @@
import type { NonXapiXoRecord } from '@vates/types/xo'
import type { AnyPrivilege, SupportedActionsByResource, SupportedResource } from '@xen-orchestra/acl'
import { BaseController, type BaseControllerType } from './base-controller.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { limitAndFilterArray } from '../helpers/utils.helper.mjs'
export abstract class XoController<T extends NonXapiXoRecord> extends BaseController<T, false> {
export abstract class XoController<
T extends NonXapiXoRecord<SupportedActionsByResource, SupportedResource> | AnyPrivilege,
> extends BaseController<T, false> {
abstract getAllCollectionObjects(opts: Record<string, unknown>): Promise<T[]>
abstract getCollectionObject(id: T['id']): Promise<T>
@@ -0,0 +1,195 @@
import type { AnyPrivilege } from '@xen-orchestra/acl'
import {
Body,
Delete,
Example,
Get,
Middlewares,
Patch,
Path,
Post,
Query,
Request,
Response,
Route,
Security,
SuccessResponse,
Tags,
} from 'tsoa'
import { provide } from 'inversify-binding-decorators'
import { json, type Request as ExRequest } from 'express'
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import {
aclPrivilege,
aclPrivilegeIds,
partialAclPrivileges,
} from '../open-api/oa-examples/acl-privilege.oa-example.mjs'
import {
badRequestResp,
createdResp,
forbiddenOperationResp,
invalidParameters,
noContentResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import type { SafeOmit, SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { entityId } from '../open-api/oa-examples/common.oa-example.mjs'
import { XoAclBasePrivilege } from '@vates/types'
import { inject } from 'inversify'
import { RestApi } from '../rest-api/rest-api.mjs'
@Route('acl-privileges')
@Security('*')
@Response(badRequestResp.status, badRequestResp.description)
@Response(unauthorizedResp.status, unauthorizedResp.description)
@Tags('acls')
@provide(AclPrivilegeController)
export class AclPrivilegeController extends XoController<AnyPrivilege> {
constructor(@inject(RestApi) restApi: RestApi) {
super('acl-privilege', restApi)
}
getAllCollectionObjects(): Promise<AnyPrivilege[]> {
return this.restApi.xoApp.getAclV2Privileges() as Promise<AnyPrivilege[]>
}
getCollectionObject(id: AnyPrivilege['id']): Promise<AnyPrivilege> {
return this.restApi.xoApp.getAclV2Privilege(id) as Promise<AnyPrivilege>
}
/**
* Returns all ACL privileges that match the following privilege:
* - resource: acl-privilege, action: read
*
* @example fields "id,action,resource"
* @example filter "action:create"
* @example limit 42
*/
@Example(aclPrivilegeIds)
@Example(partialAclPrivileges)
@Get('')
@Security('*', ['acl'])
async getAclV2Privileges(
@Request() req: ExRequest,
@Query() fields?: string,
@Query() ndjson?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<AnyPrivilege>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'acl-privilege' },
})
}
/**
* Required privilege:
* - resource: acl-privilege, action: create
*
* @example privilege {
* "action": "read",
* "resource": "alarm",
* "roleId": "784bd959-08de-4b26-b575-92ded5aef872",
* "effect": "allow",
* "selector": "id:784bd959-08de-4b26-b575-92ded5aef872"
* }
*/
@Example(entityId)
@Post('')
@Middlewares([json(), acl({ resource: 'acl-privilege', action: 'create', object: ({ req }) => req.body })])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async createAclV2Privilege(
@Body() privilege: Unbrand<SafeOmit<AnyPrivilege, 'id'>>
): Promise<{ id: Unbrand<AnyPrivilege>['id'] }> {
const newPrivilege = await this.restApi.xoApp.createAclV2Privilege(privilege as SafeOmit<AnyPrivilege, 'id'>)
return { id: newPrivilege.id }
}
/**
* Required privilege:
* - resource: acl-privilege, action: read
*
* @example id "c5d89d1a-df1e-4b72-98a0-c40adfdf49c1"
*/
@Example(aclPrivilege)
@Get('{id}')
@Middlewares(
acl({
resource: 'acl-privilege',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Privilege,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getAclV2Privilege(@Path() id: string): Promise<Unbrand<AnyPrivilege>> {
return this.getObject(id as AnyPrivilege['id'])
}
/**
* Required privilege:
* - resource: acl-privilege, action: delete
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'acl-privilege',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Privilege,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteAclV2Privilege(@Path() id: string): Promise<void> {
await this.restApi.xoApp.deleteAclV2Privilege(id as AnyPrivilege['id'])
}
/**
* Required privileges:
* - resource: acl-privilege, action: update (grants all fields)
* - resource: acl-privilege, action: update:action (if action is passed)
* - resource: acl-privilege, action: update:resource (if resource is passed)
* - resource: acl-privilege, action: update:effect (if effect is passed)
* - resource: acl-privilege, action: update:selector (if selector is passed)
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example privilege {
* "action": "read",
* "resource": "alarm",
* "effect": "allow",
* "selector": "id:784bd959-08de-4b26-b575-92ded5aef872"
* }
*/
@Patch('{id}')
@Middlewares([
json(),
acl({
resource: 'acl-privilege',
actions: actionsFromBody(['update:action', 'update:resource', 'update:effect', 'update:selector']),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Privilege,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async updateAclV2Privilege(
@Path() id: string,
@Body() privilege: Unbrand<SafeOmit<Partial<AnyPrivilege>, 'id' | 'roleId'>>
): Promise<void> {
await this.restApi.xoApp.updateAclV2Privilege(id as AnyPrivilege['id'], privilege as XoAclBasePrivilege)
}
}
@@ -0,0 +1,413 @@
import type { AnyPrivilege } from '@xen-orchestra/acl'
import {
Body,
Delete,
Example,
Get,
Middlewares,
Patch,
Path,
Post,
Put,
Query,
Request,
Response,
Route,
Security,
SuccessResponse,
Tags,
} from 'tsoa'
import { provide } from 'inversify-binding-decorators'
import { type Request as ExRequest, json } from 'express'
import type { XoAclRole, XoGroup, XoUser } from '@vates/types'
import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import { aclPrivilegeIds, partialAclPrivileges } from '../open-api/oa-examples/acl-privilege.oa-example.mjs'
import { aclRole, aclRoleIds, partialAclRoles } from '../open-api/oa-examples/acl-role.oa-example.mjs'
import {
asynchronousActionResp,
badRequestResp,
createdResp,
forbiddenOperationResp,
invalidParameters,
noContentResp,
notFoundResp,
resourceAlreadyExists,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { BASE_URL } from '../index.mjs'
import { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { limitAndFilterArray } from '../helpers/utils.helper.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { entityId } from '../open-api/oa-examples/common.oa-example.mjs'
import { inject } from 'inversify'
import { RestApi } from '../rest-api/rest-api.mjs'
@Route('acl-roles')
@Security('*')
@Response(badRequestResp.status, badRequestResp.description)
@Response(unauthorizedResp.status, unauthorizedResp.description)
@Tags('acls')
@provide(AclRoleController)
export class AclRoleController extends XoController<XoAclRole> {
constructor(@inject(RestApi) restApi: RestApi) {
super('acl-role', restApi)
}
getAllCollectionObjects(): Promise<XoAclRole[]> {
return this.restApi.xoApp.getAclV2Roles()
}
getCollectionObject(id: XoAclRole['id']): Promise<XoAclRole> {
return this.restApi.xoApp.getAclV2Role(id)
}
/**
* Returns all ACL roles that match the following privilege:
* - resource: acl-role, action: read
*
* @example fields "id,name,isTemplate"
* @example filter "name:read only"
* @example limit 42
*/
@Example(aclRoleIds)
@Example(partialAclRoles)
@Get('')
@Security('*', ['acl'])
async getAclV2Roles(
@Request() req: ExRequest,
@Query() fields?: string,
@Query() ndjson?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoAclRole>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { resource: 'acl-role', action: 'read' },
})
}
/**
* Required privilege:
* - resource: acl-role, action: create
*
* @example body {
* "name": "VMs creator",
* "description": "Allow to create VMs"
* }
*/
@Example(entityId)
@Post('')
@Middlewares([
json(),
acl({
resource: 'acl-role',
action: 'create',
object: ({ req }) => req.body,
}),
])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async createAclV2Role(
@Body() body: { name: string; description?: string }
): Promise<{ id: Unbrand<XoAclRole>['id'] }> {
const newRole = await this.restApi.xoApp.createAclV2Role(body)
return { id: newRole.id }
}
/**
* Required privilege:
* - resource: acl-role, action: read
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
*/
@Example(aclRole)
@Get('{id}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getAclV2Role(@Path() id: string): Promise<Unbrand<XoAclRole>> {
return this.getObject(id as XoAclRole['id'])
}
/**
* Required privilege:
* - resource: acl-role, action: delete
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteAclV2Role(@Path() id: string): Promise<void> {
await this.restApi.xoApp.deleteAclV2Role(id as XoAclRole['id'])
}
/**
* Required privileges:
* - resource: acl-role, action: update (grants all fields)
* - resource: acl-role, action: update:name (if name is passed)
* - resource: acl-role, action: update:description (if description is passed)
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example body {
* "name": "VMs creator",
* "description": "Allow to create VMs"
* }
*/
@Patch('{id}')
@Middlewares([
json(),
acl({
resource: 'acl-role',
actions: actionsFromBody(['update:name', 'update:description']),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async updateAclV2Role(
@Path() id: string,
@Body() body: { name?: string; description?: string | null }
): Promise<void> {
await this.restApi.xoApp.updateAclV2Role(id as XoAclRole['id'], body)
}
/**
* Copy a role with all its privileges. Possibility to modify the name and description of the copied role.
*
* Required privileges:
* - resource: acl-role, action: create
* - resource: acl-privilege, action: create (if copied role has privileges)
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example body {"name": "Copied role"}
*/
@Example(taskLocation)
@Post('{id}/actions/copy')
@Middlewares([
json(),
acl([
{
resource: 'acl-role',
action: 'create',
object: async ({ req, restApi }) => {
const model = await restApi.xoApp.getAclV2Role(req.params.id as XoAclRole['id'])
return { name: req.body.name ?? model.name, description: req.body.description ?? model.description }
},
},
{
resource: 'acl-privilege',
action: 'create',
objects: async ({ req, restApi }) => {
const privileges = await restApi.xoApp.getAclV2RolePrivileges(req.params.id as XoAclRole['id'])
return privileges.map(({ id, ...rest }) => rest)
},
},
]),
])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(notFoundResp.status, notFoundResp.description)
copyAclV2Role(
@Path() id: string,
@Body() body?: { name?: string; description?: string },
@Query() sync?: boolean
): CreateActionReturnType<{ id: string }> {
const roleId = id as XoAclRole['id']
const action = async () => {
const id = await this.restApi.xoApp.copyAclV2Role(roleId, body)
if (sync) {
this.setHeader('Location', `${BASE_URL}/acl-roles/${id}`)
}
return { id }
}
return this.createAction<{ id: XoAclRole['id'] }>(action, {
sync,
statusCode: createdResp.status,
taskProperties: {
args: body,
name: 'copy role',
objectId: roleId,
},
})
}
/**
* Returns all ACL privileges that match the following privilege:
* - resource: acl-privilege, action: read
*
* @example id "426622cc-b2db-4545-a2f0-6ec47b3a6450"
* @example fields "id,action,resource"
* @example filter "action:create"
* @example limit 42
*/
@Example(aclPrivilegeIds)
@Example(partialAclPrivileges)
@Get('{id}/privileges')
@Security('*', ['acl'])
@Response(notFoundResp.status, notFoundResp.description)
async getAclV2RolePrivileges(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@Query() ndjson?: boolean,
@Query() filter?: string,
@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, {
path: 'acl-privileges',
limit,
privilege: { resource: 'acl-privilege', action: 'read' },
})
}
/**
* Attach an ACL V2 role to a group.
*
* Required privilege:
* - resource: acl-role, action: update:groups
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example groupId "ee4965bf-d8af-4ca2-aa0e-5f29d0c5f9e2"
*/
@Put('{id}/groups/{groupId}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'update:groups',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
async attachAclV2Group(@Path() id: string, @Path() groupId: string): Promise<void> {
const roleId = id as XoAclRole['id']
// addAclV2GroupRole does not check if the group exists so we get the group here to make sure it exists.
const group = await this.restApi.xoApp.getGroup(groupId as XoGroup['id'])
await this.restApi.xoApp.addAclV2GroupRole(group.id, roleId)
}
/**
* Detach an ACL V2 role from a group.
*
* Required privilege:
* - resource: acl-role, action: update:groups
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example groupId "ee4965bf-d8af-4ca2-aa0e-5f29d0c5f9e2"
*/
@Delete('{id}/groups/{groupId}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'update:groups',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async detachAclV2Group(@Path() id: string, @Path() groupId: string): Promise<void> {
const roleId = id as XoAclRole['id']
// deleteAclV2GroupRole does not check if the group exists so we get the group here to make sure it exists.
const group = await this.restApi.xoApp.getGroup(groupId as XoGroup['id'])
await this.restApi.xoApp.deleteAclV2GroupRole(group.id, roleId)
}
/**
* Attach an ACL V2 role to a user.
*
* Required privilege:
* - resource: acl-role, action: update:users
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example userId "ee4965bf-d8af-4ca2-aa0e-5f29d0c5f9e2"
*/
@Put('{id}/users/{userId}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'update:users',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
async attachAclV2User(@Path() id: string, @Path() userId: string): Promise<void> {
const roleId = id as XoAclRole['id']
// addAclV2UserRole does not check if the user exists so we get the user here to make sure it exists.
const user = await this.restApi.xoApp.getUser(userId as XoUser['id'])
await this.restApi.xoApp.addAclV2UserRole(user.id, roleId)
}
/**
* Detach an ACL V2 role from a user.
*
* Required privilege:
* - resource: acl-role, action: update:users
*
* @example id "784bd959-08de-4b26-b575-92ded5aef872"
* @example userId "ee4965bf-d8af-4ca2-aa0e-5f29d0c5f9e2"
*/
@Delete('{id}/users/{userId}')
@Middlewares(
acl({
resource: 'acl-role',
action: 'update:users',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getAclV2Role,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async detachAclV2User(@Path() id: string, @Path() userId: string): Promise<void> {
const roleId = id as XoAclRole['id']
// deleteAclV2UserRole does not check if the user exists so we get the user here to make sure it exists.
const user = await this.restApi.xoApp.getUser(userId as XoUser['id'])
await this.restApi.xoApp.deleteAclV2UserRole(user.id, roleId)
}
}
@@ -1,12 +1,18 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import type { Request as ExRequest } from 'express'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import { provide } from 'inversify-binding-decorators'
import type { XoAlarm, XoMessage } from '@vates/types'
import { alarm, alarmIds, partialAlarms } from '../open-api/oa-examples/alarm.oa-example.mjs'
import { badRequestResp, notFoundResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs'
@@ -38,17 +44,14 @@ export class AlarmController extends XapiXoController<XoAlarm> {
/**
* Override parent getObject in order to only get `ALARM` message
*/
getObject(id: XoAlarm['id']): XoAlarm {
const maybeAlarm = this.restApi.getObject<XoMessage>(id, 'message')
if (!this.#alarmService.isAlarm(maybeAlarm)) {
throw noSuchObject(id, 'alarm')
}
return this.#alarmService.parseAlarm(maybeAlarm)
getObject(id: XoMessage['id']): XoAlarm {
return this.#alarmService.getAlarm(id)
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example fields "body,id,object"
* @example filter "body:name:physical_utilisation"
* @example limit 42
@@ -56,6 +59,7 @@ export class AlarmController extends XapiXoController<XoAlarm> {
@Example(alarmIds)
@Example(partialAlarms)
@Get('')
@Security('*', ['acl'])
getAlarms(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -64,14 +68,29 @@ export class AlarmController extends XapiXoController<XoAlarm> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<UnbrandedXoAlarm>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Required privilege:
* - resource: alarm, action: read
*
* @example id "0c98c71c-2f9c-d5c2-b9b6-2c8371730eab"
*/
@Example(alarm)
@Get('{id}')
@Middlewares(
acl({
resource: 'alarm',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(AlarmService, 'getAlarm'),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getAlarm(@Path() id: string): UnbrandedXoAlarm {
return this.getObject(id as XoAlarm['id'])
@@ -1,3 +1,4 @@
import { noSuchObject } from 'xo-common/api-errors.js'
import { XapiXoRecord, XoAlarm, XoMessage } from '@vates/types'
import { RestApi } from '../rest-api/rest-api.mjs'
import { BASE_URL } from '../index.mjs'
@@ -75,4 +76,14 @@ export class AlarmService {
}
return alarms
}
getAlarm(id: XoMessage['id']): XoAlarm {
const maybeAlarm = this.#restApi.getObject<XoMessage>(id, 'message')
if (!this.isAlarm(maybeAlarm)) {
throw noSuchObject(id, 'alarm')
}
return this.parseAlarm(maybeAlarm)
}
}
@@ -1,11 +1,16 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import type { XoBackupRepository, XoVm, XoVmBackupArchive } from '@vates/types'
import { badRequestResp, notFoundResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import {
@@ -14,9 +19,8 @@ import {
partialBackupArchives,
} from '../open-api/oa-examples/backup-archive.oa-example.mjs'
import { SendObjects } from '../helpers/helper.type.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$/
import { BackupArchiveService } from './backup-archive.service.mjs'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
@Route('backup-archives')
@Security('*')
@@ -25,8 +29,14 @@ const BACKUP_ARCHIVE_ID_REGEX = /^([0-9a-fA-F-]{36})\/+xo-vm-backups\/+([0-9a-fA
@Tags('backup-archives')
@provide(BackupArchiveController)
export class BackupArchiveController extends XoController<XoVmBackupArchive> {
constructor(@inject(RestApi) restApi: RestApi) {
#backupArchiveService: BackupArchiveService
constructor(
@inject(RestApi) restApi: RestApi,
@inject(BackupArchiveService) backupArchiveService: BackupArchiveService
) {
super('backup-archive', restApi)
this.#backupArchiveService = backupArchiveService
}
async getAllCollectionObjects({
@@ -53,23 +63,13 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
return vmBackupArchives
}
async getCollectionObject(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
getCollectionObject(id: XoVmBackupArchive['id']): Promise<XoVmBackupArchive> {
return this.#backupArchiveService.getBackupArchive(id)
}
/**
* Returns all backup archives that match the following privilege:
* - resource: backup-archive, action: read
*
* You can use the alias "*" in "backup-repository" to select all backup repositories.
*
@@ -81,6 +81,7 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
@Example(backupArchiveIds)
@Example(partialBackupArchives)
@Get('')
@Security('*', ['acl'])
@Response(notFoundResp.status, notFoundResp.description)
async getBackupArchives(
@Request() req: ExRequest,
@@ -90,16 +91,31 @@ export class BackupArchiveController extends XoController<XoVmBackupArchive> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoVmBackupArchive>>>> {
const backupArchives = await this.getObjects({ backupRepositories, filter, limit })
return this.sendObjects(Object.values(backupArchives), req)
): SendObjects<Partial<Unbrand<XoVmBackupArchive>>> {
const backupArchives = await this.getObjects({ backupRepositories, filter })
return this.sendObjects(Object.values(backupArchives), req, {
limit,
privilege: { action: 'read', resource: 'backup-archive' },
})
}
/**
* @example id "231264c3-af43-4ec0-a3be-394c5b1fdbfc//xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json"
* Required privilege:
* - resource: backup-archive, action: read
*
* @example id "231264c3-af43-4ec0-a3be-394c5b1fdbfc/xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json"
*/
@Example(backupArchive)
@Get('{id}')
@Middlewares(
acl({
resource: 'backup-archive',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(BackupArchiveService, 'getBackupArchive'),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async getBackupArchive(@Path() id: string): Promise<Unbrand<XoVmBackupArchive>> {
const backupArchive = await this.getObject(id as XoVmBackupArchive['id'])
@@ -0,0 +1,31 @@
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
}
}
@@ -26,9 +26,16 @@ import {
import type { Request as ExRequest, Response as ExResponse } from 'express'
import { provide } from 'inversify-binding-decorators'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
import { backupLog, backupLogIds, partialBackupLogs } from '../open-api/oa-examples/backup-log.oa-example.mjs'
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
import { badRequestResp, notFoundResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { limitAndFilterArray, safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import type {
@@ -75,16 +82,13 @@ export class BackupJobController extends XoController<AnyXoBackupJob> {
return backupJobs
}
async getCollectionObject(id: AnyXoBackupJob['id']): Promise<AnyXoBackupJob> {
const job = await this.restApi.xoApp.getJob(id)
if (!this.#backupJobService.isBackupJob(job)) {
throw noSuchObject(id, 'backup-job')
}
return job
getCollectionObject(id: AnyXoBackupJob['id']): Promise<AnyXoBackupJob> {
return this.#backupJobService.getBackupJob(id)
}
/**
* Returns all backup jobs that match the following privilege:
* - resource: backup-job, action: read
*
* @example fields "name,mode,type,id"
* @example filter "type:backup"
@@ -93,6 +97,7 @@ export class BackupJobController extends XoController<AnyXoBackupJob> {
@Example(vmBackupJobIds)
@Example(partialVmBackupJobs)
@Get('')
@Security('*', ['acl'])
async getBackupJobs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -100,17 +105,34 @@ export class BackupJobController extends XoController<AnyXoBackupJob> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<UnbrandAnyXoBackupJob>>> {
const backupJobs = await this.getObjects({ filter, limit })
return this.sendObjects(Object.values(backupJobs), req, 'backup-jobs')
): SendObjects<Partial<UnbrandAnyXoBackupJob>> {
const backupJobs = await this.getObjects({ filter })
return this.sendObjects(Object.values(backupJobs), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
})
}
/**
*
* Required privilege:
* - resource: backup-job, action: read
*
* @example id "d33f3dc1-92b4-469c-ad58-4c2a106a4721"
*/
@Example(vmBackupJob)
@Response(notFoundResp.status, notFoundResp.description)
@Get('{id}')
@Middlewares(
acl({
resource: 'backup-job',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(BackupJobService, 'getBackupJob'),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getBackupJob(@Path() id: string): Promise<UnbrandAnyXoBackupJob> {
return this.getObject(id as AnyXoBackupJob['id'])
}
@@ -171,6 +193,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
}
/**
* Returns all VM backup jobs that match the following privilege:
* - resource: backup-job, action: read
*
* @example fields "name,mode,id"
* @example filter "mode:delta"
@@ -180,6 +204,7 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Example(partialVmBackupJobs)
@Deprecated()
@Get('jobs/vm')
@Security('*', ['acl'])
@Tags('backup-jobs')
async getVmBackupJobs(
@Request() req: ExRequest,
@@ -188,9 +213,13 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<UnbrandXoVmBackupJob>>> {
): SendObjects<Partial<UnbrandXoVmBackupJob>> {
const vmBackupJobs = await this.restApi.xoApp.getAllJobs('backup')
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter, limit }), req, 'backup-jobs')
return this.sendObjects(limitAndFilterArray(vmBackupJobs, { filter }), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
})
}
// For compatibility, redirect /backup/jobs/:id to /backup/jobs/vm/:id
@@ -215,6 +244,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
}
/**
* Returns all metadata backup jobs that match the following privilege:
* - resource: backup-job, action: read
*
* @example fields "name,xoMetadata,id"
* @example filter "xoMetadata?"
@@ -224,6 +255,7 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Example(partialMetadataBackupJobs)
@Deprecated()
@Get('jobs/metadata')
@Security('*', ['acl'])
@Tags('backup-jobs')
async getMetadataBackupJobs(
@Request() req: ExRequest,
@@ -232,9 +264,13 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<UnbrandXoMetadataBackupJob>>> {
): SendObjects<Partial<UnbrandXoMetadataBackupJob>> {
const metadataBackupJobs = await this.restApi.xoApp.getAllJobs('metadataBackup')
return this.sendObjects(limitAndFilterArray(metadataBackupJobs, { filter, limit }), req, 'backup-jobs')
return this.sendObjects(limitAndFilterArray(metadataBackupJobs, { filter }), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
})
}
/**
@@ -250,6 +286,8 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
}
/**
* Returns all mirror backup jobs that match the following privilege:
* - resource: backup-job, action: read
*
* @example fields "name,mode,id"
* @example filter "mode:delta"
@@ -259,6 +297,7 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Example(partialMirrorBackupJobs)
@Deprecated()
@Get('jobs/mirror')
@Security('*', ['acl'])
@Tags('backup-jobs')
async getMirrorBackupJobs(
@Request() req: ExRequest,
@@ -267,9 +306,13 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<UnbrandXoMirrorBackupJob>>> {
): SendObjects<Partial<UnbrandXoMirrorBackupJob>> {
const mirrorBackupJobs = await this.restApi.xoApp.getAllJobs('mirrorBackup')
return this.sendObjects(limitAndFilterArray(mirrorBackupJobs, { filter, limit }), req, 'backup-jobs')
return this.sendObjects(limitAndFilterArray(mirrorBackupJobs, { filter }), req, {
path: 'backup-jobs',
limit,
privilege: { action: 'read', resource: 'backup-job' },
})
}
/**
@@ -285,6 +328,9 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
}
/**
* Returns all backup logs that match the following privilege:
* - resource: backup-log, action: read
*
* @example fields "jobName,status,data"
* @example filter "status:success"
* @example limit 42
@@ -293,6 +339,7 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Example(partialBackupLogs)
@Deprecated()
@Get('logs')
@Security('*', ['acl'])
@Tags('backup-logs')
async getDeprecatedBackupLogs(
@Request() req: ExRequest,
@@ -301,7 +348,7 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoBackupLog>>>> {
): SendObjects<Partial<Unbrand<XoBackupLog>>> {
const userFilter = filter === undefined ? () => true : safeParseComplexMatcher(filter).createPredicate()
const predicate = (log: AnyXoLog) => {
@@ -311,8 +358,12 @@ export class DeprecatedBackupController extends XoController<AnyXoBackupJob> {
return userFilter(log)
}
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({ filter: predicate, limit })) as XoBackupLog[]
return this.sendObjects(logs, req, 'backup-logs')
const logs = (await this.restApi.xoApp.getBackupNgLogsSorted({ filter: predicate })) as XoBackupLog[]
return this.sendObjects(logs, req, {
path: 'backup-logs',
limit,
privilege: { action: 'read', resource: 'backup-log' },
})
}
/**
@@ -14,6 +14,15 @@ export class BackupJobService {
this.#restApi = restApi
}
async getBackupJob(id: AnyXoBackupJob['id']): Promise<AnyXoBackupJob> {
const job = await this.#restApi.xoApp.getJob(id)
if (!this.isBackupJob(job)) {
throw noSuchObject(id, 'backup-job')
}
return job
}
isBackupJob(anyJob: AnyXoJob): anyJob is AnyXoBackupJob {
return this.#backupJobTypes.includes(anyJob.type)
}
@@ -1,7 +1,6 @@
import type { AnyXoLog, XoBackupLog } from '@vates/types'
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
@@ -9,8 +8,15 @@ import { backupLog, backupLogIds, partialBackupLogs } from '../open-api/oa-examp
import { BackupLogService } from './backup-log.service.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { badRequestResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
@Route('backup-logs')
@Security('*')
@@ -32,15 +38,14 @@ export class BackupLogController extends XoController<XoBackupLog> {
>
}
async getCollectionObject(id: AnyXoLog['id']): Promise<XoBackupLog> {
const log = await this.restApi.xoApp.getBackupNgLogs(id)
if (!this.#backupLogService.isBackupLog(log)) {
throw noSuchObject('backup-log')
}
return log
getCollectionObject(id: AnyXoLog['id']): Promise<XoBackupLog> {
return this.#backupLogService.getBackupLog(id)
}
/**
* Returns all backup logs that match the following privilege:
* - resource: backup-log, action: read
*
* @example fields "jobName,status,data"
* @example filter "status:success"
* @example limit 42
@@ -48,6 +53,7 @@ export class BackupLogController extends XoController<XoBackupLog> {
@Example(backupLogIds)
@Example(partialBackupLogs)
@Get('')
@Security('*', ['acl'])
async getBackupLogs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -55,16 +61,32 @@ export class BackupLogController extends XoController<XoBackupLog> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoBackupLog>>>> {
const backupLogs = await this.getObjects({ filter, limit })
return this.sendObjects(Object.values(backupLogs), req)
): SendObjects<Partial<Unbrand<XoBackupLog>>> {
const backupLogs = await this.getObjects({ filter })
return this.sendObjects(Object.values(backupLogs), req, {
limit,
privilege: { action: 'read', resource: 'backup-log' },
})
}
/**
* Required privilege:
* - resource: backup-log, action: read
*
* @example id "1753776067468"
*/
@Example(backupLog)
@Get('{id}')
@Middlewares(
acl({
resource: 'backup-log',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(BackupLogService, 'getBackupLog'),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getBackupLog(@Path() id: string): Promise<Unbrand<XoBackupLog>> {
return this.getObject(id as XoBackupLog['id'])
}
@@ -1,10 +1,34 @@
import type { AnyXoLog, XoBackupLog, XoVm } from '@vates/types'
import type { AnyXoLog, XoBackupLog, XoRestoreLog, XoVm } from '@vates/types'
import { noSuchObject } from 'xo-common/api-errors.js'
import type { RestApi } from '../rest-api/rest-api.mjs'
export class BackupLogService {
#restApi: RestApi
constructor(restApi: RestApi) {
this.#restApi = restApi
}
isBackupLog(log: AnyXoLog): log is XoBackupLog {
return log.message === 'backup'
}
async getBackupLog(id: AnyXoLog['id']): Promise<XoBackupLog> {
const log = await this.#restApi.xoApp.getBackupNgLogs(id)
if (!this.isBackupLog(log)) {
throw noSuchObject('backup-log')
}
return log
}
async getRestoreLog(id: AnyXoLog['id']): Promise<XoRestoreLog> {
const log = await this.#restApi.xoApp.getBackupNgLogs(id)
if (this.isBackupLog(log)) {
throw noSuchObject('restore-log')
}
return log
}
getVmBackupTaskLog(log: XoBackupLog, vmId: XoVm['id']) {
return log.tasks?.find(task => task.data?.id === vmId)
}
@@ -1,10 +1,17 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import { Request as ExRequest } from 'express'
import type { XoBackupRepository } from '@vates/types'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import {
backupRepositoryIds,
partialBackupRepositories,
@@ -34,6 +41,9 @@ export class BackupRepositoryController extends XoController<XoBackupRepository>
}
/**
* Returns all backup repositories that match the following privilege:
* - resource: backup-repository, action: read
*
* @example fields "id,name,enabled"
* @example filter "enabled?"
* @example limit 42
@@ -41,6 +51,7 @@ export class BackupRepositoryController extends XoController<XoBackupRepository>
@Example(backupRepositoryIds)
@Example(partialBackupRepositories)
@Get('')
@Security('*', ['acl'])
async getRepositories(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -48,15 +59,30 @@ export class BackupRepositoryController extends XoController<XoBackupRepository>
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoBackupRepository>>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter, limit })), req)
): SendObjects<Partial<Unbrand<XoBackupRepository>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'backup-repository' },
})
}
/**
* Required privilege:
* - resource: backup-repository, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
*/
@Example(backupRepository)
@Get('{id}')
@Middlewares(
acl({
resource: 'backup-repository',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getRemote,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getRepository(@Path() id: string): Promise<Unbrand<XoBackupRepository>> {
return this.getObject(id as XoBackupRepository['id'])
@@ -7,7 +7,7 @@ import type { PassThrough } from 'node:stream'
import { Listener } from '../abstract-classes/listener.mjs'
import type { CollectionEventType, EventType, SubscriberId, XoListenerType } from './event.type.mjs'
import type { XapiXoRecord, XoAlarm, XoTask } from '@vates/types'
import type { XapiXoRecord, XoAlarm, XoTask, XoUser } from '@vates/types'
import type { AlarmService } from '../alarms/alarm.service.mjs'
const log = createLogger('xo:rest-api:event-service')
@@ -18,6 +18,7 @@ export class Subscriber {
#connection: PassThrough
#isAlive: boolean
#cleanupCallbacks: Set<() => void> = new Set()
#userId: XoUser['id']
get id() {
return this.#id
@@ -31,7 +32,11 @@ export class Subscriber {
return this.#connection
}
constructor(connection: PassThrough, manager: SubscriberManager) {
get userId() {
return this.#userId
}
constructor(connection: PassThrough, manager: SubscriberManager, userId: XoUser['id']) {
this.#id = crypto.randomUUID() as SubscriberId
connection.on('close', () => this.clear())
@@ -41,6 +46,7 @@ export class Subscriber {
this.#connection = connection
this.#manager = manager
this.#isAlive = true
this.#userId = userId
}
#safeWrite(payload: string) {
@@ -84,25 +90,26 @@ export class Subscriber {
}
}
export class XoListener extends Listener {
#type: XoListenerType
export class XoListener extends Listener<XoListenerType> {
#alarmService?: AlarmService
constructor(type: XoListenerType, eventEmitter: EventEmitter, alarmService?: AlarmService) {
super(eventEmitter, ['add', 'update', 'remove'])
this.#type = type
super(eventEmitter, ['add', 'update', 'remove'], type)
this.#alarmService = alarmService
}
handleData<T extends Exclude<XapiXoRecord, XoAlarm> | XoTask>(
{ fields, event }: { fields: '*' | string[]; subscriber: Subscriber; event: CollectionEventType },
async handleData<T extends Exclude<XapiXoRecord, XoAlarm> | XoTask>(
{ fields, event, subscriber }: { fields: '*' | string[]; subscriber: Subscriber; event: CollectionEventType },
object: T | undefined,
previousObj?: T
): (Partial<XapiXoRecord | XoTask> & { $subscription: XoListenerType }) | undefined {
): Promise<
(Partial<XapiXoRecord | XoTask> & { $subscription: XoListenerType; event: CollectionEventType }) | undefined
> {
let _object: Partial<XapiXoRecord | XoTask> | undefined = object
let _prevObject: Partial<XapiXoRecord | XoTask> | undefined = previousObj
if (this.#type === 'alarm' || this.#type === 'message') {
///
if (this.type === 'alarm' || this.type === 'message') {
const isAlarm = (object: T | undefined): object is Extract<T, { type: 'message' }> =>
object !== undefined && 'type' in object && object.type === 'message' && this.#alarmService!.isAlarm(object)
@@ -112,7 +119,7 @@ export class XoListener extends Listener {
// If we are in an alarm listener and the objects are messages
// we clean them to ensure they are not sent via the SSE
// Same if we are in a message listener and the objects are alarms
if (this.#type === 'alarm') {
if (this.type === 'alarm') {
_object = objectIsAlarm ? this.#alarmService!.parseAlarm(object) : undefined
_prevObject = prevObjectIsAlarm ? this.#alarmService!.parseAlarm(previousObj) : undefined
} else {
@@ -125,6 +132,18 @@ export class XoListener extends Listener {
return
}
const aclEvent = await this.getAclEvent({
event,
object: _object,
previousObject: _prevObject,
userId: subscriber.userId,
})
// If the user has no 'read' privileges for the changes, don't send the update
if (aclEvent === undefined) {
return
}
event = aclEvent
if (fields !== '*') {
if (_object !== undefined) {
_object = pick(_object, fields)
@@ -140,7 +159,7 @@ export class XoListener extends Listener {
}
// if _object === undefined, this means we are on a remove event, so _prevObject will not be undefined
return { $subscription: this.#type, ...(_object ?? _prevObject) }
return { $subscription: this.type, event, ...(_object ?? _prevObject) }
}
}
@@ -154,7 +173,7 @@ export class PingListener extends Listener {
}, 1000 * 30)
}
handleData(): { ping: number } {
async handleData(): Promise<{ ping: number }> {
return { ping: Date.now() }
}
@@ -70,6 +70,7 @@ export class EventController extends Controller {
* Data: the removed object.
*/
@Get('')
@Security('*', ['acl'])
@SuccessResponse(200, 'OK')
openSseConnection(@Request() req: AuthenticatedRequest) {
this.#eventService.createSseSubscriber(req.res)
@@ -83,6 +84,7 @@ export class EventController extends Controller {
*/
@Example(addSubscription)
@Post('{id}/subscriptions')
@Security('*', ['acl'])
@Middlewares(json())
@SuccessResponse(createdResp.status, createdResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@@ -101,6 +103,7 @@ export class EventController extends Controller {
* @example subscriptionId: "VM"
*/
@Delete('{id}/subscriptions/{subscriptionId}')
@Security('*', ['acl'])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
removeSubscription(@Path() id: string, @Path() subscriptionId: XoListenerType): void {
@@ -1,4 +1,3 @@
import os from 'node:os'
import { createLogger } from '@xen-orchestra/log'
import { PassThrough, pipeline } from 'node:stream'
import type { EventEmitter } from 'node:events'
@@ -6,7 +5,7 @@ import type { Response } from 'express'
import type { Listener } from '../abstract-classes/listener.mjs'
import { PingListener, Subscriber, SubscriberManager, XoListener } from './event.class.mjs'
import type { ListenerType, SubscriberId } from './event.type.mjs'
import type { ListenerType, SubscriberId, XoListenerType } from './event.type.mjs'
import { AlarmService } from '../alarms/alarm.service.mjs'
import type { RestApi } from '../rest-api/rest-api.mjs'
@@ -15,7 +14,7 @@ const log = createLogger('xo:rest-api:event-service')
export class EventService {
#alarmService: AlarmService
#restApi: RestApi
#listeners: Map<string, Listener> = new Map()
#listeners: Map<string, Listener | Listener<XoListenerType>> = new Map()
#subscriberManager = new SubscriberManager()
constructor(restApi: RestApi) {
@@ -30,12 +29,12 @@ export class EventService {
this.#alarmService = restApi.ioc.get(AlarmService)
}
#getListener(type: ListenerType): Listener {
#getListener<Type extends ListenerType>(type: Type): Listener | Listener<XoListenerType> {
if (this.#listeners.has(type)) {
return this.#listeners.get(type)!
}
let listener: Listener
let listener: Listener<XoListenerType> | Listener
if (type === 'ping') {
listener = new PingListener()
} else {
@@ -75,7 +74,8 @@ export class EventService {
}
})
const subscriber = new Subscriber(connection, this.#subscriberManager)
const user = this.#restApi.getCurrentUser()
const subscriber = new Subscriber(connection, this.#subscriberManager, user.id)
subscriber.broadcast('init', { id: subscriber.id })
this.addListenerFor(subscriber.id, { type: 'ping' })
@@ -42,6 +42,7 @@ import { RestApi } from '../rest-api/rest-api.mjs'
import { limitAndFilterArray } from '../helpers/utils.helper.mjs'
import { partialUsers, userIds } from '../open-api/oa-examples/user.oa-example.mjs'
import { partialTasks, taskIds } from '../open-api/oa-examples/task.oa-example.mjs'
import { acl, actionFromBody } from '../middlewares/acl.middleware.mjs'
@Route('groups')
@Security('*')
@@ -66,6 +67,9 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* Returns all groups that match the following privilege:
* - resource: group, action: read
*
* @example fields "name,id,users"
* @example filter "users:length:>0"
* @example limit 42
@@ -73,6 +77,7 @@ export class GroupController extends XoController<XoGroup> {
@Example(groupIds)
@Example(partialGroups)
@Get('')
@Security('*', ['acl'])
async getGroups(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -80,26 +85,55 @@ export class GroupController extends XoController<XoGroup> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoGroup>>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter, limit })), req)
): SendObjects<Partial<Unbrand<XoGroup>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'group' },
})
}
/**
* Required privilege:
* - resource: group, action: read
*
* @example id "7d98fee4-3357-41a7-ac3f-9124212badb7"
*/
@Example(group)
@Get('{id}')
@Middlewares(
acl({
resource: 'group',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getGroup,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getGroup(@Path() id: string): Promise<Unbrand<XoGroup>> {
return this.getObject(id as XoGroup['id'])
}
/**
* You cannot patch `synchronized` groups (even with the right privilege)
*
* Required privileges:
* - resource: group, action: update (grants all fields)
* - resource: group, action: update:name (if name is passed)
*
* @example id "c98395a7-26d8-4e09-b055-d5f0f4a98312"
* @example body { "name": "new group name" }
*/
@Patch('{id}')
@Middlewares(json())
@Middlewares([
json(),
acl({
resource: 'group',
action: actionFromBody('update:name'),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getGroup,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
@@ -115,14 +149,18 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* Required privilege:
* - resource: group, action: create
*
* @example body {
* "name": "new group"
* }
*/
@Example(groupId)
@Post('')
@Middlewares(json())
@Middlewares([json(), acl({ resource: 'group', action: 'create', object: ({ req }) => req.body })])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(invalidParameters.status, invalidParameters.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
async createGroup(@Body() body: { name: string }): Promise<{ id: Unbrand<XoGroup>['id'] }> {
@@ -132,10 +170,22 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* Required privilege:
* - resource: group, action: delete
*
* @example id "7d98fee4-3357-41a7-ac3f-9124212badb7"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'group',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getGroup,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteGroup(@Path() id: string): Promise<void> {
const groupId = id as XoGroup['id']
@@ -143,10 +193,23 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* You cannot manage users of `synchronized` groups (even with the right privilege)
*
* Required privilege:
* - resource: group, action: update:users
*
* @example id "c98395a7-26d8-4e09-b055-d5f0f4a98312"
* @example userId "722d17b9-699b-49d2-8193-be1ac573d3de"
*/
@Delete('{id}/users/{userId}')
@Middlewares(
acl({
resource: 'group',
action: 'update:users',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getGroup,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@@ -161,10 +224,23 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* You cannot manage users of `synchronized` groups (even with the right privilege)
*
* Required privilege:
* - resource: group, action: update:users
*
* @example id "6c81b5e1-afc1-43ea-8f8d-939ceb5f3f90"
* @example userId "722d17b9-699b-49d2-8193-be1ac573d3de"
*/
@Put('{id}/users/{userId}')
@Middlewares(
acl({
resource: 'group',
action: 'update:users',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getGroup,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@@ -178,6 +254,9 @@ export class GroupController extends XoController<XoGroup> {
}
/**
* Returns all users that match the following privilege:
* - resource: user, action: read
*
* @example id "6c81b5e1-afc1-43ea-8f8d-939ceb5f3f90"
* @example fields "permission,name,id"
* @example filter "permission:none"
@@ -186,6 +265,7 @@ export class GroupController extends XoController<XoGroup> {
@Example(userIds)
@Example(partialUsers)
@Get('{id}/users')
@Security('*', ['acl'])
@Tags('users')
@Response(notFoundResp.status, notFoundResp.description)
async getGroupUsers(
@@ -196,13 +276,20 @@ export class GroupController extends XoController<XoGroup> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoUser>>>> {
): SendObjects<Partial<Unbrand<XoUser>>> {
const group = await this.getObject(id as XoGroup['id'])
const users = await Promise.all(group.users.map(id => this.#userService.getUser(id)))
return this.sendObjects(limitAndFilterArray(users, { filter, limit }), req, 'users')
return this.sendObjects(limitAndFilterArray(users, { filter }), req, {
path: 'users',
limit,
privilege: { action: 'read', resource: 'user' },
})
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "6c81b5e1-afc1-43ea-8f8d-939ceb5f3f90"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -211,6 +298,7 @@ export class GroupController extends XoController<XoGroup> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getGroupTasks(
@@ -221,9 +309,13 @@ export class GroupController extends XoController<XoGroup> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoGroup['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoGroup['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
}
@@ -17,7 +17,7 @@ export interface XoError extends Error {
export type NdjsonStream = Readable
export type SendObjects<T> = string[] | WithHref<T>[] | NdjsonStream | string
export type SendObjects<T> = Promise<string[] | WithHref<T>[] | NdjsonStream | string>
export type PromiseWriteInStreamError = { error: true }
@@ -39,3 +39,5 @@ export type ReplaceKey<Type, OldKey extends keyof Type, NewKey extends string, N
OldKey
> &
Record<NewKey, NewKeyType>
export type SafeOmit<T, K extends keyof T> = T extends T ? Omit<T, K> : never
@@ -1,11 +1,11 @@
import pick from 'lodash/pick.js'
import { Request } from 'express'
import type { XoRecord } from '@vates/types'
import { BASE_URL } from '../index.mjs'
import type { RestXoRecord } from '../abstract-classes/base-controller.mjs'
import type { WithHref } from './helper.type.mjs'
export function makeObjectMapper<T extends XoRecord>(req: Request, path?: string | ((obj: T) => string)) {
export function makeObjectMapper<T extends RestXoRecord>(req: Request, path?: string | ((obj: T) => string)) {
const makeUrl = (obj: T) => {
let _path: string
if (path === undefined) {
@@ -36,6 +36,7 @@ import type {
XsPatches,
} from '@vates/types'
import { acl } from '../middlewares/acl.middleware.mjs'
import { AlarmService } from '../alarms/alarm.service.mjs'
import { escapeUnsafeComplexMatcher } from '../helpers/utils.helper.mjs'
import { genericAlarmsExample } from '../open-api/oa-examples/alarm.oa-example.mjs'
@@ -54,6 +55,7 @@ import {
asynchronousActionResp,
badRequestResp,
featureUnauthorized,
forbiddenOperationResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
noContentResp,
@@ -87,6 +89,9 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Returns all hosts that match the following privilege:
* - resource: host, action: read
*
* @example fields "id,name_label,productBrand"
* @example filter "productBrand:XCP-ng"
* @example limit 42
@@ -94,6 +99,7 @@ export class HostController extends XapiXoController<XoHost> {
@Example(hostIds)
@Example(partialHosts)
@Get('')
@Security('*', ['acl'])
getHosts(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -102,25 +108,38 @@ export class HostController extends XapiXoController<XoHost> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoHost>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'host' },
})
}
/**
* Required privilege:
* - resource: host, action: read
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(host)
@Get('{id}')
@Middlewares(acl({ resource: 'host', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getHost(@Path() id: string): Unbrand<XoHost> {
return this.getObject(id as XoHost['id'])
}
/**
* Required privilege:
* - resource: host, action: read
*
* Host must be running
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(hostStats)
@Get('{id}/stats')
@Middlewares(acl({ resource: 'host', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(422, 'Invalid granularity')
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
@@ -129,6 +148,9 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Required privilege:
* - resource: host, action: export:logs
*
* Host must be running
*
* Download the audit log of a host.
@@ -137,7 +159,9 @@ export class HostController extends XapiXoController<XoHost> {
*
*/
@Get('{id}/audit.txt')
@Middlewares(acl({ resource: 'host', action: 'export:logs', objectId: 'params.id' }))
@SuccessResponse(200, 'Download started', 'application/octet-stream')
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async getAuditLog(@Request() req: ExRequest, @Path() id: string) {
@@ -157,6 +181,9 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Required privilege:
* - resource: host, action: export:logs
*
* Host must be running
*
* Download all logs of a host.
@@ -165,7 +192,9 @@ export class HostController extends XapiXoController<XoHost> {
*
*/
@Get('{id}/logs.tgz')
@Middlewares(acl({ resource: 'host', action: 'export:logs', objectId: 'params.id' }))
@SuccessResponse(200, 'Download started', 'application/gzip')
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async getHostLogs(@Request() req: ExRequest, @Path() id: string) {
@@ -180,6 +209,9 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example fields "id,time"
* @example filter "time:>1747053793"
@@ -187,6 +219,7 @@ export class HostController extends XapiXoController<XoHost> {
*/
@Example(genericAlarmsExample)
@Get('{id}/alarms')
@Security('*', ['acl'])
@Tags('alarms')
@Response(notFoundResp.status, notFoundResp.description)
getHostAlarms(
@@ -201,19 +234,27 @@ export class HostController extends XapiXoController<XoHost> {
const host = this.getObject(id as XoHost['id'])
const alarms = this.#alarmService.getAlarms({
filter: `${escapeUnsafeComplexMatcher(filter) ?? ''} object:uuid:${host.uuid}`,
limit,
})
return this.sendObjects(Object.values(alarms), req, 'alarms')
return this.sendObjects(Object.values(alarms), req, {
path: 'alarms',
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Required privilege:
* - resource: host, action: read
*
* Returns a boolean indicating whether SMT (Simultaneous Multi-Threading) is enabled
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(hostSmt)
@Get('{id}/smt')
@Middlewares(acl({ resource: 'host', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async gethostSmt(@Path() id: string): Promise<{ enabled: boolean }> {
@@ -226,12 +267,17 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Required privilege:
* - resource: host, action: read
*
* Host must be running
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
*/
@Example(hostMissingPatches)
@Get('{id}/missing_patches')
@Middlewares(acl({ resource: 'host', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
async getMissingPatches(@Path() id: string): Promise<XcpPatches[] | XsPatches[]> {
@@ -240,6 +286,9 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example fields "name,id,$object"
* @example filter "name:PBD_PLUG_FAILED_ON_SERVER_START"
@@ -248,6 +297,7 @@ export class HostController extends XapiXoController<XoHost> {
@Example(messageIds)
@Example(partialMessages)
@Get('{id}/messages')
@Security('*', ['acl'])
@Tags('messages')
@Response(notFoundResp.status, notFoundResp.description)
getHostMessages(
@@ -259,12 +309,19 @@ export class HostController extends XapiXoController<XoHost> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoMessage>>> {
const messages = this.getMessagesForObject(id as XoHost['id'], { filter, limit })
const messages = this.getMessagesForObject(id as XoHost['id'], { filter })
return this.sendObjects(Object.values(messages), req, 'messages')
return this.sendObjects(Object.values(messages), req, {
path: 'messages',
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -273,6 +330,7 @@ export class HostController extends XapiXoController<XoHost> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getHostTasks(
@@ -283,18 +341,27 @@ export class HostController extends XapiXoController<XoHost> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoHost['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoHost['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
/**
* Required privilege:
* - resource: host, action: update:tags
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example tag "from-rest-api"
*/
@Put('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'host', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async putHostTag(@Path() id: string, @Path() tag: string): Promise<void> {
const host = this.getXapiObject(id as XoHost['id'])
@@ -302,11 +369,16 @@ export class HostController extends XapiXoController<XoHost> {
}
/**
* Required privilege:
* - resource: host, action: update:tags
*
* @example id "b61a5c92-700e-4966-a13b-00633f03eea8"
* @example tag "from-rest-api"
*/
@Delete('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'host', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteHostTag(@Path() id: string, @Path() tag: string): Promise<void> {
const host = this.getXapiObject(id as XoHost['id'])
+15 -5
View File
@@ -15,13 +15,14 @@ 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'
const iocContainer = new Container()
decorate(injectable(), Controller)
iocContainer.load(buildProviderModule())
export function setupContainer(xoApp: XoApp) {
decorate(injectable(), Controller)
iocContainer.load(buildProviderModule())
if (iocContainer.isBound(RestApi)) {
iocContainer.unbind(RestApi)
}
@@ -97,8 +98,9 @@ export function setupContainer(xoApp: XoApp) {
iocContainer
.bind(BackupLogService)
.toDynamicValue(() => {
return new BackupLogService()
.toDynamicValue(ctx => {
const restApi = ctx.container.get(RestApi)
return new BackupLogService(restApi)
})
.inSingletonScope()
@@ -117,6 +119,14 @@ export function setupContainer(xoApp: XoApp) {
return new NetworkService(restApi)
})
.inSingletonScope()
iocContainer
.bind(BackupArchiveService)
.toDynamicValue(ctx => {
const restApi = ctx.container.get(RestApi)
return new BackupArchiveService(restApi)
})
.inSingletonScope()
}
export { iocContainer }
@@ -1,20 +1,37 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import type { Request as ExRequest } from 'express'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import { provide } from 'inversify-binding-decorators'
import type { XoMessage } from '@vates/types'
import { acl } from '../middlewares/acl.middleware.mjs'
import { alarmPredicate } from '../alarms/alarm.service.mjs'
import { message, messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs'
import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
type UnbrandedXoMessage = Unbrand<XoMessage>
function getMessage(restApi: RestApi, id: XoMessage['id']): XoMessage {
const message = restApi.getObject<XoMessage>(id, 'message')
if (alarmPredicate(message)) {
throw noSuchObject(id, 'message')
}
return message
}
@Route('messages')
@Security('*')
@Response(badRequestResp.status, badRequestResp.description)
@@ -46,16 +63,13 @@ export class MessageController extends XapiXoController<XoMessage> {
* Override parent getObject in order to exclude`ALARM` message
*/
getObject(id: XoMessage['id']): XoMessage {
const message = super.getObject(id)
if (alarmPredicate(message)) {
throw noSuchObject(id, 'message')
}
return message
return getMessage(this.restApi, id)
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example fields "name,body,id,$object"
* @example filter "name:VM_STARTED"
* @example limit 42
@@ -63,6 +77,7 @@ export class MessageController extends XapiXoController<XoMessage> {
@Example(messageIds)
@Example(partialMessages)
@Get('')
@Security('*', ['acl'])
getMessages(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -71,14 +86,32 @@ export class MessageController extends XapiXoController<XoMessage> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<UnbrandedXoMessage>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Required privilege:
* - resource: message, action: read
*
* @example id "f775eaeb-abe5-94e0-9682-14c37c3a1dfe"
*/
@Example(message)
@Get('{id}')
@Middlewares(
acl({
resource: 'message',
action: 'read',
objectId: 'params.id',
getObject:
({ restApi }) =>
id =>
getMessage(restApi, id),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getMessage(@Path() id: string): UnbrandedXoMessage {
return this.getObject(id as XoMessage['id'])
@@ -0,0 +1,301 @@
import {
getMissingPrivileges,
type AnyPrivilegeOnParam,
type AnyPrivilege,
type SupportedActionsByResource,
type SupportedActions,
type SupportedResource,
} from '@xen-orchestra/acl'
import type { Response, NextFunction } from 'express'
import type { AuthenticatedRequest, MaybePromise } from '../helpers/helper.type.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { iocContainer } from '../ioc/ioc.mjs'
import type { Branded, NonXapiXoRecord, XoApp, XapiXoRecord, XoRecord, XoAclBasePrivilege } from '@vates/types'
import { ServiceIdentifier, ValidateError } from 'tsoa'
import { ApiError } from '../helpers/error.helper.mjs'
export const ACL_MIDDLEWARE_NAME = '_aclMiddleware'
export function actionsFromBody<Resource extends SupportedResource>(
actions: SupportedActions<Resource>[]
): (opts: { req: AuthenticatedRequest }) => SupportedActions<Resource>[] {
return ({ req }) =>
actions.filter(action => {
const [, field] = action.split(':')
return field in req.body
})
}
export function actionFromBody<Resource extends SupportedResource>(action: SupportedActions<Resource>) {
return opts => actionsFromBody([action])(opts)[0]
}
export function actionsIfNotSelfUser<Resource extends SupportedResource>(
actions: SupportedActions<Resource>[]
): (opts: { req: AuthenticatedRequest; restApi: RestApi }) => SupportedActions<Resource>[] {
return ({ req, restApi }) => {
const currentUser = restApi.getCurrentUser()
const userId = req.params.id
if (currentUser.id !== userId) {
return actions
}
return []
}
}
export function actionIfNotSelfUser<Resource extends SupportedResource>(action: SupportedActions<Resource>) {
return opts => actionsIfNotSelfUser([action])(opts)[0]
}
export function autoBindService<Service extends object, Method extends keyof Service>(
serviceId: ServiceIdentifier<Service>,
method: Method
) {
return ({ restApi }: { restApi: RestApi }): Service[Method] => {
const service = restApi.ioc.get(serviceId)
const fn = service[method]
if (typeof fn !== 'function') {
throw new Error(`Invalid method: ${String(method)}`)
}
return fn.bind(service)
}
}
type RestNonXapiXoRecord = NonXapiXoRecord<SupportedActionsByResource, SupportedResource> | AnyPrivilege
type AclEntry = {
[Resource in SupportedResource]: {
resource: Resource
} & (
| {
action:
| SupportedActions<Resource>
| ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => SupportedActions<Resource>)
actions?: never
}
| {
actions:
| SupportedActions<Resource>[]
| ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => SupportedActions<Resource>[])
action?: never
}
) &
(
| {
objectIds: string[] | ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => XoRecord['id'][])
getObject?:
| ((opts: { restApi: RestApi }) => (id: Branded<any>) => Promise<RestNonXapiXoRecord | XoAclBasePrivilege>)
| ((opts: { restApi: RestApi }) => (id: Branded<any>) => XapiXoRecord)
}
| {
objectId: string | ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => XoRecord['id'])
getObject?:
| ((opts: { restApi: RestApi }) => (id: Branded<any>) => Promise<RestNonXapiXoRecord | XoAclBasePrivilege>)
| ((opts: { restApi: RestApi }) => (id: Branded<any>) => XapiXoRecord)
}
| {
objectIds?: never
objectId?: never
getObject?: never
objects:
| object[]
| ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => MaybePromise<object[]> | undefined)
object?: never
}
| {
objectIds?: never
objectId?: never
getObject?: never
objects?: never
object: object | ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => MaybePromise<object> | undefined)
}
)
}[SupportedResource]
export const XAPI_TYPE_BY_ACL_RESOURCE = {
message: 'message',
gpuGroup: 'gpuGroup',
host: 'host',
network: 'network',
pbd: 'PBD',
pci: 'PCI',
pgpu: 'PGPU',
pif: 'PIF',
pool: 'pool',
sr: 'SR',
vbd: 'VBD',
vdi: 'VDI',
'vdi-snapshot': 'VDI-snapshot',
'vdi-unmanaged': 'VDI-unmanaged',
vgpu: 'vgpu',
vgpuType: 'vgpuType',
vif: 'VIF',
vm: 'VM',
'vm-controller': 'VM-controller',
'vm-snapshot': 'VM-snapshot',
'vm-template': 'VM-template',
vtpm: 'VTPM',
sm: 'SM',
} satisfies Partial<Record<SupportedResource, XapiXoRecord['type']>>
function normalizeAclEntry(acl: AclEntry) {
const aclAction = acl.action
const aclActions =
'actions' in acl && acl.actions !== undefined
? acl.actions
: typeof aclAction === 'function'
? (opts: { req: AuthenticatedRequest; restApi: RestApi }) => [aclAction(opts)]
: [aclAction]
const actionsResolver =
typeof aclActions === 'function'
? (req: AuthenticatedRequest, restApi: RestApi) => aclActions({ req, restApi })
: () => aclActions
const base = { resource: acl.resource, actionsResolver, getObject: acl.getObject }
if ('objects' in acl && acl.objects !== undefined) {
return { ...base, objects: acl.objects }
}
if ('object' in acl && acl.object !== undefined) {
let objects:
| object[]
| ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => MaybePromise<object[]> | undefined)
if (typeof acl.object === 'function') {
const fn = acl.object
objects = async (opts: { req: AuthenticatedRequest; restApi: RestApi }) => [await fn(opts)]
} else {
objects = [acl.object]
}
return { ...base, objects }
}
if ('objectIds' in acl && acl.objectIds !== undefined) {
return { ...base, objectIds: acl.objectIds }
}
if ('objectId' in acl && acl.objectId !== undefined) {
let objectIds: string[] | ((opts: { req: AuthenticatedRequest; restApi: RestApi }) => string[])
if (typeof acl.objectId === 'function') {
const fn = acl.objectId
objectIds = (opts: { req: AuthenticatedRequest; restApi: RestApi }) => [fn(opts)]
} else {
objectIds = [acl.objectId]
}
return { ...base, objectIds }
}
throw new Error('invalid ACL entry')
}
export function acl(acls: AclEntry | AclEntry[]) {
acls = Array.isArray(acls) ? acls : [acls]
const _acls = acls.map(normalizeAclEntry)
async function middleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
const restApi = iocContainer.get(RestApi)
const user = restApi.getCurrentUser()
const invalidFields: { [key: string]: { message: string; value: unknown } } = {}
const missingPrivilegeParams: AnyPrivilegeOnParam[] = []
for (const acl of _acls) {
let objects: object[] = []
if ('objectIds' in acl) {
let ids: { id: unknown; path?: string }[] = []
if (typeof acl.objectIds === 'function') {
try {
ids = acl.objectIds({ req, restApi }).map(id => ({ id }))
} catch (error) {
return next(error)
}
} else {
for (const path of acl.objectIds) {
const id: unknown = path.split('.').reduce((obj, part) => obj?.[part], req)
ids.push({ id, path })
}
}
for (const { id, path } of ids) {
if (id === undefined) {
continue
}
if (typeof id !== 'string') {
invalidFields[path ?? 'unknown'] = {
message: 'invalid string value',
value: id,
}
continue
}
if (Object.keys(invalidFields).length > 0) {
// No need to get objects if we already known some params are invalid
continue
}
try {
const object =
(await acl.getObject?.({ restApi })(id as XoRecord['id'])) ??
restApi.getObject(id as XapiXoRecord['id'], XAPI_TYPE_BY_ACL_RESOURCE[acl.resource])
objects.push(object)
} catch (error) {
return next(error)
}
}
}
if ('objects' in acl) {
if (typeof acl.objects === 'function') {
const _objects = await acl.objects({ req, restApi })
if (_objects !== undefined) {
objects = _objects
}
} else {
objects = acl.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> }
// This loses the original correlation (e.g. it would allow invalid pairs like { resource: 'vgpu', action: 'snapshot' }).
// The `as` cast re-asserts the discriminated union member type.
for (const action of acl.actionsResolver(req, restApi)) {
if (action === undefined) {
// action can be undefined, if the action is created from `actionFromBody` or `actionIfNotSelfUser`
continue
}
missingPrivilegeParams.push({ action, resource: acl.resource, objects, user } as AnyPrivilegeOnParam)
}
}
if (Object.keys(invalidFields).length > 0) {
return next(new ValidateError(invalidFields, 'invalid parameters'))
}
let userPrivileges: AnyPrivilege[]
try {
userPrivileges = (await restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
} catch (error) {
return next(error)
}
const missingPrivileges = getMissingPrivileges(missingPrivilegeParams, userPrivileges)
if (missingPrivileges.length > 0) {
return next(
new ApiError('not enough privileges', 403, {
data: {
missingPrivileges,
},
})
)
}
next()
}
Object.defineProperty(middleware, 'name', { value: ACL_MIDDLEWARE_NAME })
return middleware
}
@@ -1,21 +1,21 @@
import { createLogger } from '@xen-orchestra/log'
import { NextFunction, Request, Response } from 'express'
import type { NextFunction, Request, Response } from 'express'
import { unauthorized } from 'xo-common/api-errors.js'
import type { XoApp } from '@vates/types/xo-app'
import { ApiError } from '../helpers/error.helper.mjs'
import type { AuthenticatedRequest } from '../helpers/helper.type.mjs'
import { iocContainer } from '../ioc/ioc.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { AuthenticatedRequest } from '../helpers/helper.type.mjs'
import { ApiError } from '../helpers/error.helper.mjs'
import { ACL_MIDDLEWARE_NAME } from './acl.middleware.mjs'
const log = createLogger('xo:rest-api:authentication')
export type SecurityName = '*' | 'token' | 'basic' | 'none'
// TODO: correctly handle ACL/Resource set users
// for now only support "xoa-admin"
// TSOA spec require this function to be async
export async function expressAuthentication(req: AuthenticatedRequest, securityName: SecurityName) {
export async function expressAuthentication(req: AuthenticatedRequest, securityName: SecurityName, scopes: 'acl'[]) {
if (securityName === 'none') {
return undefined
}
@@ -28,8 +28,20 @@ export async function expressAuthentication(req: AuthenticatedRequest, securityN
throw new ApiError(`invalid authentification. please use ${securityName} authentication`, 401)
}
if (user.permission !== 'admin') {
log.error(`The REST API can only be used by 'xoa-admin' users for now. Your permission: ${user.permission}`)
if (user.permission === 'admin') {
return user
}
// This means the route requires authentication, but it doesn't need to be associated with a specific ACL.
// The route's content will depend on the users' ACLs.
// (For example: GET /vms -> empty array if no VMs ACL; GET /events -> only changes related to the users' ACLs will be sent)
if (scopes.includes('acl')) {
return user
}
const aclMiddleware = req.route.stack.find(layer => layer.name === ACL_MIDDLEWARE_NAME)
if (aclMiddleware === undefined) {
log.error(`${req.route.path} can only be used by an administrator`)
throw unauthorized()
}
@@ -1,11 +1,34 @@
import { invalidParameters } from 'xo-common/api-errors.js'
import { NextFunction, Request, Response } from 'express'
import { ValidateError } from 'tsoa'
import { FieldErrors, ValidateError } from 'tsoa'
export default function tsoaToXoErrorHandler(error: unknown, _req: Request, _res: Response, next: NextFunction) {
if (error instanceof ValidateError) {
throw invalidParameters(error.fields)
const fields = simplifyUnionValidationErrors(error.fields)
throw invalidParameters(fields)
}
return next(error)
}
function simplifyUnionValidationErrors(fields: FieldErrors): FieldErrors {
const result: FieldErrors = {}
for (const [key, field] of Object.entries(fields)) {
if (isUnionMismatchError(field)) {
result[key] = {
...field,
message: 'Value does not match any allowed schema',
}
continue
}
result[key] = field
}
return result
}
function isUnionMismatchError(field: FieldErrors[string]): boolean {
return field.message.startsWith('Could not match the union against any of the items')
}
@@ -1,4 +1,18 @@
import { Example, Get, Path, Query, Response, Request, Route, Security, Tags, Delete, SuccessResponse, Put } from 'tsoa'
import {
Example,
Get,
Path,
Query,
Response,
Request,
Route,
Security,
Tags,
Delete,
SuccessResponse,
Put,
Middlewares,
} from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import { Request as ExRequest } from 'express'
@@ -10,6 +24,7 @@ import { genericAlarmsExample } from '../open-api/oa-examples/alarm.oa-example.m
import { network, networkIds, partialNetworks } from '../open-api/oa-examples/network.oa-example.mjs'
import {
badRequestResp,
forbiddenOperationResp,
noContentResp,
notFoundResp,
unauthorizedResp,
@@ -20,6 +35,7 @@ import type { SendObjects } from '../helpers/helper.type.mjs'
import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs'
import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs'
import { partialTasks, taskIds } from '../open-api/oa-examples/task.oa-example.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
@Route('networks')
@Security('*')
@@ -35,6 +51,9 @@ export class NetworkController extends XapiXoController<XoNetwork> {
}
/**
* Returns all networks that match the following privilege:
* - resource: network, action: read
*
* @example fields "nbd,name_label,id"
* @example filter "nbd?"
* @example limit 42
@@ -42,6 +61,7 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Example(networkIds)
@Example(partialNetworks)
@Get('')
@Security('*', ['acl'])
getNetworks(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -50,23 +70,36 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoNetwork>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'network' },
})
}
/**
* Required privilege:
* - resource: network, action: read
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
*/
@Example(network)
@Get('{id}')
@Middlewares(acl({ resource: 'network', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getNetwork(@Path() id: string): Unbrand<XoNetwork> {
return this.getObject(id as XoNetwork['id'])
}
/**
* @example id "593c39a5-9c56-28eb-969b-255b2f53791b"
* Required privilege:
* - resource: network, action: delete
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
*/
@Delete('{id}')
@Middlewares(acl({ resource: 'network', action: 'delete', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteNetwork(@Path() id: string): Promise<void> {
@@ -75,6 +108,9 @@ export class NetworkController extends XapiXoController<XoNetwork> {
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
* @example fields "id,time"
* @example filter "time:>1747053793"
@@ -82,6 +118,7 @@ export class NetworkController extends XapiXoController<XoNetwork> {
*/
@Example(genericAlarmsExample)
@Get('{id}/alarms')
@Security('*', ['acl'])
@Tags('alarms')
@Response(notFoundResp.status, notFoundResp.description)
getNetworkAlarms(
@@ -96,13 +133,19 @@ export class NetworkController extends XapiXoController<XoNetwork> {
const network = this.getObject(id as XoNetwork['id'])
const alarms = this.#alarmService.getAlarms({
filter: `${escapeUnsafeComplexMatcher(filter) ?? ''} object:uuid:${network.uuid}`,
limit,
})
return this.sendObjects(Object.values(alarms), req, 'alarms')
return this.sendObjects(Object.values(alarms), req, {
path: 'alarms',
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
* @example fields "name,id,$object"
* @example filter "name:VM_STARTED"
@@ -111,6 +154,7 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Example(messageIds)
@Example(partialMessages)
@Get('{id}/messages')
@Security('*', ['acl'])
@Tags('messages')
@Response(notFoundResp.status, notFoundResp.description)
getNetworkMessages(
@@ -122,12 +166,19 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoMessage>>> {
const messages = this.getMessagesForObject(id as XoNetwork['id'], { filter, limit })
const messages = this.getMessagesForObject(id as XoNetwork['id'], { filter })
return this.sendObjects(Object.values(messages), req, 'messages')
return this.sendObjects(Object.values(messages), req, {
path: 'messages',
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -136,6 +187,7 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getNetworkTasks(
@@ -146,18 +198,27 @@ export class NetworkController extends XapiXoController<XoNetwork> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoNetwork['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoNetwork['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
/**
* Required privilege:
* - resource: network, action: update:tags
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
* @example tag "from-rest-api"
*/
@Put('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'network', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async putNetworkTag(@Path() id: string, @Path() tag: string): Promise<void> {
const network = this.getXapiObject(id as XoNetwork['id'])
@@ -165,11 +226,16 @@ export class NetworkController extends XapiXoController<XoNetwork> {
}
/**
* Required privilege:
* - resource: network, action: update:tags
*
* @example id "9fe12ca3-d75d-cfb0-492e-cfd2bc6c568f"
* @example tag "from-rest-api"
*/
@Delete('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'network', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteNetworkTag(@Path() id: string, @Path() tag: string): Promise<void> {
const network = this.getXapiObject(id as XoNetwork['id'])
@@ -0,0 +1,27 @@
export const aclPrivilegeIds = [
'/rest/v0/acl-privileges/6d16ac11-ad48-483a-a926-3d9655648b28',
'/rest/v0/acl-privileges/7a81ac36-264e-468e-ad4d-63d9a10aee6a',
]
export const partialAclPrivileges = [
{
id: '6d16ac11-ad48-483a-a926-3d9655648b28',
action: 'create',
resource: 'vif',
href: '/rest/v0/acl-privileges/6d16ac11-ad48-483a-a926-3d9655648b28',
},
{
id: '7a81ac36-264e-468e-ad4d-63d9a10aee6a',
action: 'create',
resource: 'vdi',
href: '/rest/v0/acl-privileges/7a81ac36-264e-468e-ad4d-63d9a10aee6a',
},
]
export const aclPrivilege = {
action: 'create',
effect: 'allow',
resource: 'vif',
roleId: 'ccaab6e6-bae6-4d9c-9866-911dfc88bdc6',
id: '6d16ac11-ad48-483a-a926-3d9655648b28',
}
@@ -0,0 +1,24 @@
export const aclRoleIds = [
'/rest/v0/acl-roles/9cac02e2-f612-4b71-851e-d28b9c0cda88',
'/rest/v0/acl-roles/784bd959-08de-4b26-b575-92ded5aef872',
]
export const partialAclRoles = [
{
id: '9cac02e2-f612-4b71-851e-d28b9c0cda88',
name: 'VMs read only',
isTemplate: true,
href: '/rest/v0/acl-roles/9cac02e2-f612-4b71-851e-d28b9c0cda88',
},
{
id: '784bd959-08de-4b26-b575-92ded5aef872',
name: 'mra-test-read-only',
href: '/rest/v0/acl-roles/784bd959-08de-4b26-b575-92ded5aef872',
},
]
export const aclRole = {
name: 'mra-test-read-only',
description: 'Access the whole infra in read-only mode',
id: '784bd959-08de-4b26-b575-92ded5aef872',
}
@@ -1,11 +1,11 @@
export const backupArchiveIds = [
'/rest/v0/backup-archives/231264c3-af43-4ec0-a3be-394c5b1fdbfc//xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json',
'/rest/v0/backup-archives/1af95910-01b4-4e87-9c2f-d895cafe0776//xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
'/rest/v0/backup-archives/231264c3-af43-4ec0-a3be-394c5b1fdbfc/xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json',
'/rest/v0/backup-archives/1af95910-01b4-4e87-9c2f-d895cafe0776/xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
]
export const partialBackupArchives = [
{
id: '231264c3-af43-4ec0-a3be-394c5b1fdbfc//xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json',
id: '231264c3-af43-4ec0-a3be-394c5b1fdbfc/xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json',
backupRepository: '231264c3-af43-4ec0-a3be-394c5b1fdbfc',
disks: [
{
@@ -22,7 +22,7 @@ export const partialBackupArchives = [
href: '/rest/v0/backup-archives/231264c3-af43-4ec0-a3be-394c5b1fdbfc/xo-vm-backups/6ef7c09e-677b-1e6f-0546-7ab30413c61c/20250801T080832Z.json',
},
{
id: '1af95910-01b4-4e87-9c2f-d895cafe0776//xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
id: '1af95910-01b4-4e87-9c2f-d895cafe0776/xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
backupRepository: '1af95910-01b4-4e87-9c2f-d895cafe0776',
disks: [
{
@@ -31,7 +31,7 @@ export const partialBackupArchives = [
uuid: '73ed06ed-fdc8-43ef-a1c4-253e9005fbe0',
},
],
href: '/rest/v0/backup-archives/1af95910-01b4-4e87-9c2f-d895cafe0776//xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
href: '/rest/v0/backup-archives/1af95910-01b4-4e87-9c2f-d895cafe0776/xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
},
]
@@ -45,7 +45,7 @@ export const backupArchive = {
uuid: 'c922ef3c-9d76-4482-87f8-a4da5849ee45',
},
],
id: '1af95910-01b4-4e87-9c2f-d895cafe0776//xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
id: '1af95910-01b4-4e87-9c2f-d895cafe0776/xo-vm-backups/7cf6150f-a978-09e6-6b41-0d1d41967bdc/20250918T132942Z.json',
jobId: 'f2599aa4-7bb4-434b-bf71-cf0ebe1e06a4',
mode: 'delta',
scheduleId: '8db1c2da-2635-436f-8f78-62079fea3aa6',
@@ -0,0 +1,3 @@
export const entityId = {
id: '9cac02e2-f612-4b71-851e-d28b9c0cda88',
}
@@ -1,15 +1,17 @@
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import { Route, Security, Request, Response, Get, Query, Path, Tags, Example, Post, SuccessResponse } from 'tsoa'
import { Middlewares, Route, Security, Request, Response, Get, Query, Path, Tags, Example, Post, SuccessResponse } from 'tsoa'
import type { XoPbd } from '@vates/types'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
asynchronousActionResp,
badRequestResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
noContentResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
@@ -33,6 +35,9 @@ export class PbdController extends XapiXoController<XoPbd> {
}
/**
* Returns all PBDs that match the following privilege:
* - resource: pbd, action: read
*
* @example fields "attached,id,device_config"
* @example filter "attached?"
* @example limit 42
@@ -40,6 +45,7 @@ export class PbdController extends XapiXoController<XoPbd> {
@Example(pbdIds)
@Example(partialPbds)
@Get('')
@Security('*', ['acl'])
getPbds(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -48,14 +54,23 @@ export class PbdController extends XapiXoController<XoPbd> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoPbd>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'pbd' },
})
}
/**
* Required privilege:
* - resource: pbd, action: read
*
* @example id "16b2a60f-7c4d-f45f-7c7a-963b06fc587d"
*/
@Example(pbd)
@Get('{id}')
@Middlewares(acl({ resource: 'pbd', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPbd(@Path() id: string): Unbrand<XoPbd> {
return this.getObject(id as XoPbd['id'])
}
@@ -1,10 +1,17 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import type { XoPci } from '@vates/types'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialPcis, pci, pciIds } from '../open-api/oa-examples/pci.oa-example.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
@@ -22,6 +29,9 @@ export class PciController extends XapiXoController<XoPci> {
}
/**
* Returns all PCIs that match the following privilege:
* - resource: pci, action: read
*
* @example fields "class_name,device_name,id"
* @example filter "class_name:Non-Volatile memory controller"
* @example limit 42
@@ -29,6 +39,7 @@ export class PciController extends XapiXoController<XoPci> {
@Example(pciIds)
@Example(partialPcis)
@Get('')
@Security('*', ['acl'])
getPcis(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -37,14 +48,22 @@ export class PciController extends XapiXoController<XoPci> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoPci>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'pci' },
})
}
/**
* Required privilege:
* - resource: pci, action: read
*
* @example id "9377b642-cc71-8749-1e71-308898b652da"
*/
@Example(pci)
@Get('{id}')
@Middlewares(acl({ resource: 'pci', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPci(@Path() id: string): Unbrand<XoPci> {
return this.getObject(id as XoPci['id'])
@@ -4,8 +4,15 @@ import type { Request as ExRequest } from 'express'
import { RestApi } from '../rest-api/rest-api.mjs'
import { XapiXoController } from '../abstract-classes/xapi-xo-controller.mjs'
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { badRequestResp, notFoundResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { provide } from 'inversify-binding-decorators'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { partialPgpus, pgpu, pgpuIds } from '../open-api/oa-examples/pgpu.oa-example.mjs'
@@ -22,6 +29,9 @@ export class PgpuController extends XapiXoController<XoPgpu> {
}
/**
* Returns all PGPUs that match the following privilege:
* - resource: pgpu, action: read
*
* @example fields "id,dom0Access,gpuGroup"
* @example filter "dom0Access:enabled"
* @example limit 42
@@ -29,6 +39,7 @@ export class PgpuController extends XapiXoController<XoPgpu> {
@Example(pgpuIds)
@Example(partialPgpus)
@Get('')
@Security('*', ['acl'])
getPgpus(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -37,14 +48,22 @@ export class PgpuController extends XapiXoController<XoPgpu> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoPgpu>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'pgpu' },
})
}
/**
* Required privilege:
* - resource: pgpu, action: read
*
* @example id "838335fa-ee21-15e1-760a-a37a3a4ef1db"
*/
@Example(pgpu)
@Get('{id}')
@Middlewares(acl({ resource: 'pgpu', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPgpu(@Path() id: string): Unbrand<XoPgpu> {
return this.getObject(id as XoPgpu['id'])
@@ -1,4 +1,4 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
@@ -6,8 +6,15 @@ import type { XoAlarm, XoMessage, XoPif, XoTask } from '@vates/types'
import { AlarmService } from '../alarms/alarm.service.mjs'
import { escapeUnsafeComplexMatcher } from '../helpers/utils.helper.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
import { genericAlarmsExample } from '../open-api/oa-examples/alarm.oa-example.mjs'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialPifs, pif, pifIds } from '../open-api/oa-examples/pif.oa-example.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
@@ -31,6 +38,9 @@ export class PifController extends XapiXoController<XoPif> {
}
/**
* Returns all PIFs that match the following privilege:
* - resource: pif, action: read
*
* @example fields "attached,device,deviceName,id"
* @example filter "attached?"
* @example limit 42
@@ -38,6 +48,7 @@ export class PifController extends XapiXoController<XoPif> {
@Example(pifIds)
@Example(partialPifs)
@Get('')
@Security('*', ['acl'])
getPifs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -46,20 +57,31 @@ export class PifController extends XapiXoController<XoPif> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<UnbrandedXoPif>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'pif' },
})
}
/**
* Required privilege:
* - resource: pif, action: read
*
* @example id "d9e42451-3794-089f-de81-4ee0e6137bee"
*/
@Example(pif)
@Get('{id}')
@Middlewares(acl({ resource: 'pif', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPif(@Path() id: string): UnbrandedXoPif {
return this.getObject(id as XoPif['id'])
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example id "d9e42451-3794-089f-de81-4ee0e6137bee"
* @example fields "id,time"
* @example filter "time:>1747053793"
@@ -67,6 +89,7 @@ export class PifController extends XapiXoController<XoPif> {
*/
@Example(genericAlarmsExample)
@Get('{id}/alarms')
@Security('*', ['acl'])
@Tags('alarms')
@Response(notFoundResp.status, notFoundResp.description)
getPifAlarms(
@@ -81,13 +104,19 @@ export class PifController extends XapiXoController<XoPif> {
const pif = this.getObject(id as XoPif['id'])
const alarms = this.#alarmService.getAlarms({
filter: `${escapeUnsafeComplexMatcher(filter) ?? ''} object:uuid:${pif.uuid}`,
limit,
})
return this.sendObjects(Object.values(alarms), req, 'alarms')
return this.sendObjects(Object.values(alarms), req, {
path: 'alarms',
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example id "d9e42451-3794-089f-de81-4ee0e6137bee"
* @example fields "name,id,$object"
* @example filter "name:VM_STARTED"
@@ -96,6 +125,7 @@ export class PifController extends XapiXoController<XoPif> {
@Example(messageIds)
@Example(partialMessages)
@Get('{id}/messages')
@Security('*', ['acl'])
@Tags('messages')
@Response(notFoundResp.status, notFoundResp.description)
getPifMessages(
@@ -107,12 +137,19 @@ export class PifController extends XapiXoController<XoPif> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoMessage>>> {
const messages = this.getMessagesForObject(id as XoPif['id'], { filter, limit })
const messages = this.getMessagesForObject(id as XoPif['id'], { filter })
return this.sendObjects(Object.values(messages), req, 'messages')
return this.sendObjects(Object.values(messages), req, {
path: 'messages',
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "d9e42451-3794-089f-de81-4ee0e6137bee"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -121,6 +158,7 @@ export class PifController extends XapiXoController<XoPif> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getPifTasks(
@@ -131,9 +169,13 @@ export class PifController extends XapiXoController<XoPif> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoPif['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoPif['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
}
@@ -22,12 +22,14 @@ import { provide } from 'inversify-binding-decorators'
import { json, type Request as ExRequest, type Response as ExResponse } from 'express'
import type { VatesTask } from '@vates/types/lib/vates/task'
import { acl } from '../middlewares/acl.middleware.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import {
asynchronousActionResp,
badRequestResp,
createdResp,
featureUnauthorized,
forbiddenOperationResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
noContentResp,
@@ -115,6 +117,8 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Returns all pools that match the following privilege:
* - resource: pool, action: read
*
* @example fields "auto_poweron,name_label,id"
* @example filter "auto_poweron?"
@@ -123,6 +127,7 @@ export class PoolController extends XapiXoController<XoPool> {
@Example(poolIds)
@Example(partialPools)
@Get('')
@Security('*', ['acl'])
getPools(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -131,20 +136,32 @@ export class PoolController extends XapiXoController<XoPool> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoPool>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'pool' },
})
}
/**
* Required privilege:
* - resource: pool, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
*/
@Example(pool)
@Get('{id}')
@Middlewares(acl({ resource: 'pool', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getPool(@Path() id: string): Unbrand<XoPool> {
return this.getObject(id as XoPool['id'])
}
/**
* Required privilege:
* - resource: pool, action: create:network
* - resource: network, action: create
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example body {
* "name": "awes0me_network",
@@ -156,9 +173,17 @@ export class PoolController extends XapiXoController<XoPool> {
@Example(taskLocation)
@Example(createNetwork)
@Post('{id}/actions/create_network')
@Middlewares(json())
@Middlewares([
json(),
acl([
// these two rights are a bit redundant, but for now this is the only way to restrict network creation on a given pool
{ resource: 'pool', action: 'create:network', objectId: 'params.id' },
{ resource: 'network', action: 'create', object: ({ req }) => req.body },
]),
])
@Tags('networks')
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(invalidParametersResp.status, invalidParametersResp.description)
@@ -271,11 +296,16 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: emergency-shutdown
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
*/
@Example(taskLocation)
@Post('{id}/actions/emergency_shutdown')
@Middlewares(acl({ resource: 'pool', action: 'emergency-shutdown', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
@Response(notFoundResp.status, notFoundResp.description)
@@ -297,11 +327,16 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: rolling-reboot
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
*/
@Example(taskLocation)
@Post('{id}/actions/rolling_reboot')
@Middlewares(acl({ resource: 'pool', action: 'rolling-reboot', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
@Response(notFoundResp.status, notFoundResp.description)
@@ -324,11 +359,16 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: rolling-update
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
*/
@Example(taskLocation)
@Post('{id}/actions/rolling_update')
@Middlewares(acl({ resource: 'pool', action: 'rolling-update', objectId: 'params.id' }))
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
@Response(notFoundResp.status, notFoundResp.description)
@@ -354,14 +394,34 @@ export class PoolController extends XapiXoController<XoPool> {
/**
* Import an XVA VM into a pool
*
* Required privilege:
* - resource: sr, action: import:vm
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
* @example sr "c787b75c-3e0d-70fa-d0c3-cbfd382d7e33"
*
*/
@Example(importVm)
@Post('{id}/vms')
@Middlewares(
acl([
{
resource: 'sr',
action: 'import:vm',
objectId: ({ req, restApi }) => {
if (req.query.sr) {
return req.query.sr as XoSr['id']
} else {
const pool = restApi.getObject(req.params.id as XoPool['id'], 'pool') as XoPool
return pool.default_SR as XoSr['id']
}
},
},
])
)
@Tags('vms')
@SuccessResponse(createdResp.status, 'VM imported')
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async importVm(
@@ -386,6 +446,14 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: create:vm
* - resource: vm-template, action: instantiate
* - resource: vdi, action: create (if vdis is passed)
* - resource: vdi, action: boot (if install.repository is passed)
* - resource: vif, action: create (if vifs is passed)
* - resource: host, action: allow-vm (if affinity is passed)
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
* @example body {
* "name_label": "new VM from REST API",
@@ -396,9 +464,42 @@ export class PoolController extends XapiXoController<XoPool> {
@Example(taskLocation)
@Example(createVm)
@Post('{id}/actions/create_vm')
@Middlewares(json())
@Middlewares([
json(),
acl([
{ resource: 'pool', action: 'create:vm', objectId: 'params.id' },
{
resource: 'vm-template',
action: 'instantiate',
objectId: ({ req, restApi }) => {
const pool = restApi.getXapiObject<XoPool>(req.params.id as XoPool['id'], 'pool')
const template = pool.$xapi.getObject<XoVm>(req.body.template)
if (template.is_default_template) {
return `${pool.uuid}-${template.uuid}`
}
return req.body.template
},
},
{
resource: 'vdi',
action: 'boot',
objectId: ({ req }) => {
const repository = req.body.install?.repository
if (repository !== '') {
return repository
}
},
},
{ resource: 'vdi', action: 'create', objects: ({ req }) => req.body.vdis },
{ resource: 'vif', action: 'create', objects: ({ req }) => req.body.vifs },
{ resource: 'host', action: 'allow-vm', objectId: 'body.affinity' },
]),
])
@Tags('vms')
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
@@ -438,10 +539,15 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
*/
@Example(poolStats)
@Get('{id}/stats')
@Middlewares(acl({ resource: 'pool', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(422, 'Invalid granularity')
getStats(@Path() id: string, @Query() granularity?: XapiStatsGranularity): Promise<XapiPoolStats> {
@@ -449,10 +555,15 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629677"
*/
@Example(poolDashboard)
@Get('{id}/dashboard')
@Middlewares(acl({ resource: 'pool', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async getPoolDashboard(
@Request() req: ExRequest,
@@ -481,6 +592,9 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example fields "id,time"
* @example filter "time:>1747053793"
@@ -488,6 +602,7 @@ export class PoolController extends XapiXoController<XoPool> {
*/
@Example(genericAlarmsExample)
@Get('{id}/alarms')
@Security('*', ['acl'])
@Tags('alarms')
@Response(notFoundResp.status, notFoundResp.description)
getPoolAlarms(
@@ -502,17 +617,25 @@ export class PoolController extends XapiXoController<XoPool> {
const pool = this.getObject(id as XoPool['id'])
const alarms = this.#alarmService.getAlarms({
filter: `${escapeUnsafeComplexMatcher(filter) ?? ''} object:uuid:${pool.uuid}`,
limit,
})
return this.sendObjects(Object.values(alarms), req, 'alarms')
return this.sendObjects(Object.values(alarms), req, {
path: 'alarms',
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Required privilege:
* - resource: pool, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
*/
@Example(poolMissingPatches)
@Get('{id}/missing_patches')
@Middlewares(acl({ resource: 'pool', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
async getPoolMissingPatches(@Path() id: string): Promise<XcpPatches[] | XsPatches[]> {
@@ -523,6 +646,9 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example fields "name,id,$object"
* @example filter "name:IP_CONFIGURED_PIF_CAN_UNPLUG"
@@ -531,6 +657,7 @@ export class PoolController extends XapiXoController<XoPool> {
@Example(messageIds)
@Example(partialMessages)
@Get('{id}/messages')
@Security('*', ['acl'])
@Tags('messages')
@Response(notFoundResp.status, notFoundResp.description)
getPoolMessages(
@@ -542,17 +669,26 @@ export class PoolController extends XapiXoController<XoPool> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoMessage>>> {
const messages = this.getMessagesForObject(id as XoPool['id'], { filter, limit })
const messages = this.getMessagesForObject(id as XoPool['id'], { filter })
return this.sendObjects(Object.values(messages), req, 'messages')
return this.sendObjects(Object.values(messages), req, {
path: 'messages',
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Required privilege:
* - resource: pool, action: update:tags
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example tag "from-rest-api"
*/
@Put('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'pool', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async putPoolTag(@Path() id: string, @Path() tag: string): Promise<void> {
const pool = this.getXapiObject(id as XoPool['id'])
@@ -560,11 +696,16 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Required privilege:
* - resource: pool, action: update:tags
*
* @example id "355ee47d-ff4c-4924-3db2-fd86ae629676"
* @example tag "from-rest-api"
*/
@Delete('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'pool', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deletePoolTag(@Path() id: string, @Path() tag: string): Promise<void> {
const pool = this.getXapiObject(id as XoPool['id'])
@@ -572,6 +713,9 @@ export class PoolController extends XapiXoController<XoPool> {
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example fields "id,status,properties"
* @example filter "status:failure"
* @example limit 42
@@ -579,6 +723,7 @@ export class PoolController extends XapiXoController<XoPool> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getPoolTasks(
@@ -589,10 +734,14 @@ export class PoolController extends XapiXoController<XoPool> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoPool['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoPool['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
/**
@@ -1,10 +1,17 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import type { XoProxy } from '@vates/types'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { partialProxies, proxy, proxyIds } from '../open-api/oa-examples/proxy.oa-example.mjs'
import type { SendObjects } from '../helpers/helper.type.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
@@ -29,6 +36,9 @@ export class ProxyController extends XoController<XoProxy> {
}
/**
* Returns all proxies that match the following privilege:
* - resource: proxy, action: read
*
* @example fields "vmUuid,id,name"
* @example filter "vmUuid?"
* @example limit 42
@@ -36,6 +46,7 @@ export class ProxyController extends XoController<XoProxy> {
@Example(proxyIds)
@Example(partialProxies)
@Get('')
@Security('*', ['acl'])
async getProxies(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -43,16 +54,31 @@ export class ProxyController extends XoController<XoProxy> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoProxy>>>> {
const proxies = Object.values(await this.getObjects({ filter, limit }))
return this.sendObjects(proxies, req)
): SendObjects<Partial<Unbrand<XoProxy>>> {
const proxies = Object.values(await this.getObjects({ filter }))
return this.sendObjects(proxies, req, {
limit,
privilege: { action: 'read', resource: 'proxy' },
})
}
/**
* Required privilege:
* - resource: proxy, action: read
*
* @example id "e625ea0c-a876-405a-b838-109d762efe88"
*/
@Example(proxy)
@Get('{id}')
@Middlewares(
acl({
resource: 'proxy',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getProxy,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getProxy(@Path() id: string): Promise<Unbrand<XoProxy>> {
return this.getObject(id as XoProxy['id'])
@@ -4,6 +4,7 @@ import type { XapiXoRecord, XoApp, XoUser } from '@vates/types'
import type { Container } from 'inversify'
import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import { AnyPrivilege } from '@xen-orchestra/acl'
const log = createLogger('xo:rest-api:error-handler')
@@ -2,13 +2,19 @@ import { AnyXoLog, XoRestoreLog } from '@vates/types'
import { createLogger } from '@xen-orchestra/log'
import { Deprecated, Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { noSuchObject } from 'xo-common/api-errors.js'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import { acl, autoBindService } from '../middlewares/acl.middleware.mjs'
import { BackupLogService } from '../backup-logs/backup-log.service.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { badRequestResp, unauthorizedResp, Unbrand } from '../open-api/common/response.common.mjs'
import {
badRequestResp,
forbiddenOperationResp,
notFoundResp,
unauthorizedResp,
Unbrand,
} from '../open-api/common/response.common.mjs'
import { XoController } from '../abstract-classes/xo-controller.mjs'
import { SendObjects } from '../helpers/helper.type.mjs'
import { partialRestoreLogs, restoreLog, restoreLogIds } from '../open-api/oa-examples/restore-log.oa-example.mjs'
@@ -34,15 +40,14 @@ export class RestoreLogController extends XoController<XoRestoreLog> {
return this.restApi.xoApp.getBackupNgLogsSorted({ filter }) as Promise<XoRestoreLog[]>
}
async getCollectionObject(id: AnyXoLog['id']): Promise<XoRestoreLog> {
const log = await this.restApi.xoApp.getBackupNgLogs(id)
if (this.#backupLogService.isBackupLog(log)) {
throw noSuchObject('restore-log')
}
return log
getCollectionObject(id: AnyXoLog['id']): Promise<XoRestoreLog> {
return this.#backupLogService.getRestoreLog(id)
}
/**
* Returns all restore logs that match the following privilege:
* - resource: restore-log, action: read
*
* @example fields "jobName,status,data"
* @example filter "status:success"
* @example limit 42
@@ -50,6 +55,7 @@ export class RestoreLogController extends XoController<XoRestoreLog> {
@Example(restoreLogIds)
@Example(partialRestoreLogs)
@Get('')
@Security('*', ['acl'])
async getRestoreLogs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -58,15 +64,31 @@ export class RestoreLogController extends XoController<XoRestoreLog> {
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoRestoreLog>>>> {
const restoreLogs = await this.getObjects({ filter, limit })
return this.sendObjects(Object.values(restoreLogs), req)
const restoreLogs = await this.getObjects({ filter })
return this.sendObjects(Object.values(restoreLogs), req, {
limit,
privilege: { action: 'read', resource: 'restore-log' },
})
}
/**
* @example id "fo"
* Required privilege:
* - resource: restore-log, action: read
*
* @example id "1758180544428"
*/
@Example(restoreLog)
@Get('{id}')
@Middlewares(
acl({
resource: 'restore-log',
action: 'read',
objectId: 'params.id',
getObject: autoBindService(BackupLogService, 'getRestoreLog'),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getRestoreLog(@Path() id: string): Promise<Unbrand<XoRestoreLog>> {
return this.getObject(id as XoRestoreLog['id'])
}
@@ -98,15 +120,14 @@ export class DeprecatedRestoreController extends XoController<XoRestoreLog> {
return this.restApi.xoApp.getBackupNgLogsSorted({ filter }) as Promise<XoRestoreLog[]>
}
async getCollectionObject(id: AnyXoLog['id']): Promise<XoRestoreLog> {
const log = await this.restApi.xoApp.getBackupNgLogs(id)
if (this.#backupLogService.isBackupLog(log)) {
throw noSuchObject('restore-log')
}
return log
getCollectionObject(id: AnyXoLog['id']): Promise<XoRestoreLog> {
return this.#backupLogService.getRestoreLog(id)
}
/**
* Returns all restore logs that match the following privilege:
* - resource: restore-log, action: read
*
* @example fields "jobName,status,data"
* @example filter "status:success"
* @example limit 42
@@ -122,9 +143,13 @@ export class DeprecatedRestoreController extends XoController<XoRestoreLog> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoRestoreLog>>>> {
const restoreLogs = await this.getObjects({ filter, limit })
return this.sendObjects(Object.values(restoreLogs), req, 'restore-logs')
): SendObjects<Partial<Unbrand<XoRestoreLog>>> {
const restoreLogs = await this.getObjects({ filter })
return this.sendObjects(Object.values(restoreLogs), req, {
path: 'restore-logs',
limit,
privilege: { action: 'read', resource: 'restore-log' },
})
}
/**
@@ -1,13 +1,28 @@
import type { XoSchedule } from '@vates/types'
import { Example, Get, Path, Post, Query, Request, Response, Route, Security, SuccessResponse, Tags } from 'tsoa'
import {
Example,
Get,
Middlewares,
Path,
Post,
Query,
Request,
Response,
Route,
Security,
SuccessResponse,
Tags,
} from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
asynchronousActionResp,
badRequestResp,
featureUnauthorized,
forbiddenOperationResp,
internalServerErrorResp,
noContentResp,
notFoundResp,
@@ -41,6 +56,9 @@ export class ScheduleController extends XoController<XoSchedule> {
}
/**
* Returns all schedules that match the following privilege:
* - resource: schedule, action: read
*
* @example fields "enabled,jobId,cron,id"
* @example filter "enabled?"
* @example limit 42
@@ -48,6 +66,7 @@ export class ScheduleController extends XoController<XoSchedule> {
@Example(scheduleIds)
@Example(partialSchedules)
@Get('')
@Security('*', ['acl'])
async getSchedules(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -55,28 +74,63 @@ export class ScheduleController extends XoController<XoSchedule> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoSchedule>>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter, limit })), req)
): SendObjects<Partial<Unbrand<XoSchedule>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'schedule' },
})
}
/**
*
* Required privilege:
* - resource: schedule, action: read
*
* @example id "cf7249f8-d20b-494f-97f4-b1f32f94e780"
*/
@Example(schedule)
@Get('{id}')
@Middlewares(
acl({
resource: 'schedule',
action: 'read',
objectId: 'params.id',
getObject:
({ restApi }) =>
id =>
restApi.xoApp.getSchedule(id),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async getSchedule(@Path() id: string): Promise<Unbrand<XoSchedule>> {
return this.getObject(id as XoSchedule['id'])
}
/**
*
* Required privilege:
* - resource: schedule, action: run
*
* @example id "cf7249f8-d20b-494f-97f4-b1f32f94e780"
*/
@Example(taskLocation)
@Post('{id}/actions/run')
@Middlewares(
acl({
resource: 'schedule',
action: 'run',
objectId: 'params.id',
getObject:
({ restApi }) =>
id =>
restApi.xoApp.getSchedule(id),
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(featureUnauthorized.status, featureUnauthorized.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
async runSchedule(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
@@ -21,10 +21,12 @@ import type { XoServer, XoTask } from '@vates/types'
import type { InsertableXoServer } from './server.type.mjs'
import { acl } from '../middlewares/acl.middleware.mjs'
import {
asynchronousActionResp,
badRequestResp,
createdResp,
forbiddenOperationResp,
invalidParameters,
noContentResp,
notFoundResp,
@@ -59,6 +61,9 @@ export class ServerController extends XoController<XoServer> {
}
/**
* Returns all servers that match the following privilege:
* - resource: server, action: read
*
* @example fields "status,id"
* @example filter "status:/^connected$/"
* @example limit 42
@@ -66,6 +71,7 @@ export class ServerController extends XoController<XoServer> {
@Example(serverIds)
@Example(partialServers)
@Get('')
@Security('*', ['acl'])
async getServers(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -74,30 +80,60 @@ export class ServerController extends XoController<XoServer> {
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoServer>>>> {
return this.sendObjects(Object.values(await this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(await this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'server' },
})
}
/**
* Required privilege:
* - resource: server, action: read
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
*/
@Example(server)
@Get('{id}')
@Middlewares(
acl({
resource: 'server',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getXenServer,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getServer(@Path() id: string): Promise<Unbrand<XoServer>> {
return this.getObject(id as XoServer['id'])
}
/**
* Required privilege:
* - resource: server, action: delete
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'server',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getXenServer,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteServer(@Path() id: string) {
await this.restApi.xoApp.unregisterXenServer(id as XoServer['id'])
}
/**
* Required privilege:
* - resource: server, action: create
*
* @example body {
* "allowUnauthorized": true,
* "host": "192.168.1.10",
@@ -108,8 +144,9 @@ export class ServerController extends XoController<XoServer> {
*/
@Example(serverId)
@Post('')
@Middlewares(json())
@Middlewares([json(), acl({ resource: 'server', action: 'create', object: ({ req }) => req.body })])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
@Response(invalidParameters.status, invalidParameters.description)
async addServer(@Body() body: InsertableXoServer): Promise<{ id: string }> {
@@ -118,12 +155,24 @@ export class ServerController extends XoController<XoServer> {
}
/**
* Required privilege:
* - resource: server, action: connect
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
*/
@Example(taskLocation)
@Post('{id}/actions/connect')
@Middlewares(
acl({
resource: 'server',
action: 'connect',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getXenServer,
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(409, 'The server is already connected')
connectServer(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
@@ -140,12 +189,24 @@ export class ServerController extends XoController<XoServer> {
}
/**
* Required privilege:
* - resource: server, action: disconnect
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
*/
@Example(taskLocation)
@Post('{id}/actions/disconnect')
@Middlewares(
acl({
resource: 'server',
action: 'disconnect',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getXenServer,
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(409, 'The server is already disconnected')
disconnectServer(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
@@ -162,6 +223,9 @@ export class ServerController extends XoController<XoServer> {
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "f07ab729-c0e8-721c-45ec-f11276377030"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -170,6 +234,7 @@ export class ServerController extends XoController<XoServer> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getServerTasks(
@@ -180,9 +245,13 @@ export class ServerController extends XoController<XoServer> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoServer['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoServer['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
}
@@ -1,9 +1,10 @@
import { Example, Get, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { Example, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa'
import { inject } from 'inversify'
import { provide } from 'inversify-binding-decorators'
import type { Request as ExRequest } from 'express'
import type { XoSm } from '@vates/types'
import { acl } from '../middlewares/acl.middleware.mjs'
import { badRequestResp, notFoundResp, unauthorizedResp, type Unbrand } from '../open-api/common/response.common.mjs'
import { partialSms, sm, smIds } from '../open-api/oa-examples/sm.oa-example.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
@@ -22,6 +23,9 @@ export class SmController extends XapiXoController<XoSm> {
}
/**
* Returns all SMs that match the following privilege:
* - resource: sm, action: read
*
* @example fields "uuid,name_label,SM_type"
* @example filter "SM_type:ext"
* @example limit 42
@@ -29,6 +33,7 @@ export class SmController extends XapiXoController<XoSm> {
@Example(smIds)
@Example(partialSms)
@Get('')
@Security('*', ['acl'])
getSrs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -37,14 +42,21 @@ export class SmController extends XapiXoController<XoSm> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoSm>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'sm' },
})
}
/**
* Required privilege:
* - resource: sm, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
*/
@Example(sm)
@Get('{id}')
@Middlewares(acl({ resource: 'sm', action: 'read', objectId: 'params.id' }))
@Response(notFoundResp.status, notFoundResp.description)
getSr(@Path() id: string): Unbrand<XoSm> {
return this.getObject(id as XoSm['id'])
@@ -2,6 +2,7 @@ import {
Delete,
Example,
Get,
Middlewares,
Path,
Post,
Put,
@@ -19,6 +20,7 @@ import { Request as ExRequest } from 'express'
import type { XenApiVdi, XoMessage, XoTask, XoVdi, XoAlarm, XoSr } from '@vates/types'
import { SUPPORTED_VDI_FORMAT } from '@vates/types'
import { acl } from '../middlewares/acl.middleware.mjs'
import { AlarmService } from '../alarms/alarm.service.mjs'
import { BASE_URL } from '../index.mjs'
import { escapeUnsafeComplexMatcher } from '../helpers/utils.helper.mjs'
@@ -29,6 +31,7 @@ import {
createdResp,
internalServerErrorResp,
invalidParameters as invalidParametersResp,
forbiddenOperationResp,
noContentResp,
notFoundResp,
unauthorizedResp,
@@ -57,6 +60,9 @@ export class SrController extends XapiXoController<XoSr> {
}
/**
* Returns all SRs that match the following privilege:
* - resource: sr, action: read
*
* @example fields "uuid,name_label,allocationStrategy"
* @example filter "allocationStrategy:thin"
* @example limit 42
@@ -64,6 +70,7 @@ export class SrController extends XapiXoController<XoSr> {
@Example(srIds)
@Example(partialSrs)
@Get('')
@Security('*', ['acl'])
getSrs(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -72,20 +79,31 @@ export class SrController extends XapiXoController<XoSr> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoSr>>> {
return this.sendObjects(Object.values(this.getObjects({ filter, limit })), req)
return this.sendObjects(Object.values(this.getObjects({ filter })), req, {
limit,
privilege: { action: 'read', resource: 'sr' },
})
}
/**
* Required privilege:
* - resource: sr, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
*/
@Example(sr)
@Get('{id}')
@Middlewares(acl({ resource: 'sr', action: 'read', objectId: 'params.id' }))
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getSr(@Path() id: string): Unbrand<XoSr> {
return this.getObject(id as XoSr['id'])
}
/**
* Returns all alarms that match the following privilege:
* - resource: alarm, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example fields "id,time"
* @example filter "time:>1747053793"
@@ -93,6 +111,7 @@ export class SrController extends XapiXoController<XoSr> {
*/
@Example(genericAlarmsExample)
@Get('{id}/alarms')
@Security('*', ['acl'])
@Tags('alarms')
@Response(notFoundResp.status, notFoundResp.description)
getSrAlarms(
@@ -107,14 +126,21 @@ export class SrController extends XapiXoController<XoSr> {
const sr = this.getObject(id as XoSr['id'])
const alarms = this.#alarmService.getAlarms({
filter: `${escapeUnsafeComplexMatcher(filter) ?? ''} object:uuid:${sr.uuid}`,
limit,
})
return this.sendObjects(Object.values(alarms), req, 'alarms')
return this.sendObjects(Object.values(alarms), req, {
path: 'alarms',
limit,
privilege: { action: 'read', resource: 'alarm' },
})
}
/**
* Import an exported VDI
*
* Required privilege:
* - resource: sr, action: import:vdi
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example name_label "VDI_foo_import"
* @example name_description "VDI imported by the REST API"
@@ -122,8 +148,10 @@ export class SrController extends XapiXoController<XoSr> {
*/
@Example(vdiId)
@Post('{id}/vdis')
@Middlewares(acl({ resource: 'sr', action: 'import:vdi', objectId: 'params.id' }))
@Tags('vdis')
@SuccessResponse(createdResp.status, 'VDI imported')
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async srImportVdi(
@Request() req: ExRequest & { length?: number },
@@ -152,6 +180,9 @@ export class SrController extends XapiXoController<XoSr> {
}
/**
* Returns all messages that match the following privilege:
* - resource: message, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example fields "name,id,$object"
* @example filter "name:VM_STARTED"
@@ -160,6 +191,7 @@ export class SrController extends XapiXoController<XoSr> {
@Example(messageIds)
@Example(partialMessages)
@Get('{id}/messages')
@Security('*', ['acl'])
@Tags('messages')
@Response(notFoundResp.status, notFoundResp.description)
getSrMessages(
@@ -171,12 +203,19 @@ export class SrController extends XapiXoController<XoSr> {
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<XoMessage>>> {
const messages = this.getMessagesForObject(id as XoSr['id'], { filter, limit })
const messages = this.getMessagesForObject(id as XoSr['id'], { filter })
return this.sendObjects(Object.values(messages), req, 'messages')
return this.sendObjects(Object.values(messages), req, {
path: 'messages',
limit,
privilege: { action: 'read', resource: 'message' },
})
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -185,6 +224,7 @@ export class SrController extends XapiXoController<XoSr> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getSrTasks(
@@ -195,30 +235,44 @@ export class SrController extends XapiXoController<XoSr> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoSr['id'], { filter, limit })
return this.sendObjects(Object.values(tasks), req, 'tasks')
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoSr['id'], { filter })
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
/**
* Required privilege:
* - resource: sr, action: update:tags
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example tag "from-rest-api"
*/
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Put('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'sr', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async putSrTag(@Path() id: string, @Path() tag: string): Promise<void> {
const sr = this.getXapiObject(id as XoSr['id'])
await sr.$call('add_tags', tag)
}
/**
* Required privilege:
* - resource: sr, action: update:tags
*
* @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
* @example tag "from-rest-api"
*/
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Delete('{id}/tags/{tag}')
@Middlewares(acl({ resource: 'sr', action: 'update:tags', objectId: 'params.id' }))
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteSrTag(@Path() id: string, @Path() tag: string): Promise<void> {
const sr = this.getXapiObject(id as XoSr['id'])
await sr.$call('remove_tags', tag)
@@ -14,11 +14,14 @@ import {
Delete,
Post,
SuccessResponse,
Middlewares,
} from 'tsoa'
import { acl } from '../middlewares/acl.middleware.mjs'
import { SendObjects } from '../helpers/helper.type.mjs'
import {
asynchronousActionResp,
badRequestResp,
forbiddenOperationResp,
noContentResp,
notFoundResp,
unauthorizedResp,
@@ -34,6 +37,7 @@ import { makeObjectMapper } from '../helpers/object-wrapper.helper.mjs'
import type { CreateActionReturnType } from '../abstract-classes/base-controller.mjs'
import { safeParseComplexMatcher } from '../helpers/utils.helper.mjs'
import { RestApi } from '../rest-api/rest-api.mjs'
import { AnyPrivilege, hasPrivilegeOn } from '@xen-orchestra/acl'
@Route('tasks')
@Security('*')
@@ -59,6 +63,8 @@ export class TaskController extends XoController<XoTask> {
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* If watch is true, ndjson must also be true
*
@@ -69,6 +75,7 @@ export class TaskController extends XoController<XoTask> {
@Example(taskIds)
@Example(partialTasks)
@Get('')
@Security('*', ['acl'])
@Response(badRequestResp.status, badRequestResp.description)
async getTasks(
@Request() req: ExRequest,
@@ -78,7 +85,7 @@ export class TaskController extends XoController<XoTask> {
@Query() watch?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
): SendObjects<Partial<Unbrand<XoTask>>> {
if (watch) {
if (!ndjson) {
throw new ApiError('watch=true requires ndjson=true', 400)
@@ -103,13 +110,28 @@ export class TaskController extends XoController<XoTask> {
req.destroy()
})
function update(task: XoTask) {
if (userFilter === undefined || userFilter(task)) {
const userId = this.restApi.getCurrentUser().id
const update = async (task: XoTask) => {
const user = await this.restApi.xoApp.getUser(userId)
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
if (
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
(userFilter === undefined || userFilter(task))
) {
stream.write(['update', task])
}
}
function remove(task: XoTask) {
stream.write(['remove', { id: task.id }])
const remove = async (task: XoTask) => {
const user = await this.restApi.xoApp.getUser(userId)
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
if (
hasPrivilegeOn({ user, userPrivileges, action: 'read', resource: 'task', objects: task }) &&
(userFilter === undefined || userFilter(task))
) {
stream.write(['remove', { id: task.id }])
}
}
this.restApi.tasks.on('update', update).on('remove', remove)
@@ -117,15 +139,30 @@ export class TaskController extends XoController<XoTask> {
return stream
}
const tasks = Object.values(await this.getObjects({ filter, limit }))
return this.sendObjects(tasks, req)
const tasks = Object.values(await this.getObjects({ filter }))
return this.sendObjects(tasks, req, {
limit,
privilege: { action: 'read', resource: 'task' },
})
}
/**
* Required privilege:
* - resource: task, action: read
*
* @example id "0mdd1basu"
*/
@Example(task)
@Get('{id}')
@Middlewares(
acl({
resource: 'task',
action: 'read',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.tasks.get.bind(restApi.xoApp.tasks),
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async getTask(@Request() req: ExRequest, @Path() id: string, @Query() wait?: boolean): Promise<Unbrand<XoTask>> {
const taskId = id as XoTask['id']
@@ -144,17 +181,44 @@ export class TaskController extends XoController<XoTask> {
return this.getObject(taskId)
}
/**
* Deletes all tasks the current user has the following privilege on:
* - resource: task, action: delete
*/
@Delete('')
@Security('*', ['acl'])
@SuccessResponse(noContentResp.status, noContentResp.description)
async deleteTasks(): Promise<void> {
await this.restApi.tasks.clearLogs()
const user = this.restApi.getCurrentUser()
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
const deletePromises: Promise<void>[] = []
for await (const task of this.restApi.tasks.list()) {
if (hasPrivilegeOn({ user, userPrivileges, resource: 'task', action: 'delete', objects: task })) {
deletePromises.push(this.restApi.tasks.deleteLog(task.id))
}
}
await Promise.all(deletePromises)
}
/**
* Required privilege:
* - resource: task, action: delete
*
* @example id "0mdd1basu"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'task',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.tasks.get.bind(restApi.xoApp.tasks),
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteTask(@Path() id: string): Promise<void> {
const task = await this.getObject(id as XoTask['id'])
@@ -162,12 +226,24 @@ export class TaskController extends XoController<XoTask> {
}
/**
* Required privilege:
* - resource: task, action: abort
*
* @example id "0mdd1basu"
*/
@Example(taskLocation)
@Post('{id}/actions/abort')
@Middlewares(
acl({
resource: 'task',
action: 'abort',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.tasks.get.bind(restApi.xoApp.tasks),
})
)
@SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description)
@Response(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async abortTask(@Path() id: string, @Query() sync?: boolean): CreateActionReturnType<void> {
const taskId = id as XoTask['id']
@@ -17,11 +17,13 @@ import {
Tags,
} from 'tsoa'
import { createLogger } from '@xen-orchestra/log'
import { forbiddenOperation } from 'xo-common/api-errors.js'
import { inject } from 'inversify'
import { json, type Request as ExRequest } from 'express'
import { provide } from 'inversify-binding-decorators'
import type { XoAuthenticationToken, XoGroup, XoTask, XoUser } from '@vates/types'
import { acl, actionIfNotSelfUser, actionsFromBody } from '../middlewares/acl.middleware.mjs'
import {
badRequestResp,
createdResp,
@@ -34,7 +36,6 @@ import {
unauthorizedResp,
type Unbrand,
} from '../open-api/common/response.common.mjs'
import { forbiddenOperation } from 'xo-common/api-errors.js'
import {
partialUsers,
user,
@@ -52,6 +53,8 @@ import { XoController } from '../abstract-classes/xo-controller.mjs'
import { groupIds, partialGroups } from '../open-api/oa-examples/group.oa-example.mjs'
import { partialTasks, taskIds } from '../open-api/oa-examples/task.oa-example.mjs'
import { redirectMeAlias } from './user.middleware.mjs'
import { aclPrivilegeIds, partialAclPrivileges } from '../open-api/oa-examples/acl-privilege.oa-example.mjs'
import type { AnyPrivilege } from '@xen-orchestra/acl'
const log = createLogger('xo:rest-api:user-controller')
@@ -80,6 +83,9 @@ export class UserController extends XoController<XoUser> {
}
/**
* Returns all users that match the following privilege:
* - resource: user, action: read
*
* @example fields "permission,name,id"
* @example filter "permission:none"
* @example limit 42
@@ -87,6 +93,7 @@ export class UserController extends XoController<XoUser> {
@Example(userIds)
@Example(partialUsers)
@Get('')
@Security('*', ['acl'])
async getUsers(
@Request() req: ExRequest,
@Query() fields?: string,
@@ -94,22 +101,46 @@ export class UserController extends XoController<XoUser> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoUser>>>> {
const users = Object.values(await this.getObjects({ filter, limit }))
return this.sendObjects(users, req)
): SendObjects<Partial<Unbrand<XoUser>>> {
const users = Object.values(await this.getObjects({ filter }))
return this.sendObjects(users, req, {
limit,
privilege: { action: 'read', resource: 'user' },
})
}
/**
* Required privilege:
* - resource: user, action: read (if not self)
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
*/
@Example(user)
@Get('{id}')
@Middlewares(
acl({
resource: 'user',
action: actionIfNotSelfUser('read'),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getUser,
})
)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
getUser(@Path() id: string): Promise<Unbrand<XoUser>> {
return this.getObject(id as XoUser['id'])
}
/**
* You cannot change your own `permission` (even with the right privilege)
*
* Required privileges:
* - resource: user, action: update (grants all fields)
* - resource: user, action: update:name (if name is passed)
* - resource: user, action: update:password (if password is passed)
* - resource: user, action: update:permission (if permission is passed)
* - resource: user, action: update:preferences (if preferences is passed)
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
* @example body {
* "name": "updated user name",
@@ -119,7 +150,15 @@ export class UserController extends XoController<XoUser> {
* }
*/
@Patch('{id}')
@Middlewares(json())
@Middlewares([
json(),
acl({
resource: 'user',
actions: actionsFromBody(['update:name', 'update:password', 'update:permission', 'update:preferences']),
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getUser,
}),
])
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(notFoundResp.status, notFoundResp.description)
@Response(resourceAlreadyExists.status, resourceAlreadyExists.description)
@@ -127,14 +166,8 @@ export class UserController extends XoController<XoUser> {
async updateUser(@Path() id: string, @Body() body: UpdateUserRequestBody): Promise<void> {
const currentUser = this.restApi.getCurrentUser()
const isAdmin = currentUser.permission === 'admin'
if (isAdmin) {
if (body.permission !== undefined && currentUser.id === id) {
throw forbiddenOperation('update user', 'cannot change own permission')
}
} else if (body.name !== undefined || body.password !== undefined || body.permission !== undefined) {
throw forbiddenOperation('update user', 'cannot change these fields without admin rights')
if (body.permission !== undefined && currentUser.id === id) {
throw forbiddenOperation('update user', 'cannot change own permission')
}
const user = await this.getObject(id as XoUser['id'])
@@ -151,13 +184,17 @@ export class UserController extends XoController<XoUser> {
}
/**
* Required privilege:
* - resource: user, action: create
*
* @example body { "name": "new user", "password": "password", "permission": "none" }
*/
@Example(userId)
@Post('')
@Middlewares(json())
@Middlewares([json(), acl({ resource: 'user', action: 'create', object: ({ req }) => req.body })])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(unauthorizedResp.status, unauthorizedResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(invalidParameters.status, invalidParameters.description)
async createUser(
@Body() body: { name: string; password: string; permission?: string }
@@ -168,16 +205,31 @@ export class UserController extends XoController<XoUser> {
}
/**
* Required privilege:
* - resource: user, action: delete
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
*/
@Delete('{id}')
@Middlewares(
acl({
resource: 'user',
action: 'delete',
objectId: 'params.id',
getObject: ({ restApi }) => restApi.xoApp.getUser,
})
)
@SuccessResponse(noContentResp.status, noContentResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(notFoundResp.status, notFoundResp.description)
async deleteUser(@Path() id: string): Promise<void> {
await this.restApi.xoApp.deleteUser(id as XoUser['id'])
}
/**
* Returns all groups that match the following privilege:
* - resource: group, action: read
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
* @example fields "name,id,users"
* @example filter "users:length:>0"
@@ -186,6 +238,7 @@ export class UserController extends XoController<XoUser> {
@Example(groupIds)
@Example(partialGroups)
@Get('{id}/groups')
@Security('*', ['acl'])
@Tags('groups')
@Response(notFoundResp.status, notFoundResp.description)
async getUserGroups(
@@ -196,20 +249,27 @@ export class UserController extends XoController<XoUser> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoGroup>>>> {
): SendObjects<Partial<Unbrand<XoGroup>>> {
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, limit }), req, 'groups')
return this.sendObjects(limitAndFilterArray(groups, { filter }), req, {
path: 'groups',
limit,
privilege: { action: 'read', resource: 'group' },
})
}
/**
* You can only see your own authentication tokens
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
* @example filter "expiration:>1757371582496"
* @example limit 42
*/
@Example(authenticationTokens)
@Get('{id}/authentication_tokens')
@Security('*', ['acl'])
@Response(notFoundResp.status, notFoundResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
async getAuthenticationTokens(
@@ -231,6 +291,9 @@ export class UserController extends XoController<XoUser> {
}
/**
* Returns all tasks that match the following privilege:
* - resource: task, action: read
*
* @example id "722d17b9-699b-49d2-8193-be1ac573d3de"
* @example fields "id,status,properties"
* @example filter "status:failure"
@@ -239,6 +302,7 @@ export class UserController extends XoController<XoUser> {
@Example(taskIds)
@Example(partialTasks)
@Get('{id}/tasks')
@Security('*', ['acl'])
@Tags('tasks')
@Response(notFoundResp.status, notFoundResp.description)
async getUserTasks(
@@ -249,10 +313,14 @@ export class UserController extends XoController<XoUser> {
@Query() markdown?: boolean,
@Query() filter?: string,
@Query() limit?: number
): Promise<SendObjects<Partial<Unbrand<XoTask>>>> {
const tasks = await this.getTasksForObject(id as XoUser['id'], { filter, limit })
): SendObjects<Partial<Unbrand<XoTask>>> {
const tasks = await this.getTasksForObject(id as XoUser['id'], { filter })
return this.sendObjects(Object.values(tasks), req, 'tasks')
return this.sendObjects(Object.values(tasks), req, {
path: 'tasks',
limit,
privilege: { action: 'read', resource: 'task' },
})
}
// ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (10-13-2026)--------------------
@@ -290,12 +358,15 @@ export class UserController extends XoController<XoUser> {
// ----------- DEPRECATED TO BE REMOVED IN ONE YEAR (10-13-2026)--------------------
/**
* You can only create authentication token for yourself
*
* @example id "me"
* @example body {"client": {"id": "my-fav-client"}, "description": "token for CLI usage", "expiresIn": "1 hour"}
*/
@Example(authenticationToken)
@Post('{id}/authentication_tokens')
@Middlewares(json())
@Security('*', ['acl'])
@SuccessResponse(createdResp.status, createdResp.description)
@Response(forbiddenOperationResp.status, forbiddenOperationResp.description)
@Response(internalServerErrorResp.status, internalServerErrorResp.description)
@@ -322,4 +393,39 @@ export class UserController extends XoController<XoUser> {
return { token }
}
/**
* Returns all ACL privileges that match the following privilege:
* - resource: acl-privilege, action: read (if not self)
*
* @example id "me"
* @example fields "id,action,resource"
* @example filter "action:create"
* @example limit 42
*/
@Example(aclPrivilegeIds)
@Example(partialAclPrivileges)
@Get('{id}/acl-privileges')
@Security('*', ['acl'])
@Tags('acls')
@Response(notFoundResp.status, notFoundResp.description)
async getUserPrivileges(
@Request() req: ExRequest,
@Path() id: string,
@Query() fields?: string,
@Query() ndjson?: boolean,
@Query() filter?: string,
@Query() limit?: number
): SendObjects<Partial<Unbrand<AnyPrivilege>>> {
const user = await this.getObject(id as XoUser['id'])
const currentUser = this.restApi.getCurrentUser()
const userPrivileges = (await this.restApi.xoApp.getAclV2UserPrivileges(user.id)) as AnyPrivilege[]
return this.sendObjects(limitAndFilterArray(userPrivileges, { filter }), req, {
path: 'acl-privileges',
limit,
privilege: currentUser.id === user.id ? undefined : { action: 'read', resource: 'acl-privilege' },
})
}
}

Some files were not shown because too many files have changed in this diff Show More