@xen-orchestra/rest-api 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,29 @@ npm install --save @xen-orchestra/rest-api
22
22
 
23
23
  The REST API is based on the `TSOA` framework and therefore we use decorators a lot to define the behavior of a route or a group of routes. To keep things easily visible, it is best to always use the decorators in the same order.
24
24
 
25
+ At the request of the DevOps team, any REST API PR that updates the OpenAPI specification must include a reviewer from the DevOps team. (Non-blocking for merge.)
26
+
27
+ ### Resource consistency
28
+
29
+ For a given resource (VMs, VIFs, users, etc.), property names must be consistent across all endpoints.
30
+
31
+ > Example: a PATCH request on a VM must use the same property names as the VM representation returned by the REST API.
32
+
33
+ ```
34
+ GET /vms/:id
35
+ {
36
+ name_label: 'Foo',
37
+ name_description: 'Foo Bar',
38
+ ...
39
+ }
40
+
41
+ PATCH /vms/:id
42
+ {
43
+ name_label: 'Bar' // OK ✅
44
+ nameDescription: 'Bar Foo' // Not OK ❌
45
+ }
46
+ ```
47
+
25
48
  ### Class decorator
26
49
 
27
50
  ```ts
@@ -46,6 +69,7 @@ class Foo extends Controller {
46
69
  * @example id 1234
47
70
  */
48
71
  @Example(['foo', 'bar'])
72
+ @Extension('x-mcp-exposure', 'allow')
49
73
  @Get('{id}')
50
74
  @Security('*')
51
75
  @Middlewares(json())
@@ -70,7 +94,7 @@ If an endpoint does not have a middleware ACL, it will be accessible **ONLY** to
70
94
 
71
95
  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`).
72
96
 
73
- `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`.
97
+ `actionsFromBody(['update:nameLabel', 'update:nameDescription'])` checks if `nameLabel` is present in the request body, and then applies the ACL check. The same applies to `nameDescription`.
74
98
 
75
99
  `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.
76
100
 
@@ -128,11 +152,11 @@ It is sometimes necessary to check ACLs based on the body of the request sent by
128
152
  *
129
153
  * Required privileges:
130
154
  * - resource: vm, action: update (grants all fields)
131
- * - resource: vm, action: update:name_label (if name_label is passed)
132
- * - resource: vm, action: update:name_description (if name_description is passed)
155
+ * - resource: vm, action: update:nameLabel (if nameLabel is passed)
156
+ * - resource: vm, action: update:nameDescription (if nameDescription is passed)
133
157
  */
134
158
  @Patch('{id}')
135
- @Middlewares(acl({resource: 'vm', actions: actionsFromBody(['update:name_label', 'update:name_description']), objectId: 'params.id'}))
159
+ @Middlewares(acl({resource: 'vm', actions: actionsFromBody(['update:nameLabel', 'update:nameDescription']), objectId: 'params.id'}))
136
160
  @Response(403)
137
161
  createVdi(@Path() id: string, @Body() body: patchBody) {
138
162
  updateVm(id, body)
@@ -162,6 +186,14 @@ getUser(@Path() id: string) { ... }
162
186
 
163
187
  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`.
164
188
 
189
+ ### MCP exposure
190
+
191
+ All REST API endpoints must define an MCP exposure policy using the `@Extension` decorator.
192
+
193
+ - `@Extension('x-mcp-exposure', 'allow')` for all `GET` endpoints.
194
+ - `@Extension('x-mcp-exposure', 'confirm')` for all non-`GET` endpoints (`POST`, `PATCH`, `PUT`, `DELETE`, etc.).
195
+ - `@Extension('x-mcp-exposure', 'deny')` only for exceptional cases.
196
+
165
197
  ## Contributions
166
198
 
167
199
  Contributions are _very_ welcomed, either on the documentation or on
@@ -45,6 +45,10 @@ export class BackupJobService {
45
45
  if (schedule.enabled) {
46
46
  return true;
47
47
  }
48
+ const scheduleSequence = await this.#restApi.xoApp.findEnabledScheduleSequenceFromSchedule(schedule.id);
49
+ if (scheduleSequence !== undefined) {
50
+ return true;
51
+ }
48
52
  }
