mirror of
https://github.com/vatesfr/xen-orchestra.git
synced 2026-10-12 02:24:05 +00:00
feat(rest-api): ACL V2 integration (#9774)
This commit is contained in:
@@ -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']
|
||||
}
|
||||
@@ -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:
|
||||
| {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../scripts/npmignore
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
name: true,
|
||||
users: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
'allow-vm': true,
|
||||
export: {
|
||||
logs: true,
|
||||
},
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,13 @@
|
||||
export default {
|
||||
create: {
|
||||
network: true,
|
||||
vm: true,
|
||||
},
|
||||
'emergency-shutdown': true,
|
||||
read: true,
|
||||
'rolling-reboot': true,
|
||||
'rolling-update': true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
import: {
|
||||
vdi: true,
|
||||
vm: true,
|
||||
},
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
abort: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
create: true,
|
||||
delete: true,
|
||||
read: true,
|
||||
update: {
|
||||
name: true,
|
||||
password: true,
|
||||
permission: true,
|
||||
preferences: 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,11 @@
|
||||
export default {
|
||||
boot: true,
|
||||
create: true,
|
||||
delete: true,
|
||||
'export-content': true,
|
||||
'import-content': true,
|
||||
read: true,
|
||||
update: {
|
||||
tags: true,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default {
|
||||
read: true,
|
||||
}
|
||||
@@ -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]
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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).
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user