feat(xo-server): support bundle in featureCheck (#9769)

This commit is contained in:
Mathieu
2026-06-08 10:16:03 +02:00
committed by GitHub
parent 7f693de5a5
commit 7adcf9afd3
6 changed files with 275 additions and 32 deletions

View File

@@ -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<void>
checkFeatureAuthorization(featureCode: FeatureCode): 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)

View File

@@ -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

View File

@@ -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',
}))

View File

@@ -237,7 +237,7 @@ export default class {
* @returns {Promise<XoAclRole>}
*/
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<boolean>}
*/
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<XoAclRole>}
*/
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<XoAclRole>}
*/
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<XoAclRole[]>}
*/
async getAclV2Roles() {
await this._app.checkFeatureAuthorization('ACL')
await this._app.checkFeatureAuthorization('RBAC')
// @ts-ignore typed as Promise<void>...
return this.#roleDb.get()
@@ -342,7 +342,7 @@ export default class {
* @returns {Promise<Privilege>}
*/
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<boolean>}
*/
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<Privilege>}
*/
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<Privilege>}
*/
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<Privilege[]>}
*/
async getAclV2Privileges() {
await this._app.checkFeatureAuthorization('ACL')
await this._app.checkFeatureAuthorization('RBAC')
// @ts-ignore typed as Promise<void>...
return this.#privilegeDb.get()
@@ -452,7 +452,7 @@ export default class {
* @returns {Promise<UserRole>}
*/
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<boolean>}
*/
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<GroupRole>}
*/
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<boolean>}
*/
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<Privilege[]>}
*/
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<XoAclRole[]>}
*/
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<Privilege[]>}
*/
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<XoAclRole[]>}
*/
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<XoAclRole['id']>}
*/
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)

View File

@@ -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 })
}
}
}

View File

@@ -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)
})
})
})