49
53
  catch (error) {
50
54
  if (!noSuchObject.is(error, { id: maybeScheduleId, type: 'schedule' })) {
@@ -7,17 +7,23 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
- import { Example, Extension, Get, Middlewares, Path, Query, Request, Response, Route, Security, Tags } from 'tsoa';
10
+ import { Body, Example, Extension, Get, Middlewares, Patch, Path, Post, Query, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
11
11
  import { inject } from 'inversify';
12
12
  import { provide } from 'inversify-binding-decorators';
13
- import { acl } from '../middlewares/acl.middleware.mjs';
14
- import { badRequestResp, forbiddenOperationResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
15
- import { backupRepositoryIds, partialBackupRepositories, backupRepository, } from '../open-api/oa-examples/backup-repository.oa-example.mjs';
13
+ import { json } from 'express';
14
+ import { forbiddenOperation } from 'xo-common/api-errors.js';
15
+ import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs';
16
+ import { badRequestResp, createdResp, forbiddenOperationResp, invalidParameters, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
17
+ import { backupRepositoryIds, partialBackupRepositories, backupRepository, backupRepositoryId, } from '../open-api/oa-examples/backup-repository.oa-example.mjs';
16
18
  import { XoController } from '../abstract-classes/xo-controller.mjs';
17
19
  import { RestApi } from '../rest-api/rest-api.mjs';
20
+ import { BackupRepositoryService } from './backup-repository.service.mjs';
21
+ import { taskLocation } from '../open-api/oa-examples/task.oa-example.mjs';
18
22
  let BackupRepositoryController = class BackupRepositoryController extends XoController {
19
- constructor(restApi) {
23
+ #backupRepositoryService;
24
+ constructor(restApi, backupRepositoryService) {
20
25
  super('backup-repository', restApi);
26
+ this.#backupRepositoryService = backupRepositoryService;
21
27
  }
22
28
  // --- abstract methods
23
29
  getAllCollectionObjects() {
@@ -40,6 +46,16 @@ let BackupRepositoryController = class BackupRepositoryController extends XoCont
40
46
  privilege: { action: 'read', resource: 'backup-repository' },
41
47
  });
42
48
  }
49
+ /**
50
+ * Required privilege:
51
+ * - resource: backup-repository, action: create
52
+ *
53
+ * @example body { "name": "NFS Remote", "options": "vers=4", "proxy": "722d17b9-699b-59d2-8193-be1ac573d3de", "url": "nfs://192.168.100.225:/media/nfs" }
54
+ */
55
+ async createBackupRepository(body) {
56
+ const backupRepository = await this.restApi.xoApp.createRemote(body);
57
+ return { id: backupRepository.id };
58
+ }
43
59
  /**
44
60
  * Required privilege:
45
61
  * - resource: backup-repository, action: read
@@ -49,6 +65,48 @@ let BackupRepositoryController = class BackupRepositoryController extends XoCont
49
65
  getRepository(id) {
50
66
  return this.getObject(id);
51
67
  }
68
+ /**
69
+ * Forgets a backup repository configuration.
70
+ *
71
+ * A backup repository cannot be forgotten if it is referenced by any backup job (enabled or disabled).
72
+ *
73
+ * Required privilege:
74
+ * - resource: backup-repository, action: forget
75
+ *
76
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
77
+ */
78
+ async forgetBackupRepository(id, sync) {
79
+ const repositoryId = id;
80
+ const action = async () => {
81
+ const referencingJobs = await this.#backupRepositoryService.getReferencingJobs(repositoryId);
82
+ if (referencingJobs.length > 0) {
83
+ throw forbiddenOperation('forget backup repository', `repository is referenced by ${referencingJobs.length} backup job(s): ${referencingJobs.join(', ')}`);
84
+ }
85
+ await this.restApi.xoApp.removeRemote(repositoryId);
86
+ };
87
+ return this.createAction(action, {
88
+ sync,
89
+ statusCode: noContentResp.status,
90
+ taskProperties: {
91
+ name: 'forget backup repository',
92
+ objectId: repositoryId,
93
+ },
94
+ });
95
+ }
96
+ /** Required privileges:
97
+ * - resource: backup-repository, action: update (grants all fields)
98
+ * - resource: backup-repository, action: update:enabled (if enabled is passed)
99
+ * - resource: backup-repository, action: update:name (if name is passed)
100
+ * - resource: backup-repository, action: update:options (if options is passed)
101
+ * - resource: backup-repository, action: update:proxy (if proxy is passed)
102
+ * - resource: backup-repository, action: update:url (if url is passed)
103
+ *
104
+ * @example id "c4284e12-37c9-7967-b9e8-83ef229c3e03"
105
+ * @example body { "enabled": true, "name": "NFS Remote", "options": "vers=4", "proxy": "722d17b9-699b-59d2-8193-be1ac573d3de", "url": "nfs://192.168.100.225:/media/nfs" }
106
+ */
107
+ async updateBackupRepository(id, body) {
108
+ await this.restApi.xoApp.updateRemote(id, body);
109
+ }
52
110
  };
53
111
  __decorate([
54
112
  Example(backupRepositoryIds),
@@ -63,6 +121,22 @@ __decorate([
63
121
  __param(4, Query()),
64
122
  __param(5, Query())
65
123
  ], BackupRepositoryController.prototype, "getRepositories", null);
124
+ __decorate([
125
+ Example(backupRepositoryId),
126
+ Post(''),
127
+ Middlewares([
128
+ json(),
129
+ acl({
130
+ resource: 'backup-repository',
131
+ action: 'create',
132
+ object: ({ req }) => req.body,
133
+ }),
134
+ ]),
135
+ SuccessResponse(createdResp.status, createdResp.description),
136
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
137
+ Response(invalidParameters.status, invalidParameters.description),
138
+ __param(0, Body())
139
+ ], BackupRepositoryController.prototype, "createBackupRepository", null);
66
140
  __decorate([
67
141
  Example(backupRepository),
68
142
  Extension('x-mcp-exposure', 'allow'),
@@ -77,6 +151,40 @@ __decorate([
77
151
  Response(notFoundResp.status, notFoundResp.description),
78
152
  __param(0, Path())
79
153
  ], BackupRepositoryController.prototype, "getRepository", null);
154
+ __decorate([
155
+ Example(taskLocation),
156
+ Extension('x-mcp-exposure', 'confirm'),
157
+ Post('{id}/actions/forget'),
158
+ Middlewares(acl({
159
+ resource: 'backup-repository',
160
+ action: 'forget',
161
+ objectId: 'params.id',
162
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
163
+ })),
164
+ SuccessResponse(noContentResp.status, noContentResp.description),
165
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
166
+ Response(notFoundResp.status, notFoundResp.description),
167
+ __param(0, Path()),
168
+ __param(1, Query())
169
+ ], BackupRepositoryController.prototype, "forgetBackupRepository", null);
170
+ __decorate([
171
+ Patch('{id}'),
172
+ Middlewares([
173
+ json(),
174
+ acl({
175
+ resource: 'backup-repository',
176
+ actions: actionsFromBody(['update:enabled', 'update:name', 'update:options', 'update:proxy', 'update:url']),
177
+ objectId: 'params.id',
178
+ getObject: ({ restApi }) => restApi.xoApp.getRemote,
179
+ }),
180
+ ]),
181
+ SuccessResponse(noContentResp.status, noContentResp.description),
182
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
183
+ Response(notFoundResp.status, notFoundResp.description),
184
+ Response(invalidParameters.status, invalidParameters.description),
185
+ __param(0, Path()),
186
+ __param(1, Body())
187
+ ], BackupRepositoryController.prototype, "updateBackupRepository", null);
80
188
  BackupRepositoryController = __decorate([
81
189
  Route('backup-repositories'),
82
190
  Security('*'),
@@ -84,6 +192,7 @@ BackupRepositoryController = __decorate([
84
192
  Response(unauthorizedResp.status, unauthorizedResp.description),
85
193
  Tags('backup-repositories'),
86
194
  provide(BackupRepositoryController),
87
- __param(0, inject(RestApi))
195
+ __param(0, inject(RestApi)),
196
+ __param(1, inject(BackupRepositoryService))
88
197
  ], BackupRepositoryController);
89
198
  export { BackupRepositoryController };
@@ -0,0 +1,46 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
8
+ return function (target, key) { decorator(target, key, paramIndex); }
9
+ };
10
+ import { RestApi } from '../rest-api/rest-api.mjs';
11
+ import { inject } from 'inversify';
12
+ let BackupRepositoryService = class BackupRepositoryService {
13
+ #restApi;
14
+ constructor(restApi) {
15
+ this.#restApi = restApi;
16
+ }
17
+ isBackupRepositoryReferenced(idsToCheck, repositoryId) {
18
+ if (idsToCheck === undefined) {
19
+ return false;
20
+ }
21
+ const { id } = idsToCheck;
22
+ const ids = typeof id === 'string' ? [id] : id.__or;
23
+ return ids.includes(repositoryId);
24
+ }
25
+ async getReferencingJobs(repositoryId) {
26
+ const allJobs = await this.#restApi.xoApp.getAllJobs();
27
+ const referencingJobs = [];
28
+ for (const job of allJobs) {
29
+ if (job.type === 'backup' || job.type === 'metadataBackup') {
30
+ if (this.isBackupRepositoryReferenced(job.remotes, repositoryId)) {
31
+ referencingJobs.push(job.id);
32
+ }
33
+ }
34
+ else if (job.type === 'mirrorBackup') {
35
+ if (job.sourceRemote === repositoryId || this.isBackupRepositoryReferenced(job.remotes, repositoryId)) {
36
+ referencingJobs.push(job.id);
37
+ }
38
+ }
39
+ }
40
+ return referencingJobs;
41
+ }
42
+ };
43
+ BackupRepositoryService = __decorate([
44
+ __param(0, inject(RestApi))
45
+ ], BackupRepositoryService);
46
+ export { BackupRepositoryService };
package/dist/ioc/ioc.mjs CHANGED
@@ -16,6 +16,7 @@ import { NetworkService } from '../networks/network.service.mjs';
16
16
  import { BackupArchiveService } from '../backup-archives/backup-archive.service.mjs';
17
17
  import { SrService } from '../srs/sr.service.mjs';
18
18
  import { LicenseService } from '../licenses/license.service.mjs';
19
+ import { BackupRepositoryService } from '../backup-repositories/backup-repository.service.mjs';
19
20
  const iocContainer = new Container();
20
21
  export function setupContainer(xoApp) {
21
22
  decorate(injectable(), Controller);
@@ -125,5 +126,12 @@ export function setupContainer(xoApp) {
125
126
  return new LicenseService(restApi);
126
127
  })
127
128
  .inSingletonScope();
129
+ iocContainer
130
+ .bind(BackupRepositoryService)
131
+ .toDynamicValue(ctx => {
132
+ const restApi = ctx.container.get(RestApi);
133
+ return new BackupRepositoryService(restApi);
134
+ })
135
+ .inSingletonScope();
128
136
  }
129
137
  export { iocContainer };
@@ -1,9 +1,11 @@
1
1
  import { getMissingPrivileges, } from '@xen-orchestra/acl';
2
+ import { createLogger } from '@xen-orchestra/log';
2
3
  import { RestApi } from '../rest-api/rest-api.mjs';
3
4
  import { iocContainer } from '../ioc/ioc.mjs';
4
5
  import { ValidateError } from 'tsoa';
5
6
  import { ApiError } from '../helpers/error.helper.mjs';
6
7
  export const ACL_MIDDLEWARE_NAME = '_aclMiddleware';
8
+ const log = createLogger('xo:rest-api:middleware');
7
9
  export function actionsFromBody(actions) {
8
10
  return ({ req }) => actions.filter(action => {
9
11
  const [, field] = action.split(':');
@@ -107,7 +109,14 @@ export function acl(acls) {
107
109
  const _acls = acls.map(normalizeAclEntry);
108
110
  async function middleware(req, res, next) {
109
111
  const restApi = iocContainer.get(RestApi);
110
- const user = restApi.getCurrentUser();
112
+ let user;
113
+ try {
114
+ user = restApi.getCurrentUser();
115
+ }
116
+ catch (error) {
117
+ log.warn('An unauthenticated user made its way to acl middleware', error);
118
+ return next(error);
119
+ }
111
120
  const invalidFields = {};
112
121
  const missingPrivilegeParams = [];
113
122
  for (const acl of _acls) {
@@ -0,0 +1,10 @@
1
+ import { createLogger } from '@xen-orchestra/log';
2
+ const log = createLogger('xo:rest-api:deprecated.middleware');
3
+ export function vmExportCompressDeprecated(req, _res, next) {
4
+ const compress = req.query.compress;
5
+ if (compress === 'true' || compress === 'false') {
6
+ log.warn("the query param 'compress' as boolean is deprecated. Please use an explicit value next time");
7
+ req.query.compress = compress === 'true' ? 'gzip' : undefined;
8
+ }
9
+ next();
10
+ }
@@ -29,6 +29,7 @@ export default function genericErrorHandler(error, req, res, _next) {
29
29
  }
30
30
  else if (invalidCredentials.is(error)) {
31
31
  statusCode = 401;
32
+ res.setHeader('WWW-Authenticate', 'Basic realm="xo"');
32
33
  }
33
34
  else if (objectAlreadyExists.is(error)) {
34
35
  statusCode = 409;
@@ -29,3 +29,4 @@ export const backupRepository = {
29
29
  id: '677e50c5-8d8a-4c89-b1ac-e2f4593d0ebb',
30
30
  url: 's3://FOIS5DY532RGXD62TJ52:obfuscated-q3oi6d9X8uenGvdLnHk2@s3.us-east-2.amazonaws.com/with-lock/backup?useVhdDirectory=true',
31
31
  };
32
+ export const backupRepositoryId = { id: '677e50c5-8d8a-4c89-b1ac-e2f4593d0ebb' };