Skip to content

Commit

Permalink
VirtualMachine actions rbac (#3905)
Browse files Browse the repository at this point in the history
Signed-off-by: zlayne <zlayne@redhat.com>
  • Loading branch information
zlayne authored Sep 26, 2024
1 parent 7635abc commit 653cf30
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 69 deletions.
4 changes: 2 additions & 2 deletions backend/src/routes/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import { Stream } from 'stream'
import { promisify } from 'util'
import { jsonPost } from '../lib/json-request'
import { logger } from '../lib/logger'
import { ITransformedResource } from '../lib/pagination'
import { ServerSideEvent, ServerSideEvents } from '../lib/server-side-events'
import { getServiceAccountToken } from '../lib/serviceAccountToken'
import { getAuthenticatedToken } from '../lib/token'
import { IResource } from '../resources/resource'
import { ITransformedResource } from '../lib/pagination'

const { map, split } = eventStream
const pipeline = promisify(Stream.pipeline)
Expand Down Expand Up @@ -631,7 +631,7 @@ function canGetResource(resource: IResource, token: string): Promise<boolean> {
return canAccess(resource, 'get', token)
}

function canAccess(
export function canAccess(
resource: { kind: string; apiVersion: string; metadata?: { name?: string; namespace?: string } },
verb: 'get' | 'list' | 'create',
token: string
Expand Down
133 changes: 75 additions & 58 deletions backend/src/routes/virtualMachineProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import { constants, Http2ServerRequest, Http2ServerResponse, OutgoingHttpHeaders } from 'http2'
import { jsonPut, jsonRequest } from '../lib/json-request'
import { logger } from '../lib/logger'
import { respondInternalServerError } from '../lib/respond'
import { respond, respondInternalServerError } from '../lib/respond'
import { getServiceAccountToken } from '../lib/serviceAccountToken'
import { getAuthenticatedToken } from '../lib/token'
import { ResourceList } from '../resources/resource-list'
import { Route } from '../resources/route'
import { Secret } from '../resources/secret'
import { canAccess } from './events'

const { HTTP_STATUS_INTERNAL_SERVER_ERROR } = constants
const proxyHeaders = [
Expand Down Expand Up @@ -37,66 +38,82 @@ export async function virtualMachineProxy(req: Http2ServerRequest, res: Http2Ser
req.on('end', async () => {
const body = JSON.parse(chucks.join()) as ActionBody

// console-mce ClusterRole does not allow for GET on secrets. Have to list in a namespace
const secretPath = process.env.CLUSTER_API_URL + `/api/v1/namespaces/${body.managedCluster}/secrets`
const managedClusterToken: string = await jsonRequest(secretPath, serviceAccountToken)
.then((response: ResourceList<Secret>) => {
const secret = response.items.find((secret) => secret.metadata.name === 'vm-actor')
const proxyToken = secret.data?.token ?? ''
return Buffer.from(proxyToken, 'base64').toString('ascii')
})
.catch((err: Error): undefined => {
logger.error({ msg: `Error getting secret in namespace ${body.managedCluster}`, error: err.message })
return undefined
})

// Get cluster proxy host
const proxyServer = await jsonRequest(
process.env.CLUSTER_API_URL +
'/apis/route.openshift.io/v1/namespaces/multicluster-engine/routes/cluster-proxy-addon-user',
// If user is not able to create an MCA in the managed cluster namespace -> they aren't authorized to trigger actions.
const hasAuth = await canAccess(
{
kind: 'ManagedClusterAction',
apiVersion: 'action.open-cluster-management.io/v1beta1',
metadata: { namespace: body.managedCluster },
},
'create',
token
)
.then((response: Route) => {
const scheme = response?.spec?.tls?.termination ? 'https' : 'http'
return response?.spec?.host ? `${scheme}://${response.spec.host}` : ''
})
.catch((err: Error): undefined => {
logger.error({ msg: 'Error getting cluster proxy Route', error: err.message })
return undefined
})
).then((allowed) => allowed)

// req.url is one of: /virtualmachines/<action> OR /virtualmachineinstances/<action>
// the VM name is need between the kind and action for the correct api url.
const splitURL = req.url.split('/')
const joinedURL = `${splitURL[1]}/${body.vmName}/${splitURL[2]}`
const path = `${proxyServer}/${body.managedCluster}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/${joinedURL}`
const headers: OutgoingHttpHeaders = { authorization: `Bearer ${managedClusterToken}` }
for (const header of proxyHeaders) {
if (req.headers[header]) headers[header] = req.headers[header]
}
if (hasAuth) {
// console-mce ClusterRole does not allow for GET on secrets. Have to list in a namespace
const secretPath = process.env.CLUSTER_API_URL + `/api/v1/namespaces/${body.managedCluster}/secrets`
const managedClusterToken: string = await jsonRequest(secretPath, serviceAccountToken)
.then((response: ResourceList<Secret>) => {
const secret = response.items.find((secret) => secret.metadata.name === 'vm-actor')
const proxyToken = secret.data?.token ?? ''
return Buffer.from(proxyToken, 'base64').toString('ascii')
})
.catch((err: Error): undefined => {
logger.error({ msg: `Error getting secret in namespace ${body.managedCluster}`, error: err.message })
return undefined
})

// Get cluster proxy host
const proxyServer = await jsonRequest(
process.env.CLUSTER_API_URL +
'/apis/route.openshift.io/v1/namespaces/multicluster-engine/routes/cluster-proxy-addon-user',
token
)
.then((response: Route) => {
const scheme = response?.spec?.tls?.termination ? 'https' : 'http'
return response?.spec?.host ? `${scheme}://${response.spec.host}` : ''
})
.catch((err: Error): undefined => {
logger.error({ msg: 'Error getting cluster proxy Route', error: err.message })
return undefined
})

if (!path) return respondInternalServerError(req, res)
await jsonPut(path, {}, managedClusterToken)
.then((results) => {
if (results?.statusCode >= 200 && results?.statusCode < 300) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(results))
} else {
logger.error({
msg: 'Error in VirtualMachine action response',
error: results.body.message,
})
res.setHeader('Content-Type', 'application/json')
res.writeHead(results.statusCode ?? HTTP_STATUS_INTERNAL_SERVER_ERROR)
delete results.body?.code // code is added via writeHead
res.end(JSON.stringify(results.body))
}
})
.catch((err: Error) => {
logger.error({ msg: 'Error on VirtualMachine action request', error: err.message })
respondInternalServerError(req, res)
return undefined
})
// req.url is one of: /virtualmachines/<action> OR /virtualmachineinstances/<action>
// the VM name is needed between the kind and action for the correct api url.
const splitURL = req.url.split('/')
const joinedURL = `${splitURL[1]}/${body.vmName}/${splitURL[2]}`
const path = `${proxyServer}/${body.managedCluster}/apis/subresources.kubevirt.io/v1/namespaces/${body.vmNamespace}/${joinedURL}`
const headers: OutgoingHttpHeaders = { authorization: `Bearer ${managedClusterToken}` }
for (const header of proxyHeaders) {
if (req.headers[header]) headers[header] = req.headers[header]
}

if (!path) return respondInternalServerError(req, res)
await jsonPut(path, {}, managedClusterToken)
.then((results) => {
if (results?.statusCode >= 200 && results?.statusCode < 300) {
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(results))
} else {
logger.error({
msg: 'Error in VirtualMachine action response',
error: results.body.message,
})
res.setHeader('Content-Type', 'application/json')
res.writeHead(results.statusCode ?? HTTP_STATUS_INTERNAL_SERVER_ERROR)
delete results.body?.code // code is added via writeHead
res.end(JSON.stringify(results.body))
}
})
.catch((err: Error) => {
logger.error({ msg: 'Error on VirtualMachine action request', error: err.message })
respondInternalServerError(req, res)
return undefined
})
} else {
logger.error({ msg: `Unauthorized request ${req.url.split('/')[2]} on VirtualMachine ${body.vmName}` })
return respond(res, `Unauthorized request ${req.url.split('/')[2]} on VirtualMachine ${body.vmName}`, 401)
}
})
} catch (err) {
logger.error(err)
Expand Down
30 changes: 30 additions & 0 deletions backend/test/routes/virtualMachineProxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import { request } from '../mock-request'
describe('Virtual Machine actions', function () {
it('should successfully call start action', async function () {
nock(process.env.CLUSTER_API_URL).get('/apis').reply(200)
nock(process.env.CLUSTER_API_URL)
.post(
'/apis/authorization.k8s.io/v1/selfsubjectaccessreviews',
'{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}'
)
.reply(200, {
status: {
allowed: true,
},
})
nock(process.env.CLUSTER_API_URL)
.get('/api/v1/namespaces/testCluster/secrets')
.reply(200, {
Expand Down Expand Up @@ -69,6 +79,16 @@ describe('Virtual Machine actions', function () {

it('should error on start action request', async function () {
nock(process.env.CLUSTER_API_URL).get('/apis').reply(200)
nock(process.env.CLUSTER_API_URL)
.post(
'/apis/authorization.k8s.io/v1/selfsubjectaccessreviews',
'{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}'
)
.reply(200, {
status: {
allowed: true,
},
})
nock(process.env.CLUSTER_API_URL)
.get('/api/v1/namespaces/testCluster/secrets')
.reply(200, {
Expand Down Expand Up @@ -139,6 +159,16 @@ describe('Virtual Machine actions', function () {

it('should fail with invalid route and secret', async function () {
nock(process.env.CLUSTER_API_URL).get('/apis').reply(200)
nock(process.env.CLUSTER_API_URL)
.post(
'/apis/authorization.k8s.io/v1/selfsubjectaccessreviews',
'{"apiVersion":"authorization.k8s.io/v1","kind":"SelfSubjectAccessReview","metadata":{},"spec":{"resourceAttributes":{"group":"action.open-cluster-management.io","namespace":"testCluster","resource":"managedclusteractions","verb":"create"}}}'
)
.reply(200, {
status: {
allowed: true,
},
})
nock(process.env.CLUSTER_API_URL).get('/api/v1/namespaces/testCluster/secrets').reply(400, {
statusCode: 400,
apiVersion: 'v1',
Expand Down
57 changes: 55 additions & 2 deletions frontend/src/routes/Search/SearchResults/utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,8 +230,61 @@ test('Correctly return VirtualMachine with actions disabled', () => {
)
expect(res).toMatchSnapshot()
})
test('should handle vm action buttons', () => {
const item = { managedHub: 'cluster1' }
test('should handle managed vm action buttons', () => {
const item = {
_uid: 'cluster1/42634581-0cc1-4aa9-bec6-69f59049e2d3',
apigroup: 'kubevirt.io',
apiversion: 'v1',
cluster: 'cluster1',
created: '2024-09-09T20:00:42Z',
kind: 'VirtualMachine',
kind_plural: 'virtualmachines',
name: 'centos7-gray-owl-35',
namespace: 'openshift-cnv',
ready: 'False',
status: 'Paused',
}
const vmActionsEnabled = true
const actions = getRowActions(
'VirtualMachine',
'kind:VirtualMachine',
false,
() => {},
() => {},
allClusters,
navigate,
toastContextMock,
vmActionsEnabled,
t
)
const startVMAction = actions.find((action) => action.id === 'startVM')
const stopVMAction = actions.find((action) => action.id === 'stopVM')
const restartVMAction = actions.find((action) => action.id === 'restartVM')
const pauseVMAction = actions.find((action) => action.id === 'pauseVM')
const unpauseVMAction = actions.find((action) => action.id === 'unpauseVM')

startVMAction?.click(item)
stopVMAction?.click(item)
restartVMAction?.click(item)
pauseVMAction?.click(item)
unpauseVMAction?.click(item)
})

test('should handle hub vm action buttons', () => {
const item = {
_hubClusterResource: 'true',
_uid: 'local-cluster/42634581-0cc1-4aa9-bec6-69f59049e2d3',
apigroup: 'kubevirt.io',
apiversion: 'v1',
cluster: 'local-cluster',
created: '2024-09-09T20:00:42Z',
kind: 'VirtualMachine',
kind_plural: 'virtualmachines',
name: 'centos7-gray-owl-35',
namespace: 'openshift-cnv',
ready: 'False',
status: 'Paused',
}
const vmActionsEnabled = true
const actions = getRowActions(
'VirtualMachine',
Expand Down
29 changes: 22 additions & 7 deletions frontend/src/routes/Search/SearchResults/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ export function handleVMActions(
.catch((err) => {
console.error(`VirtualMachine: ${item.name} ${action} error. ${err}`)

let errMessage = err?.message ?? t('An unexpected error occurred.')
if (errMessage.includes(':')) errMessage = errMessage.split(':')[1]
let errMessage: string = err?.message ?? t('An unexpected error occurred.')
if (errMessage.includes(':')) errMessage = errMessage.split(':').slice(1).join(':')
if (errMessage === 'Unauthorized') errMessage = t('Unauthorized to execute this action.')
toast.addAlert({
title: t('Error triggering action {{action}} on VirtualMachine {{name}}', {
Expand Down Expand Up @@ -267,9 +267,12 @@ export function getRowActions(
id: 'startVM',
title: t('Start {{resourceKind}}', { resourceKind }),
click: (item: any) => {
const path = item?._hubClusterResource
? `/apis/subresources.kubevirt.io/v1/namespaces/${item.namespace}/virtualmachines/${item.name}/start`
: `/virtualmachines/start`
handleVMActions(
'start',
'/virtualmachines/start',
path,
item,
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
toast,
Expand All @@ -281,9 +284,12 @@ export function getRowActions(
id: 'stopVM',
title: t('Stop {{resourceKind}}', { resourceKind }),
click: (item: any) => {
const path = item?._hubClusterResource
? `/apis/subresources.kubevirt.io/v1/namespaces/${item.namespace}/virtualmachines/${item.name}/stop`
: `/virtualmachines/stop`
handleVMActions(
'stop',
'/virtualmachines/stop',
path,
item,
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
toast,
Expand All @@ -295,9 +301,12 @@ export function getRowActions(
id: 'restartVM',
title: t('Restart {{resourceKind}}', { resourceKind }),
click: (item: any) => {
const path = item?._hubClusterResource
? `/apis/subresources.kubevirt.io/v1/namespaces/${item.namespace}/virtualmachines/${item.name}/restart`
: `/virtualmachines/restart`
handleVMActions(
'restart',
'/virtualmachines/restart',
path,
item,
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
toast,
Expand All @@ -309,9 +318,12 @@ export function getRowActions(
id: 'pauseVM',
title: t('Pause {{resourceKind}}', { resourceKind }),
click: (item: any) => {
const path = item?._hubClusterResource
? `/apis/subresources.kubevirt.io/v1/namespaces/${item.namespace}/virtualmachineinstances/${item.name}/pause`
: `/virtualmachineinstances/pause`
handleVMActions(
'pause',
'/virtualmachineinstances/pause',
path,
item,
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
toast,
Expand All @@ -323,9 +335,12 @@ export function getRowActions(
id: 'unpauseVM',
title: t('Unpause {{resourceKind}}', { resourceKind }),
click: (item: any) => {
const path = item?._hubClusterResource
? `/apis/subresources.kubevirt.io/v1/namespaces/${item.namespace}/virtualmachineinstances/${item.name}/unpause`
: `/virtualmachineinstances/unpause`
handleVMActions(
'unpause',
'/virtualmachineinstances/unpause',
path,
item,
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
toast,
Expand Down

0 comments on commit 653cf30

Please sign in to comment.