diff --git a/@vates/types/src/xo-app.mts b/@vates/types/src/xo-app.mts index 4db4ac19ca..ac80c99945 100644 --- a/@vates/types/src/xo-app.mts +++ b/@vates/types/src/xo-app.mts @@ -56,6 +56,27 @@ export type XapiConnection = Xapi & { sessionId: string _url?: { protocol: string; hostname: string; port?: string } } + +type FeatureCode = + | 'BACKUP.DELTA' + | 'BACKUP.DELTA_REPLICATION' + | 'BACKUP.FULL' + | 'BACKUP.HEALTHCHECK' + | 'BACKUP.METADATA' + | 'BACKUP.MIRROR' + | 'BACKUP.WITH_RAM' + | 'BACKUP.SMART_BACKUP' + | 'BACKUP.S3' + | 'DOCKER' + | 'EXPORT.XVA' + | 'LIST_MISSING_PATCHES' + | 'POOL_EMERGENCY_SHUTDOWN' + | 'RBAC' + | 'ROLLING_POOL_UPDATE' + | 'ROLLING_POOL_REBOOT' + | 'WARM_MIGRATION' + | 'PLUGIN.OPENMETRICS' + type XapiRecordByXapiXoRecord = { gpuGroup: XenApiGpuGroupWrapped host: XenApiHostWrapped @@ -184,7 +205,7 @@ export type XoApp = { opts?: { bypassOtp?: boolean; bypassTaskCreation?: boolean } ) => Promise<{ bypassOtp: boolean; expiration: number; user: XoUser }> /* Throw if no authorization */ - checkFeatureAuthorization(featureCode: string): Promise + checkFeatureAuthorization(featureCode: FeatureCode): Promise /* connect a server (XCP-ng/XenServer) */ connectXenServer(id: XoServer['id']): Promise // TODO: replace all XoAclBasePrivilege with a more strict type. (discriminate union) diff --git a/CHANGELOG.unreleased.md b/CHANGELOG.unreleased.md index a6c9fec82e..79072cff08 100644 --- a/CHANGELOG.unreleased.md +++ b/CHANGELOG.unreleased.md @@ -63,6 +63,7 @@ - @xen-orchestra/qcow2 patch - @xen-orchestra/rest-api minor - @xen-orchestra/xapi minor +- xo-common minor - xo-server minor - xo-server-sdn-controller patch - xo-web patch diff --git a/packages/xo-common/api-errors.js b/packages/xo-common/api-errors.js index 454886c0bd..6ec0aa0984 100644 --- a/packages/xo-common/api-errors.js +++ b/packages/xo-common/api-errors.js @@ -206,11 +206,13 @@ exports.incorrectState = create(25, ({ actual, expected, object, property }) => message: 'incorrect state', })) -exports.featureUnauthorized = create(26, ({ featureCode, currentPlan, minPlan }) => ({ +exports.featureUnauthorized = create(26, ({ featureCode, currentPlan, minPlan, currentBundle, allowedBundles }) => ({ data: { featureCode, currentPlan, minPlan, + currentBundle, + allowedBundles }, message: 'feature Unauthorized', })) diff --git a/packages/xo-server/src/xo-mixins/acls-v2/index.mjs b/packages/xo-server/src/xo-mixins/acls-v2/index.mjs index 9983ec96cd..15bfd44344 100644 --- a/packages/xo-server/src/xo-mixins/acls-v2/index.mjs +++ b/packages/xo-server/src/xo-mixins/acls-v2/index.mjs @@ -237,7 +237,7 @@ export default class { * @returns {Promise} */ async createAclV2Role(role) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') return this.#roleDb.add(role) } @@ -249,7 +249,7 @@ export default class { * @returns {Promise} */ async deleteAclV2Role(id, { force = false } = {}) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.getAclV2Role(id) @@ -282,7 +282,7 @@ export default class { * @returns {Promise} */ async updateAclV2Role(id, { name, description }, { force = false } = {}) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.getAclV2Role(id) if (!force && 'isTemplate' in role) { @@ -308,7 +308,7 @@ export default class { * @returns {Promise} */ async getAclV2Role(id) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.#roleDb.first(id) if (role === undefined) { @@ -322,7 +322,7 @@ export default class { * @returns {Promise} */ async getAclV2Roles() { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') // @ts-ignore typed as Promise... return this.#roleDb.get() @@ -342,7 +342,7 @@ export default class { * @returns {Promise} */ async createAclV2Privilege({ action, selector, effect = 'allow', resource, roleId }, { force = false } = {}) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.getAclV2Role(roleId) if (!force && 'isTemplate' in role) { @@ -360,7 +360,7 @@ export default class { * @returns {Promise} */ async deleteAclV2Privilege(id, { force = false } = {}) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const privilege = await this.getAclV2Privilege(id) const role = await this.getAclV2Role(privilege.roleId) @@ -383,7 +383,7 @@ export default class { * @returns {Promise} */ async updateAclV2Privilege(id, { action, selector, effect, resource }) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const privilege = await this.getAclV2Privilege(id) const role = await this.getAclV2Role(privilege.roleId) @@ -421,7 +421,7 @@ export default class { * @returns {Promise} */ async getAclV2Privilege(id) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const privilege = await this.#privilegeDb.first(id) if (privilege === undefined) { @@ -435,7 +435,7 @@ export default class { * @returns {Promise} */ async getAclV2Privileges() { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') // @ts-ignore typed as Promise... return this.#privilegeDb.get() @@ -452,7 +452,7 @@ export default class { * @returns {Promise} */ async addAclV2UserRole(userId, roleId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** * @type {UserRole[]} @@ -484,7 +484,7 @@ export default class { * @returns {Promise} */ async deleteAclV2UserRole(userId, roleId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** * @type {UserRole[]} @@ -512,7 +512,7 @@ export default class { * @returns {Promise} */ async addAclV2GroupRole(groupId, roleId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** * @type {GroupRole[]} @@ -544,7 +544,7 @@ export default class { * @returns {Promise} */ async deleteAclV2GroupRole(groupId, roleId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** * @type {GroupRole[]} */ @@ -566,7 +566,7 @@ export default class { * @returns {Promise} */ async getAclV2RolePrivileges(roleId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.getAclV2Role(roleId) return this.#privilegeDb._get({ roleId: role.id }) @@ -577,7 +577,7 @@ export default class { * @returns {Promise} */ async getAclV2UserRoles(userId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** @type {XoUser} */ const user = await this._app.getUser(userId) @@ -596,7 +596,7 @@ export default class { * @returns {Promise} */ async getAclV2UserPrivileges(userId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const roles = await this.getAclV2UserRoles(userId) return (await Promise.all(roles.map(role => this.getAclV2RolePrivileges(role.id)))).flat() @@ -607,7 +607,7 @@ export default class { * @returns {Promise} */ async getAclV2GroupRoles(groupId) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') /** @type {GroupRole[]} */ const dbGroupRoles = await this.#groupRoleDb._get({ groupId }) @@ -623,7 +623,7 @@ export default class { * @returns {Promise} */ async copyAclV2Role(roleId, params = {}) { - await this._app.checkFeatureAuthorization('ACL') + await this._app.checkFeatureAuthorization('RBAC') const role = await this.getAclV2Role(roleId) const privileges = await this.getAclV2RolePrivileges(roleId) diff --git a/packages/xo-server/src/xo-mixins/authorization.mjs b/packages/xo-server/src/xo-mixins/authorization.mjs index c444694976..fd9abd7642 100644 --- a/packages/xo-server/src/xo-mixins/authorization.mjs +++ b/packages/xo-server/src/xo-mixins/authorization.mjs @@ -11,6 +11,13 @@ const STARTER = 2 const ENTERPRISE = 3 const PREMIUM = 4 +// https://git.vates.tech/vates/www-xo/src/branch/main/src/productNames.js#L230 +const BUNDLE_ESSENTIAL = 'bundle-essential' +const BUNDLE_ESSENTIAL_PLUS = 'bundle-essential-plus' +const BUNDLE_ENTERPRISE = 'bundle-enterprise' +const BUNDLE_PRO = 'bundle-pro' +const BUNDLE_X1 = 'bundle-x1' + export const PLANS = { free: FREE, starter: STARTER, @@ -18,8 +25,23 @@ export const PLANS = { premium: PREMIUM, } +export const BUNDLES = { + essential: BUNDLE_ESSENTIAL, + essentialPlus: BUNDLE_ESSENTIAL_PLUS, + enterprise: BUNDLE_ENTERPRISE, + pro: BUNDLE_PRO, + x1: BUNDLE_X1, +} + +const BUNDLE_TO_PLAN = { + [BUNDLE_ESSENTIAL]: STARTER, + [BUNDLE_ESSENTIAL_PLUS]: PREMIUM, + [BUNDLE_PRO]: ENTERPRISE, + [BUNDLE_ENTERPRISE]: PREMIUM, + [BUNDLE_X1]: STARTER, +} + const AUTHORIZATIONS = { - ACL: ENTERPRISE, BACKUP: { DELTA: STARTER, DELTA_REPLICATION: ENTERPRISE, @@ -45,6 +67,11 @@ const AUTHORIZATIONS = { }, } +// features: https://vates.tech/en/pricing-and-support/ +const BUNDLE_AUTHORIZATIONS = { + RBAC: [BUNDLE_ESSENTIAL_PLUS, BUNDLE_PRO, BUNDLE_ENTERPRISE], +} + export default class Authorization { #app constructor(app) { @@ -52,8 +79,22 @@ export default class Authorization { } #getMinPlan(featureCode) { - const minPlan = get(AUTHORIZATIONS, featureCode) - assert.notEqual(minPlan, undefined, `${featureCode} is not defined in the AUTHORIZATIONS object`) + let minPlan = get(AUTHORIZATIONS, featureCode) + // If `featureCode` does not exist in the legacy `AUTHORIZATIONS` object, try to find the "equivalent" in the `BUNDLE_AUTHORIZATIONS` object + if (minPlan === undefined) { + const bundles = get(BUNDLE_AUTHORIZATIONS, featureCode) + assert.notEqual( + bundles, + undefined, + `${featureCode} is not defined in either AUTHORIZATIONS or BUNDLE_AUTHORIZATIONS object` + ) + minPlan = bundles + .map(bundle => BUNDLE_TO_PLAN[bundle]) + .sort() + .shift() + } + + assert.notEqual(minPlan, undefined, `${featureCode} is not defined in the BUNDLE_AUTHORIZATIONS object`) return minPlan } @@ -72,20 +113,58 @@ export default class Authorization { return PLANS[plan] } + async #getCurrentBundleId() { + const now = Date.now() + const xoaLicences = await retry(() => this.#app.getSelfLicenses(), { + when: error => error.message?.includes('invalid status connecting'), + onRetry(error) { + log.warn('XOA connection not ready, retrying', { + attempt: this.attemptNumber, + delay: this.delay, + error, + }) + }, + }) + const activeBundleLicenses = xoaLicences?.filter( + ({ expires, bundleInfo }) => (expires === undefined || expires > now) && bundleInfo !== undefined + ) + if (activeBundleLicenses === undefined || activeBundleLicenses.length === 0) { + return undefined + } + + return activeBundleLicenses.sort( + (a, b) => (BUNDLE_TO_PLAN[b.bundleInfo.id] ?? 0) - (BUNDLE_TO_PLAN[a.bundleInfo.id] ?? 0) + )[0].bundleInfo.id + } + async checkFeatureAuthorization(featureCode) { if (this.#app.getXoaPlan === undefined) { // source user => everything is open return } - const minPlan = this.#getMinPlan(featureCode) - const currentPlan = await this.#getCurrentPlan() - if (currentPlan < minPlan) { - throw featureUnauthorized({ - featureCode, - currentPlan, - minPlan, - }) + const bundleId = await this.#getCurrentBundleId() + if (bundleId !== undefined) { + const allowedBundles = get(BUNDLE_AUTHORIZATIONS, featureCode) + if (allowedBundles === undefined) { + const minPlan = this.#getMinPlan(featureCode) + if (BUNDLE_TO_PLAN[bundleId] >= minPlan) { + return + } + } else { + if (allowedBundles.includes(bundleId)) { + return + } + } + + throw featureUnauthorized({ featureCode, currentBundle: bundleId, allowedBundles }) + } else { + // fallback to legacy feature check + const minPlan = this.#getMinPlan(featureCode) + const currentPlan = await this.#getCurrentPlan() + if (currentPlan < minPlan) { + throw featureUnauthorized({ featureCode, currentPlan, minPlan }) + } } } diff --git a/packages/xo-server/src/xo-mixins/authorization.test.mjs b/packages/xo-server/src/xo-mixins/authorization.test.mjs new file mode 100644 index 0000000000..6e660d6854 --- /dev/null +++ b/packages/xo-server/src/xo-mixins/authorization.test.mjs @@ -0,0 +1,140 @@ +import assert from 'assert/strict' +import test from 'node:test' +import Authorization, { BUNDLES, PLANS } from './authorization.mjs' + +const { describe, it } = test + +const makeApp = ({ plan = PLANS.premium, licenses } = {}) => ({ + getXoaPlan: async () => plan, + getSelfLicenses: async () => licenses, +}) + +const makeLicense = (bundleId, { expires } = {}) => ({ + bundleInfo: { id: bundleId }, + expires, +}) + +describe('Authorization', function () { + describe('checkFeatureAuthorization', function () { + describe('source user (no getXoaPlan)', function () { + it('should allow any feature', async function () { + const auth = new Authorization({}) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('RBAC')) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('WARM_MIGRATION')) + }) + }) + + describe('unknown feature code', function () { + it('should throw an assertion error', async function () { + const auth = new Authorization(makeApp({ plan: 'enterprise' })) + await assert.rejects(() => auth.checkFeatureAuthorization('UNKNOWN.FEATURE'), { name: 'AssertionError' }) + }) + }) + + describe('bundle-based authorization', function () { + describe('feature defined in BUNDLE_AUTHORIZATIONS (RBAC)', function () { + for (const allowedBundle of [BUNDLES.essentialPlus, BUNDLES.pro, BUNDLES.enterprise]) { + it(`should allow RBAC with: ${allowedBundle}`, async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense(allowedBundle)] })) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('RBAC')) + }) + } + + for (const deniedBundle of [BUNDLES.essential, BUNDLES.x1]) { + it(`should deny RBAC with ${deniedBundle}`, async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense(deniedBundle)] })) + await assert.rejects(() => auth.checkFeatureAuthorization('RBAC')) + }) + } + }) + + // test plan -> bundle migration + describe('feature not in BUNDLE_AUTHORIZATIONS (plan comparison fallback)', function () { + it('should allow when bundle plan meets the minimum', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense('bundle-essential')] })) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('BACKUP.DELTA')) + }) + + it('should deny when bundle plan is below minimum', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense('bundle-essential')] })) + await assert.rejects(() => auth.checkFeatureAuthorization('WARM_MIGRATION')) + }) + + it('should allow when bundle plan exactly meets minimum', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense('bundle-essential-plus')] })) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('WARM_MIGRATION')) + }) + }) + + describe('multiple licenses', function () { + it('should use the highest tier bundle when several are active', async function () { + const auth = new Authorization( + makeApp({ + licenses: [makeLicense('bundle-essential'), makeLicense('bundle-essential-plus')], + }) + ) + // RBAC requires bundle-essential-plus → should pass because it picks the best bundle + await assert.doesNotReject(() => auth.checkFeatureAuthorization('RBAC')) + }) + }) + + describe('license expiry', function () { + // IMO, when plan check is no more used, we should throw if licence has expired + it('should ignore expired licenses and fall back to plan check (to avoid breaking changes)', async function () { + const auth = new Authorization( + makeApp({ + plan: 'free', + licenses: [makeLicense('bundle-enterprise', { expires: Date.now() - 1000 })], + }) + ) + // No active bundle → legacy plan check → free < enterprise → deny + await assert.rejects(() => auth.checkFeatureAuthorization('RBAC')) + }) + + it('should use non-expired licenses', async function () { + const auth = new Authorization( + makeApp({ licenses: [makeLicense('bundle-essential', { expires: Date.now() + 100_000 })] }) + ) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('BACKUP.DELTA')) + }) + + it('should treat licenses with no expiry as active', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense('bundle-essential-plus')] })) + await assert.doesNotReject(() => auth.checkFeatureAuthorization('WARM_MIGRATION')) + }) + }) + }) + + describe('legacy plan-based authorization (no active bundle)', function () { + for (const [plan, feature, shouldPass] of [ + ['starter', 'BACKUP.DELTA', true], + ['starter', 'RBAC', false], + ['enterprise', 'RBAC', true], + ['enterprise', 'WARM_MIGRATION', false], + ['premium', 'WARM_MIGRATION', true], + ['free', 'BACKUP.DELTA', false], + ]) { + it(`${plan} plan + ${feature} → ${shouldPass ? 'allow' : 'deny'}`, async function () { + const auth = new Authorization(makeApp({ plan })) + if (shouldPass) { + await assert.doesNotReject(() => auth.checkFeatureAuthorization(feature)) + } else { + await assert.rejects(() => auth.checkFeatureAuthorization(feature)) + } + }) + } + }) + }) + + describe('hasFeatureAuthorization', function () { + it('should return true when feature is authorized', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense(BUNDLES.enterprise)] })) + assert.equal(await auth.hasFeatureAuthorization('WARM_MIGRATION'), true) + }) + + it('should return false when feature is not authorized', async function () { + const auth = new Authorization(makeApp({ licenses: [makeLicense(BUNDLES.essential)] })) + assert.equal(await auth.hasFeatureAuthorization('RBAC'), false) + }) + }) +})