Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Member roles #642

Merged
merged 14 commits into from
Mar 30, 2020
11 changes: 11 additions & 0 deletions backend/lib/routes/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ router.route('/:name')
next(err)
}
})
.put(async (req, res, next) => {
try {
const user = req.user
const namespace = req.params.namespace
const name = req.params.name
const roles = req.body.roles
res.send(await members.update({ user, namespace, name, roles }))
grolu marked this conversation as resolved.
Show resolved Hide resolved
} catch (err) {
next(err)
}
})
.delete(async (req, res, next) => {
try {
const user = req.user
Expand Down
44 changes: 38 additions & 6 deletions backend/lib/services/members.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ function fromResource (project = {}, serviceAccounts = []) {
.chain(project)
.get('spec.members')
.filter(['kind', 'User'])
.map('name')
.map(username => ({
.map(({ name: username, role, roles }) => ({
grolu marked this conversation as resolved.
Show resolved Hide resolved
username,
roles: roles ? [role, ...roles] : [role],
grolu marked this conversation as resolved.
Show resolved Hide resolved
...serviceAccountsMetadata[username]
}))
.value()
Expand Down Expand Up @@ -76,19 +76,21 @@ async function deleteServiceaccount (client, { namespace, name }) {
}
}

async function setProjectMember (client, { namespace, name }) {
async function setProjectMember (client, { namespace, name, roles }) {
// get project
const project = await client.getProjectByNamespace(namespace)
// get project members from project
const members = _.slice(project.spec.members, 0)
if (_.find(members, ['name', name])) {
throw new Conflict(`User '${name}' is already member of this project`)
}
const role = roles.shift()
grolu marked this conversation as resolved.
Show resolved Hide resolved
members.push({
kind: 'User',
name,
apiGroup: 'rbac.authorization.k8s.io',
role: 'admin'
role,
roles
})
const body = {
spec: {
Expand All @@ -98,6 +100,26 @@ async function setProjectMember (client, { namespace, name }) {
return client['core.gardener.cloud'].projects.mergePatch(project.metadata.name, body)
}

async function updateProjectMember (client, { namespace, name, roles }) {
grolu marked this conversation as resolved.
Show resolved Hide resolved
// get project
const project = await client.getProjectByNamespace(namespace)
// get project members from project
const members = _.slice(project.spec.members, 0)
const member = _.find(members, ['name', name])
if (!member) {
throw new NotFound(`User '${name}' is not a member of this project`)
}
const role = roles.shift()
_.assign(member, { role, roles })

const body = {
spec: {
members
}
}
return client['core.gardener.cloud'].projects.mergePatch(project.metadata.name, body)
}

async function unsetProjectMember (client, { namespace, name }) {
// get project
const project = await client.getProjectByNamespace(namespace)
Expand Down Expand Up @@ -152,6 +174,7 @@ exports.get = async function ({ user, namespace, name }) {
const clusterName = 'garden'
const contextName = `${clusterName}-${projectName}-${name}`
member.kind = 'ServiceAccount'
member.roles = member.roles ? [member.role, ...member.roles] : [member.role]
grolu marked this conversation as resolved.
Show resolved Hide resolved
member.kubeconfig = dumpKubeconfig({
user: serviceAccountName,
context: contextName,
Expand All @@ -165,7 +188,7 @@ exports.get = async function ({ user, namespace, name }) {
return member
}

exports.create = async function ({ user, namespace, body: { name } }) {
exports.create = async function ({ user, namespace, body: { name, roles } }) {
grolu marked this conversation as resolved.
Show resolved Hide resolved
const client = user.client

const [, serviceaccountNamespace, serviceaccountName] = /^system:serviceaccount:([^:]+):([^:]+)$/.exec(name) || []
Expand All @@ -178,7 +201,16 @@ exports.create = async function ({ user, namespace, body: { name } }) {
}

// assign user to project
const project = await setProjectMember(client, { namespace, name })
const project = await setProjectMember(client, { namespace, name, roles })
const { items: serviceAccounts } = await client.core.serviceaccounts.list(namespace)
return fromResource(project, serviceAccounts)
}

exports.update = async function ({ user, namespace, name, roles }) {
const client = user.client

// update user in project
const project = await updateProjectMember(client, { namespace, name, roles })
const { items: serviceAccounts } = await client.core.serviceaccounts.list(namespace)
return fromResource(project, serviceAccounts)
}
Expand Down
26 changes: 21 additions & 5 deletions backend/test/acceptance/api.members.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,28 +75,44 @@ module.exports = function ({ agent, k8s, auth }) {
it('should add a project member', async function () {
const bearer = await user.bearer
const name = 'baz@example.org'
k8s.stub.addMember({ bearer, namespace, name })
const roles = ['admin', 'owner']
grolu marked this conversation as resolved.
Show resolved Hide resolved
k8s.stub.addMember({ bearer, namespace, name, roles })
const res = await agent
.post(`/api/namespaces/${namespace}/members`)
.set('cookie', await user.cookie)
.send({ metadata, name })

.send({ metadata, name, roles })
expect(res).to.have.status(200)
expect(res).to.be.json
expect(res.body).to.eql(_.concat(members, { username: 'baz@example.org' }))
expect(res.body).to.eql(_.concat(members, { username: 'baz@example.org', roles: ['admin', 'owner'] }))
})

it('should not add member that is already a project member', async function () {
const bearer = await user.bearer
const name = 'foo@example.org'
k8s.stub.addMember({ bearer, namespace, name })
const roles = ['admin']
k8s.stub.addMember({ bearer, namespace, name, roles })
const res = await agent
.post(`/api/namespaces/${namespace}/members`)
.set('cookie', await user.cookie)
.send({ metadata, name })
expect(res).to.have.status(409)
})

it('should update roles of a project member', async function () {
const bearer = await user.bearer
const name = 'bar@example.org'
const roles = ['newRole']
k8s.stub.updateMember({ bearer, namespace, name, roles })
const res = await agent
.put(`/api/namespaces/${namespace}/members/${name}`)
.set('cookie', await user.cookie)
.send({ metadata, roles })
expect(res).to.have.status(200)
expect(res).to.be.json
const member = _.find(res.body, { username: 'bar@example.org' })
expect(member).to.eql({ username: 'bar@example.org', roles: ['newRole'] })
})
grolu marked this conversation as resolved.
Show resolved Hide resolved

it('should delete a project member', async function () {
const bearer = await user.bearer
const name = 'bar@example.org'
Expand Down
66 changes: 56 additions & 10 deletions backend/test/support/nocks/k8s.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,18 @@ const projectList = [
createdBy: 'bar@example.org',
owner: 'foo@example.org',
members: [
'bar@example.org',
'system:serviceaccount:garden-foo:robot'
{
name: 'foo@example.org',
roles: ['admin', 'owner']
},
{
name: 'bar@example.org',
roles: ['admin']
},
{
name: 'system:serviceaccount:garden-foo:robot',
roles: ['viewer']
}
],
description: 'foo-description',
purpose: 'foo-purpose',
Expand All @@ -59,8 +69,14 @@ const projectList = [
createdBy: 'foo@example.org',
owner: 'bar@example.org',
members: [
'foo@example.org',
'system:serviceaccount:garden-bar:robot'
{
name: 'foo@example.org',
roles: ['admin', 'owner']
},
{
name: 'system:serviceaccount:garden-foo:robot',
roles: ['viewer', 'admin']
}
],
description: 'bar-description',
purpose: 'bar-purpose'
Expand Down Expand Up @@ -309,7 +325,7 @@ function getProjectMembers (project) {
return _
.chain(project)
.get('spec.members')
.map(({ name: username }) => ({ username }))
.map(({ name: username, role, roles }) => ({ username, roles: roles ? [role, ...roles] : [role] }))
grolu marked this conversation as resolved.
Show resolved Hide resolved
.value()
}

Expand All @@ -326,21 +342,26 @@ function getInfrastructureSecret (namespace, name, profileName, data = {}) {
}
}

function getUser (name) {
return {
function getUser (member) {
const name = member.name || member
const user = {
apiGroup: 'rbac.authorization.k8s.io',
kind: 'User',
name
}
if (member.roles) {
const roles = member.roles.slice()
const role = roles.shift()
_.assign(user, { role, roles })
}
return user
}

function getProject ({ name, namespace, createdBy, owner, members = [], description, purpose, phase = 'Ready', costObject = '' }) {
owner = owner || createdBy
namespace = namespace || `garden-${name}`
members = _
.chain(members)
.concat(owner)
.uniq()
.map(getUser)
.value()
owner = getUser(owner)
Expand Down Expand Up @@ -1283,7 +1304,7 @@ const stub = {
}
})
},
addMember ({ bearer, namespace, name: username }) {
addMember ({ bearer, namespace, name: username, roles }) {
const project = readProject(namespace)
const newProject = _.cloneDeep(project)
const name = project.metadata.name
Expand All @@ -1310,6 +1331,31 @@ const stub = {
scope
]
},
updateMember ({ bearer, namespace, name: username, roles }) {
const project = readProject(namespace)
const newProject = _.cloneDeep(project)
const name = project.metadata.name

const scope = nockWithAuthorization(bearer)
.get(`/apis/core.gardener.cloud/v1beta1/projects/${name}`)
.reply(200, () => project)
const existingMember = _.find(project.spec.members, ['name', username])
if (existingMember) {
scope
.patch(`/apis/core.gardener.cloud/v1beta1/projects/${name}`, body => {
_.assign(newProject.spec.members, body.spec.members)
return true
})
.reply(200, () => newProject)
}
getServiceAccountsForNamespace(scope, namespace)
return [
nockWithAuthorization(bearer)
.get(`/api/v1/namespaces/${namespace}`)
.reply(200, () => getProjectNamespace(namespace)),
scope
]
},
removeMember ({ bearer, namespace, name: username }) {
const project = readProject(namespace)
const newProject = _.cloneDeep(project)
Expand Down
47 changes: 35 additions & 12 deletions frontend/src/components/ProjectServiceAccountRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
-->

<template^>
<template>
<div>
<v-list-tile avatar>
<v-list-tile-avatar>
Expand All @@ -39,6 +39,7 @@ limitations under the License.
<v-layout row
fill-height
align-center
v-if="created && creationTimestamp"
>
<span class="mr-3">Created</span>
<v-tooltip top>
Expand All @@ -51,6 +52,13 @@ limitations under the License.
{{username}}
</v-list-tile-sub-title>
</v-list-tile-content>
<v-list-tile-action>
<v-layout row align-center>
<v-chip v-for="(roleName, index) in roleNames" :key="index" small color="blue-grey darken-2" text-color="white">
grolu marked this conversation as resolved.
Show resolved Hide resolved
{{roleName}}
</v-chip>
</v-layout>
</v-list-tile-action>
<v-list-tile-action v-if="isServiceAccountFromCurrentNamespace && canGetSecrets">
<v-tooltip top>
<v-btn slot="activator" icon class="blue-grey--text" @click.native.stop="onDownload">
Expand All @@ -67,6 +75,14 @@ limitations under the License.
<span>Show Kubeconfig</span>
</v-tooltip>
</v-list-tile-action>
<v-list-tile-action v-if="canPatchProject">
<v-tooltip top>
<v-btn slot="activator" icon class="blue-grey--text text--darken-2" @click.native.stop="onEdit">
<v-icon>mdi-pencil</v-icon>
</v-btn>
<span>Update Service Account</span>
</v-tooltip>
</v-list-tile-action>
<v-list-tile-action v-if="canPatchProject">
<v-tooltip top>
<v-btn slot="activator" icon class="red--text" @click.native.stop="onDelete">
Expand All @@ -80,14 +96,13 @@ limitations under the License.
</template>

<script>
import { mapState } from 'vuex'
import { mapState, mapGetters } from 'vuex'
import TimeString from '@/components/TimeString'
import GPopper from '@/components/GPopper'
import AccountAvatar from '@/components/AccountAvatar'
import {
isServiceAccountFromNamespace
} from '@/utils'
import { mapGetters } from 'vuex'

export default {
name: 'project-service-account-row',
Expand All @@ -110,15 +125,20 @@ export default {
required: true
},
createdBy: {
type: String,
required: true
type: String
},
creationTimestamp: {
type: String,
required: true
type: String
},
created: {
type: String,
type: String
},
roles: {
type: Array,
required: true
},
roleNames: {
type: Array,
required: true
}
},
Expand All @@ -134,18 +154,21 @@ export default {
return isServiceAccountFromNamespace(this.username, this.namespace)
},
createdByClasses () {
return !!this.createdBy ? ['font-weight-bold'] : ['grey--text']
return this.createdBy ? ['font-weight-bold'] : ['grey--text']
}
},
methods: {
async onDownload () {
this.$emit('onDownload', this.username)
this.$emit('download', this.username)
grolu marked this conversation as resolved.
Show resolved Hide resolved
},
async onKubeconfig () {
this.$emit('onKubeconfig', this.username)
this.$emit('kubeconfig', this.username)
},
onEdit (username) {
this.$emit('edit', this.username, this.roles)
},
onDelete (username) {
this.$emit('onDelete', this.username)
this.$emit('delete', this.username)
}
}
}
Expand Down