Skip to content

Commit

Permalink
sync factory
Browse files Browse the repository at this point in the history
  • Loading branch information
holgerkoser committed Dec 6, 2024
1 parent e935f9d commit f66b68c
Show file tree
Hide file tree
Showing 11 changed files with 305 additions and 74 deletions.
13 changes: 13 additions & 0 deletions backend/lib/cache/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ module.exports = {
}
return project
},
getProjectByUid (uid) {
return cache.get('projects').find(['metadata.uid', uid])
},
getProjects () {
return cache.getProjects()
},
Expand Down Expand Up @@ -141,4 +144,14 @@ module.exports = {
getTicketCache () {
return cache.getTicketCache()
},
getByUid (kind, uid) {
switch (kind) {
case 'Project':
return this.getProjectByUid(uid)
case 'Shoot':
return this.getShootByUid(uid)
default:
throw new TypeError(`Kind '${kind}' not supported`)
}
},
}
16 changes: 13 additions & 3 deletions backend/lib/io/dispatcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

const shoots = require('./shoots')
const projects = require('./projects')

async function subscribe (socket, key, options = {}) {
switch (key) {
Expand All @@ -29,16 +30,25 @@ function synchronize (socket, key, ...args) {
switch (key) {
case 'shoots': {
const [uids] = args
if (!Array.isArray(uids)) {
throw new TypeError('Invalid parameters for synchronize shoots')
}
assertArray(uids)
return shoots.synchronize(socket, uids)
}
case 'projects': {
const [uids] = args
assertArray(uids)
return projects.synchronize(socket, uids)
}
default:
throw new TypeError(`Invalid synchronization type - ${key}`)
}
}

function assertArray (value) {
if (!Array.isArray(value)) {
throw new TypeError('Invalid parameters for synchronize shoots')
}
}

module.exports = {
subscribe,
unsubscribe,
Expand Down
86 changes: 85 additions & 1 deletion backend/lib/io/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ const { promisify } = require('util')
const createError = require('http-errors')
const cookieParser = require('cookie-parser')
const kubernetesClient = require('@gardener-dashboard/kube-client')
const { cloneDeep } = require('lodash')
const cache = require('../cache')
const logger = require('../logger')
const authorization = require('../services/authorization')
const { authenticate } = require('../security')
const { trimObjectMetadata } = require('../utils')

const { isHttpError } = createError

Expand All @@ -20,9 +24,33 @@ function expiresIn (socket) {
return Math.max(0, refreshAt * 1000 - Date.now())
}

async function userProfiles (req, res, next) {
try {
const [
canListProjects,
canGetSecrets,
] = await Promise.all([
authorization.canListProjects(req.user),
authorization.canListSecrets(req.user),
])
const profiles = Object.freeze({
canListProjects,
canGetSecrets,
})
Object.defineProperty(req.user, 'profiles', {
value: profiles,
enumerable: true,
})
next()
} catch (err) {
next(err)
}
}

function authenticateFn (options) {
const cookieParserAsync = promisify(cookieParser())
const authenticateAsync = promisify(authenticate(options))
const userProfilesAsync = promisify(userProfiles)
const noop = () => { }
const res = {
clearCookie: noop,
Expand All @@ -32,7 +60,7 @@ function authenticateFn (options) {
return async req => {
await cookieParserAsync(req, res)
await authenticateAsync(req, res)
logger.info('User: %s', req.user)
await userProfilesAsync(req, res)
return req.user
}
}
Expand Down Expand Up @@ -98,10 +126,66 @@ function joinPrivateRoom (socket) {
return socket.join(sha256(user.id))
}

function uidNotFoundFactory (group, kind) {
return uid => ({
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `${kind} with uid ${uid} does not exist`,
reason: 'NotFound',
details: {
uid,
group,
kind,
},
code: 404,
})
}

const constants = Object.freeze({
OBJECT_FORBIDDEN: 0,
OBJECT_DEFAULT: 1,
OBJECT_UNMODIFIED: 2,
})

function synchronizeFactory (kind, options = {}) {
const {
group = 'core.gardener.cloud',
predicate = () => constants.OBJECT_DEFAULT,
} = options
const uidNotFound = uidNotFoundFactory(group, kind)

return (socket, uids = []) => {
return uids.map(uid => {
const object = cache.getByUid(kind, uid)
if (!object) {
// the project has been removed from the cache
return uidNotFound(uid)
}
switch (predicate(socket, object)) {
case constants.OBJECT_DEFAULT: {
const clonedObject = cloneDeep(object)
trimObjectMetadata(clonedObject)
return clonedObject
}
case constants.OBJECT_UNMODIFIED: {
return object
}
default: {
// the user has no authorization to access the object
return uidNotFound(uid)
}
}
})
}
}

const helper = module.exports = {
constants,
authenticationMiddleware,
getUserFromSocket,
setDisconnectTimeout,
sha256,
joinPrivateRoom,
synchronizeFactory,
}
27 changes: 27 additions & 0 deletions backend/lib/io/projects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// SPDX-FileCopyrightText: 2022 SAP SE or an SAP affiliate company and Gardener contributors
//
// SPDX-License-Identifier: Apache-2.0
//

'use strict'

const { get } = require('lodash')
const { isMemberOf } = require('../utils')
const {
constants,
getUserFromSocket,
synchronizeFactory,
} = require('./helper')

module.exports = {
synchronize: synchronizeFactory('Project', {
group: 'core.gardener.cloud',
predicate (socket, object) {
const user = getUserFromSocket(socket)
return get(user, ['profiles', 'canListProjects'], false) || isMemberOf(object, user)
? constants.OBJECT_DEFAULT
: constants.OBJECT_FORBIDDEN
},
}),
}
67 changes: 24 additions & 43 deletions backend/lib/io/shoots.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ const _ = require('lodash')
const createError = require('http-errors')
const cache = require('../cache')
const logger = require('../logger')
const { projectFilter, trimObjectMetadata, parseRooms } = require('../utils')
const {
projectFilter,
parseRooms,
} = require('../utils')
const { authorization } = require('../services')
const { getUserFromSocket } = require('./helper')
const {
constants,
getUserFromSocket,
synchronizeFactory,
} = require('./helper')

async function canListAllShoots (user, namespaces) {
const canListShoots = async namespace => [namespace, await authorization.canListShoots(user, namespace)]
Expand Down Expand Up @@ -72,51 +79,25 @@ function unsubscribe (socket) {
return Promise.all(promises)
}

function synchronize (socket, uids = []) {
const rooms = Array.from(socket.rooms).filter(room => room !== socket.id)
const [
isAdmin,
namespaces,
qualifiedNames,
] = parseRooms(rooms)
const synchronize = synchronizeFactory('Shoot', {
predicate (socket, object) {
const rooms = Array.from(socket.rooms).filter(room => room !== socket.id)
const [
isAdmin,
namespaces,
qualifiedNames,
] = parseRooms(rooms)

const uidNotFound = uid => {
return {
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `Shoot with uid ${uid} does not exist`,
reason: 'NotFound',
details: {
uid,
group: 'core.gardener.cloud',
kind: 'shoots',
},
code: 404,
}
}
return uids.map(uid => {
const object = cache.getShootByUid(uid)
if (!object) {
// the shoot has been removed from the cache
return uidNotFound(uid)
}
const { namespace, name } = object.metadata
const qualifiedName = [namespace, name].join('/')
const hasValidSubscription = isAdmin || namespaces.includes(namespace) || qualifiedNames.includes(qualifiedName)
if (!hasValidSubscription) {
// the socket has NOT joined a room (admin, namespace or individual shoot) the current shoot belongs to
return uidNotFound(uid)
if (qualifiedNames.includes(qualifiedName)) {
return constants.OBJECT_UNMODIFIED
} else if (isAdmin || namespaces.includes(namespace)) {
return constants.OBJECT_DEFAULT
}
// only send all shoot details for single shoot subscriptions
if (!qualifiedNames.includes(qualifiedName)) {
const clonedObject = _.cloneDeep(object)
trimObjectMetadata(clonedObject)
return clonedObject
}
return object
})
}
return constants.OBJECT_FORBIDDEN
},
})

module.exports = {
subscribe,
Expand Down
11 changes: 11 additions & 0 deletions backend/lib/services/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ exports.isAdmin = function (user) {
})
}

exports.canListSecrets = function (user, namespace) {
return hasAuthorization(user, {
resourceAttributes: {
verb: 'list',
group: '',
resource: 'secrets',
namespace,
},
})
}

exports.canListProjects = function (user) {
return hasAuthorization(user, {
resourceAttributes: {
Expand Down
45 changes: 26 additions & 19 deletions backend/lib/watches/projects.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,17 @@

'use strict'

const { get } = require('lodash')

const ioHelper = require('../io/helper')
const { isMemberOf } = require('../utils')

module.exports = (io, informer, options) => {
const nsp = io.of('/')

const handleEvent = async (newObject, oldObject) => {
const { uid } = newObject.metadata

const handleEvent = async (type, newObject, oldObject) => {
const path = ['metadata', 'uid']
const uid = get(newObject, path, get(oldObject, path))
const sockets = await io.fetchSockets()
const users = new Map()
for (const socket of sockets) {
Expand All @@ -24,26 +26,31 @@ module.exports = (io, informer, options) => {
}
}
for (const user of users.values()) {
const isMember = isMemberOf(newObject, user)
const hasBeenMember = oldObject
? isMemberOf(oldObject, user)
: false
let type
if (hasBeenMember && isMember) {
type = 'MODIFIED'
} else if (!hasBeenMember && isMember) {
type = 'ADDED'
} else if (hasBeenMember && !isMember) {
type = 'DELETED'
const event = { uid }
const canListProjects = get(user, ['profiles', 'canListProjects'], false)
if (canListProjects) {
event.type = type
} else {
const isMember = isMemberOf(newObject, user)
const hasBeenMember = oldObject
? isMemberOf(oldObject, user)
: false
if (hasBeenMember && isMember) {
event.type = 'MODIFIED'
} else if (!hasBeenMember && isMember) {
event.type = 'ADDED'
} else if (hasBeenMember && !isMember) {
event.type = 'DELETED'
}
}
if (type) {
if (event.type) {
const room = ioHelper.sha256(user.id)
nsp.to(room).emit('projects', { type, uid })
nsp.to(room).emit('projects', event)
}
}
}

informer.on('add', (...args) => handleEvent(...args))
informer.on('update', (...args) => handleEvent(...args))
informer.on('delete', (...args) => handleEvent(null, ...args))
informer.on('add', object => handleEvent('ADDED', object))
informer.on('update', (object, oldObject) => handleEvent('MODIFIED', object, oldObject))
informer.on('delete', object => handleEvent('DELETED', object))
}
Loading

0 comments on commit f66b68c

Please sign in to comment.