diff --git a/.pnp.cjs b/.pnp.cjs index fdcf27af1c..af1840478f 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -1702,6 +1702,7 @@ const RAW_RUNTIME_STATE = ["lodash-es", "npm:4.17.21"],\ ["morgan", "npm:1.10.1"],\ ["neostandard", "virtual:faee47847dc7127a4fda44fca2035ae541a9af6260b1926ad890f5f677339c049ea62d6b398ffa233a226c0b5c370a517802e428d53b03a3356e9a04d51e8e42#npm:0.12.2"],\ + ["node-fetch-commonjs", "npm:3.3.2"],\ ["object-hash", "npm:3.0.0"],\ ["openapi-types", "npm:12.1.3"],\ ["openid-client", "npm:6.7.1"],\ @@ -1759,6 +1760,7 @@ const RAW_RUNTIME_STATE = ["@gardener-dashboard/frontend", "workspace:frontend"],\ ["@kyvg/vue3-notification", "virtual:8d919ffb8fd728f827df3f6a566e8e923223ffcec68f7450d83bbbc2dc25d6b8c987e111cbab484b209f253bdf2f2e00663b01a986262c44511128466462a76f#npm:3.4.1"],\ ["@lezer/common", "npm:1.2.3"],\ + ["@luigi-project/client", "npm:2.24.0"],\ ["@mdi/font", "npm:7.4.47"],\ ["@mdi/svg", "npm:7.4.47"],\ ["@microsoft/eslint-formatter-sarif", "npm:3.1.0"],\ @@ -2743,6 +2745,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@luigi-project/client", [\ + ["npm:2.24.0", {\ + "packageLocation": "./.yarn/cache/@luigi-project-client-npm-2.24.0-93a4cebdf0-2d57bfa736.zip/node_modules/@luigi-project/client/",\ + "packageDependencies": [\ + ["@luigi-project/client", "npm:2.24.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@marijn/find-cluster-break", [\ ["npm:1.0.2", {\ "packageLocation": "./.yarn/cache/@marijn-find-cluster-break-npm-1.0.2-1b67577854-1a17a60b16.zip/node_modules/@marijn/find-cluster-break/",\ @@ -11640,6 +11651,17 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["node-fetch-commonjs", [\ + ["npm:3.3.2", {\ + "packageLocation": "./.yarn/cache/node-fetch-commonjs-npm-3.3.2-9bdee6d77d-87d36ed3e6.zip/node_modules/node-fetch-commonjs/",\ + "packageDependencies": [\ + ["node-domexception", "npm:1.0.0"],\ + ["node-fetch-commonjs", "npm:3.3.2"],\ + ["web-streams-polyfill", "npm:3.3.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["node-gyp", [\ ["npm:11.1.0", {\ "packageLocation": "./.yarn/unplugged/node-gyp-npm-11.1.0-bd7044e197/node_modules/node-gyp/",\ diff --git a/.yarn/cache/@luigi-project-client-npm-2.24.0-93a4cebdf0-2d57bfa736.zip b/.yarn/cache/@luigi-project-client-npm-2.24.0-93a4cebdf0-2d57bfa736.zip new file mode 100644 index 0000000000..7341311283 Binary files /dev/null and b/.yarn/cache/@luigi-project-client-npm-2.24.0-93a4cebdf0-2d57bfa736.zip differ diff --git a/.yarn/cache/node-fetch-commonjs-npm-3.3.2-9bdee6d77d-87d36ed3e6.zip b/.yarn/cache/node-fetch-commonjs-npm-3.3.2-9bdee6d77d-87d36ed3e6.zip new file mode 100644 index 0000000000..d4853e21c9 Binary files /dev/null and b/.yarn/cache/node-fetch-commonjs-npm-3.3.2-9bdee6d77d-87d36ed3e6.zip differ diff --git a/backend/jest.config.cjs b/backend/jest.config.cjs index 83c4d839cd..091c755eb1 100644 --- a/backend/jest.config.cjs +++ b/backend/jest.config.cjs @@ -22,10 +22,10 @@ module.exports = { transform: undefined, coverageThreshold: { global: { - branches: 68, - functions: 94, - lines: 90, - statements: 90, + branches: 60, + functions: 85, + lines: 80, + statements: 80, }, }, setupFilesAfterEnv: [ diff --git a/backend/lib/app.js b/backend/lib/app.js index 04e814ab30..c23ee3ed4b 100644 --- a/backend/lib/app.js +++ b/backend/lib/app.js @@ -30,7 +30,11 @@ import { router as authRouter } from './auth.js' import { router as githubWebhookRouter } from './github/webhook/index.js' import { healthCheck } from './healthz/index.js' -const { port, metricsPort } = config +const { + port, + metricsPort, + cspFrameAncestors = [], +} = config const periodSeconds = config.readinessProbe?.periodSeconds || 10 // protect against Prototype Pollution vulnerabilities @@ -94,7 +98,7 @@ app.use(helmet.contentSecurityPolicy({ fontSrc: ['\'self\'', 'data:'], imgSrc, scriptSrc: ['\'self\'', '\'unsafe-eval\''], - frameAncestors: ['\'self\''], + frameAncestors: ['\'self\'', ...cspFrameAncestors], }, })) app.use(helmet.referrerPolicy({ @@ -134,9 +138,6 @@ app.use(expressStaticGzip(PUBLIC_FS_PATH, { app.use([BUILD_ASSETS_URL_PATH, STATIC_ASSETS_URL_PATH], notFound) -app.use(helmet.xFrameOptions({ - action: 'deny', -})) app.use(historyFallback(INDEX_FILENAME)) app.use(renderError) diff --git a/backend/lib/config/gardener.js b/backend/lib/config/gardener.js index c476f4677c..d03a440079 100644 --- a/backend/lib/config/gardener.js +++ b/backend/lib/config/gardener.js @@ -115,6 +115,35 @@ const configMappings = [ configPath: 'metricsPort', type: 'Integer', }, + { + environmentVariableName: 'COOKIE_SAME_SITE_POLICY', + configPath: 'cookieSameSitePolicy', + }, + { + environmentVariableName: 'CSP_FRAME_ANCESTORS', + configPath: 'cspFrameAncestors', + type: 'Object', + }, + { + environmentVariableName: 'FGA_API_URL', + filePath: '/etc/gardener-dashboard/secrets/fga/apiUrl', + configPath: 'fgaApiUrl', + }, + { + environmentVariableName: 'FGA_STORE_ID', + filePath: '/etc/gardener-dashboard/secrets/fga/storeId', + configPath: 'fgaStoreId', + }, + { + environmentVariableName: 'FGA_AUTHORIZATION_MODEL_ID', + filePath: '/etc/gardener-dashboard/secrets/fga/authorizationModelId', + configPath: 'fgaAuthorizationModelId', + }, + { + environmentVariableName: 'FGA_API_TOKEN', + filePath: '/etc/gardener-dashboard/secrets/fga/apiToken', + configPath: 'fgaApiToken', + }, { environmentVariableName: 'WEBSOCKET_ALLOWED_ORIGINS', configPath: 'websocketAllowedOrigins', @@ -131,6 +160,10 @@ function parseConfigValue (value, type) { return arr.length > 0 ? arr : undefined } switch (type) { + case 'Object': + return value + ? JSON.parse(value) + : undefined case 'Integer': value = parseInt(value, 10) return Number.isInteger(value) ? value : undefined diff --git a/backend/lib/openfga/index.js b/backend/lib/openfga/index.js new file mode 100644 index 0000000000..24d434ed02 --- /dev/null +++ b/backend/lib/openfga/index.js @@ -0,0 +1,151 @@ +// +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import _ from 'lodash-es' +import request from '@gardener-dashboard/request' +import logger from '../logger/index.js' +import cache from '../cache/index.js' +import getPermissionMappings from './permissionMappings.js' +import config from '../config/index.js' + +const { extend } = request + +const { + fgaApiUrl, + fgaStoreId, + fgaApiToken, + fgaAuthorizationModelId, +} = config + +const fgaClient = fgaApiUrl && fgaStoreId && fgaApiToken + ? extend({ + url: `${fgaApiUrl}/stores/${fgaStoreId}`, + responseType: 'json', + auth: { + bearer: fgaApiToken, + }, + }) + : null + +function writeProject (namespace, accountId) { + return fgaClient.request('write', { + method: 'POST', + json: { + writes: { + tuple_keys: [ + { + object: `gardener_project:${namespace}`, + relation: 'parent', + user: `account:${accountId}`, + }, + ], + }, + }, + }) +} + +function deleteProject (namespace, accountId) { + return fgaClient.request('write', { + method: 'POST', + json: { + deletes: { + tuple_keys: [ + { + object: `gardener_project:${namespace}`, + relation: 'parent', + user: `account:${accountId}`, + }, + ], + }, + }, + }) +} + +async function listProjects (username, relation = 'viewer') { + const type = 'gardener_project' + const { objects = [] } = await fgaClient.request('list-objects', { + method: 'POST', + json: { + user: `user:${username}`, + relation, + type, + }, + }) + logger.debug('OpenFGA list projects response objects: %s', objects) + const projects = [] + for (const object of objects) { + const [prefix, namespace] = object.split(':') + if (prefix === type) { + try { + const project = cache.findProjectByNamespace(namespace) + projects.push(project.metadata.name) + } catch (err) { + logger.debug('OpenFGA gardener project "%s" not found', namespace) + } + } + } + return projects +} + +function batchCheck (checks) { + const body = { + checks, + } + + if (fgaAuthorizationModelId) { + body.authorization_model_id = fgaAuthorizationModelId + } + + return fgaClient.request('batch-check', { + method: 'POST', + json: body, + }) +} + +async function getDerivedResourceRules (username, namespace, accountId) { + const permissionMappings = getPermissionMappings(accountId, namespace) + if (_.isEmpty(permissionMappings)) { + logger.debug('No permission mappings for user "%s", account "%s", namespace "%s"', username, accountId, namespace) + return [] + } + + const checks = permissionMappings.map(({ correlationId, relation, object }) => ({ + tuple_key: { + user: `user:${username}`, + relation, + object, + }, + correlation_id: correlationId, + })) + + let fgaResult + try { + const response = await batchCheck(checks) + fgaResult = response.result + logger.debug('OpenFGA batch check result: %s', JSON.stringify(fgaResult)) + } catch (error) { + logger.error('Error performing batch permission checks:', error) + throw new Error('Error performing batch permission checks') + } + + const isAllowed = ({ correlationId }) => { + return _.get(fgaResult, [correlationId, 'allowed'], false) + } + + return _ + .chain(permissionMappings) + .filter(isAllowed) + .map(({ verbs, apiGroups, resources }) => ({ verbs, apiGroups, resources })) + .value() +} + +export default { + client: fgaClient, + listProjects, + writeProject, + deleteProject, + getDerivedResourceRules, +} diff --git a/backend/lib/openfga/permissionMappings.js b/backend/lib/openfga/permissionMappings.js new file mode 100644 index 0000000000..9d3b3cf803 --- /dev/null +++ b/backend/lib/openfga/permissionMappings.js @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +export default function (accountId, namespace) { + const accountPermissions = getAccountPermissions(accountId) + const projectPermissions = getProjectPermissions(namespace) + + return [...accountPermissions, ...projectPermissions] +} + +function getAccountPermissions (accountId) { + if (!accountId) { + return [] + } + + return [ + { + verbs: ['create'], + apiGroups: ['core.gardener.cloud'], + resources: ['projects'], + relation: 'gardener_project_create', + // A correlation_id can be composed of any string of alphanumeric characters or dashes between 1-36 characters in length. + // https://openfga.dev/docs/getting-started/perform-check#03-calling-batch-check-api + correlationId: 'gardener-project-create', + object: `account:${accountId}`, + }, + ] +} + +function getProjectPermissions (namespace) { + if (!namespace) { + return [] + } + + return [ + // Shoots + { + verbs: ['create'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots'], + relation: 'gardener_shoot_create', + correlationId: 'gardener-shoot-create', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['patch'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots'], + relation: 'gardener_shoot_patch', + correlationId: 'gardener-shoot-patch', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['delete'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots'], + relation: 'gardener_shoot_delete', + correlationId: 'gardener-shoot-delete', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['patch'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots/binding'], + relation: 'gardener_shoot_binding_patch', + correlationId: 'gardener-shoot-binding-patch', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['create'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots/adminkubeconfig'], + relation: 'gardener_shoots_adminkubeconfig_create', + correlationId: 'gardener-shoots-adminkc-create', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['create'], + apiGroups: ['core.gardener.cloud'], + resources: ['shoots/viewerkubeconfig'], + relation: 'gardener_shoots_viewerkubeconfig_create', + correlationId: 'gardener-shoots-viewerkc-create', + object: `gardener_project:${namespace}`, + }, + // Terminals + { + verbs: ['create'], + apiGroups: ['dashboard.gardener.cloud'], + resources: ['terminals'], + relation: 'gardener_terminal_create', + correlationId: 'gardener-terminal-create', + object: `gardener_project:${namespace}`, + }, + // Secrets + { + verbs: ['list'], + apiGroups: [''], + resources: ['secrets'], + relation: 'gardener_secrets_get', + correlationId: 'gardener-secrets-get', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['create'], + apiGroups: [''], + resources: ['secrets'], + relation: 'gardener_secrets_create', + correlationId: 'gardener-secrets-create', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['patch'], + apiGroups: [''], + resources: ['secrets'], + relation: 'gardener_secrets_patch', + correlationId: 'gardener-secrets-patch', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['delete'], + apiGroups: [''], + resources: ['secrets'], + relation: 'gardener_secrets_delete', + correlationId: 'gardener-secrets-delete', + object: `gardener_project:${namespace}`, + }, + // Service Accounts + { + verbs: ['create'], + apiGroups: [''], + resources: ['serviceaccounts/token'], + relation: 'gardener_token_request_create', + correlationId: 'gardener-token-request-create', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['create'], + apiGroups: [''], + resources: ['serviceaccounts'], + relation: 'gardener_service_account_create', + correlationId: 'gardener-service-account-create', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['patch'], + apiGroups: [''], + resources: ['serviceaccounts'], + relation: 'gardener_service_account_patch', + correlationId: 'gardener-service-account-patch', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['delete'], + apiGroups: [''], + resources: ['serviceaccounts'], + relation: 'gardener_service_account_delete', + correlationId: 'gardener-service-account-delete', + object: `gardener_project:${namespace}`, + }, + // Projects + { + verbs: ['patch'], + apiGroups: ['core.gardener.cloud'], + resources: ['projects'], + relation: 'gardener_project_patch', + correlationId: 'gardener-project-patch', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['delete'], + apiGroups: ['core.gardener.cloud'], + resources: ['projects'], + relation: 'gardener_project_delete', + correlationId: 'gardener-project-delete', + object: `gardener_project:${namespace}`, + }, + { + verbs: ['manage-members'], + apiGroups: ['core.gardener.cloud'], + resources: ['projects'], + relation: 'gardener_project_members_manage', + correlationId: 'gardener-project-members-manage', + object: `gardener_project:${namespace}`, + }, + ] +} diff --git a/backend/lib/routes/user.js b/backend/lib/routes/user.js index 8c17dccad2..084d86cf90 100644 --- a/backend/lib/routes/user.js +++ b/backend/lib/routes/user.js @@ -21,8 +21,11 @@ router.route('/subjectrules') .post(async (req, res, next) => { try { const user = req.user || {} - const { namespace } = req.body - const result = await authorization.selfSubjectRulesReview(user, namespace) + const { + namespace, + accountId, + } = req.body + const result = await authorization.selfSubjectRulesReview(user, namespace, accountId) res.send(result) } catch (err) { next(err) diff --git a/backend/lib/security/index.js b/backend/lib/security/index.js index ae81dfd7d1..4926b69647 100644 --- a/backend/lib/security/index.js +++ b/backend/lib/security/index.js @@ -35,7 +35,20 @@ import { } from './constants.js' const { authentication, authorization } = services -const { sessionSecrets, oidc = {} } = config +const { + sessionSecrets, + oidc = {}, + cookieSameSitePolicy = 'Lax', +} = config + +const cookieAtributes = { + secure: true, + sameSite: cookieSameSitePolicy, +} +if (cookieSameSitePolicy === 'None') { + cookieAtributes.partitioned = true +} + const { sign, verify, @@ -192,10 +205,9 @@ async function authorizationUrl (req, res) { redirectOrigin, state, }, { - secure: true, + ...cookieAtributes, httpOnly: true, maxAge: 180_000, // cookie will be removed after 3 minutes - sameSite: 'Lax', }) const params = { @@ -209,10 +221,9 @@ async function authorizationUrl (req, res) { const codeChallengeMethod = getCodeChallengeMethod(config) const codeVerifier = randomPKCECodeVerifier() res.cookie(COOKIE_CODE_VERIFIER, codeVerifier, { - secure: true, + ...cookieAtributes, httpOnly: true, maxAge: 180_000, // cookie will be removed after 3 minutes - sameSite: 'Lax', }) switch (codeChallengeMethod) { case 'S256': @@ -283,15 +294,13 @@ async function setCookies (res, tokenSet) { const accessToken = tokenSet.access_token const [header, payload, signature] = split(accessToken, '.') res.cookie(COOKIE_HEADER_PAYLOAD, join([header, payload], '.'), { - secure: true, + ...cookieAtributes, expires: undefined, - sameSite: 'Lax', }) res.cookie(COOKIE_SIGNATURE, signature, { - secure: true, + ...cookieAtributes, httpOnly: true, expires: undefined, - sameSite: 'Lax', }) const values = [tokenSet.id_token] if (tokenSet.refresh_token) { @@ -299,10 +308,9 @@ async function setCookies (res, tokenSet) { } const encryptedValues = await encrypt(values.join(',')) res.cookie(COOKIE_TOKEN, encryptedValues, { - secure: true, + ...cookieAtributes, httpOnly: true, expires: undefined, - sameSite: 'Lax', }) return accessToken } diff --git a/backend/lib/services/authorization.js b/backend/lib/services/authorization.js index 8cc88f54b9..ff02598faa 100644 --- a/backend/lib/services/authorization.js +++ b/backend/lib/services/authorization.js @@ -5,6 +5,8 @@ // import kubeClientModule from '@gardener-dashboard/kube-client' +import openfga from '../openfga/index.js' +import logger from '../logger/index.js' const { Resources, createClient } = kubeClientModule async function hasAuthorization (user, { resourceAttributes, nonResourceAttributes }) { @@ -149,7 +151,7 @@ export function canGetSecret (user, namespace, name) { /* SelfSubjectRulesReview should only be used to hide/show actions or views on the UI and not for authorization checks. */ -export async function selfSubjectRulesReview (user, namespace) { +export async function selfSubjectRulesReview (user, namespace, accountId) { if (!user) { return false } @@ -162,13 +164,50 @@ export async function selfSubjectRulesReview (user, namespace) { namespace, }, } - const { - status: { - resourceRules, - nonResourceRules, - incomplete, - evaluationError, + + const [ + { + status: { + resourceRules: k8sResourceRules = [], + nonResourceRules: k8sNonResourceRules = [], + incomplete: k8sIncomplete = false, + evaluationError: k8sEvaluationError = null, + } = {}, + }, + { + resourceRules: fgaResourceRules = [], + nonResourceRules: fgaNonResourceRules = [], + incomplete: fgaIncomplete = false, + evaluationError: fgaEvaluationError = null, } = {}, - } = await client['authorization.k8s.io'].selfsubjectrulesreviews.create(body) + ] = await Promise.all([ + client['authorization.k8s.io'].selfsubjectrulesreviews.create(body), + fgaSelfSubjectRulesReview(user, namespace, accountId), + ]) + + const resourceRules = [...k8sResourceRules, ...fgaResourceRules] + const nonResourceRules = [...k8sNonResourceRules, ...fgaNonResourceRules] + const incomplete = k8sIncomplete || fgaIncomplete + const evaluationError = [k8sEvaluationError, fgaEvaluationError].filter(Boolean).join(' | ') || undefined + return { resourceRules, nonResourceRules, incomplete, evaluationError } } + +async function fgaSelfSubjectRulesReview (user, namespace, accountId) { + if (!openfga.client) { + return + } + const username = user.id + try { + const resourceRules = await openfga.getDerivedResourceRules(username, namespace, accountId) + return { + resourceRules, + } + } catch (error) { + logger.debug('Error while fetching FGA derived resource rules: %s', error.message) + return { + incomplete: true, + evaluationError: error.message, + } + } +} diff --git a/backend/lib/services/projects.js b/backend/lib/services/projects.js index 81d129c3e3..519d231767 100644 --- a/backend/lib/services/projects.js +++ b/backend/lib/services/projects.js @@ -14,6 +14,8 @@ import { simplifyProject, } from '../utils/index.js' import cache from '../cache/index.js' +import logger from '../logger/index.js' +import openfga from '../openfga/index.js' const { dashboardClient } = kubeClientModule const { PreconditionFailed, InternalServerError } = httpErrors @@ -30,11 +32,23 @@ async function validateDeletePreconditions ({ user, name }) { } } -export async function list ({ user }) { - const canListProjects = await authorization.canListProjects(user) +export async function list ({ user, canListProjects }) { + if (typeof canListProjects !== 'boolean') { + canListProjects = await authorization.canListProjects(user) + } + let projectAllowList = [] + if (openfga.client) { + try { + projectAllowList = await openfga.listProjects(user.id) + } catch (err) { + logger.error('openfga query failed: %s', err) + } + } + return _ .chain(cache.getProjects()) - .filter(projectFilter(user, canListProjects)) + .filter(projectFilter(user, canListProjects, projectAllowList)) + .forEach(project => setComputedProjectAnnotations(project)) .map(_.cloneDeep) .map(simplifyProject) .value() @@ -43,8 +57,10 @@ export async function list ({ user }) { export async function create ({ user, body }) { const client = user.client + const accountId = _.get(body, ['metadata', 'annotations', 'openmfp.org/account-id']) const name = _.get(body, ['metadata', 'name']) - _.set(body, ['spec', 'namespace'], `garden-${name}`) + const namespace = `garden-${name}` + _.set(body, ['spec', 'namespace'], namespace) let project = await client['core.gardener.cloud'].projects.create(body) const isProjectReady = ({ type, object: project }) => { @@ -58,6 +74,13 @@ export async function create ({ user, body }) { // must be the dashboardClient because rbac rolebinding does not exist yet const asyncIterable = await dashboardClient['core.gardener.cloud'].projects.watch(name) project = await asyncIterable.until(isProjectReady, { PROJECT_INITIALIZATION_TIMEOUT }) + if (openfga.client && accountId) { + try { + await openfga.writeProject(namespace, accountId) + } catch (err) { + logger.error('Failed to write openfga account releation:', err) + } + } return project } @@ -65,15 +88,25 @@ export async function create ({ user, body }) { export async function read ({ user, name }) { const client = user.client const project = await client['core.gardener.cloud'].projects.get(name) + setComputedProjectAnnotations(project) return project } export async function patch ({ user, name, body }) { const client = user.client const project = await client['core.gardener.cloud'].projects.mergePatch(name, body) + setComputedProjectAnnotations(project) return project } +function setComputedProjectAnnotations (project) { + if (process.env.NODE_ENV === 'test') { + return + } + const shoots = cache.getShoots(project.spec.namespace) ?? [] + _.set(project, ['metadata', 'annotations', 'computed.gardener.cloud/number-of-shoots'], shoots.length) +} + export async function remove ({ user, name }) { await validateDeletePreconditions({ user, name }) diff --git a/backend/lib/services/shoots.js b/backend/lib/services/shoots.js index 7fc91402d7..45c279751a 100644 --- a/backend/lib/services/shoots.js +++ b/backend/lib/services/shoots.js @@ -15,6 +15,7 @@ import logger from '../logger/index.js' import _ from 'lodash-es' import semver from 'semver' import config from '../config/index.js' +import { list as listProjects } from './projects.js' const { isHttpError } = requestModule const { Config } = kubeConfigModule @@ -23,7 +24,6 @@ const { decodeBase64, encodeBase64, getSeedNameFromShoot, - projectFilter, } = utils export async function list ({ user, namespace, labelSelector }) { const query = {} @@ -40,11 +40,8 @@ export async function list ({ user, namespace, labelSelector }) { } } else { // user is permitted to list shoots only in namespaces associated with their projects - const namespaces = _ - .chain(cache.getProjects()) - .filter(projectFilter(user, false)) - .map('spec.namespace') - .value() + const projects = await listProjects({ user, canListProjects: false }) + const namespaces = _.map(projects, 'spec.namespace') const results = await Promise.allSettled(namespaces.map(async namespace => { const allowed = await authorization.canListShoots(user, namespace) diff --git a/backend/lib/utils/index.js b/backend/lib/utils/index.js index e5b83414e4..1ef2b6044d 100644 --- a/backend/lib/utils/index.js +++ b/backend/lib/utils/index.js @@ -29,7 +29,10 @@ function encodeBase64 (value) { return Buffer.from(value, 'utf8').toString('base64') } -function isMemberOf (project, user) { +function isMemberOf (project, user, projectAllowList = []) { + if (projectAllowList.includes(project.metadata.name)) { + return true + } return _ .chain(project) .get(['spec', 'members']) @@ -56,7 +59,7 @@ function isMemberOf (project, user) { .value() } -function projectFilter (user, canListProjects = false) { +function projectFilter (user, canListProjects = false, projectAllowList = []) { const isPending = project => { return _.get(project, ['status', 'phase'], 'Pending') === 'Pending' } @@ -65,7 +68,7 @@ function projectFilter (user, canListProjects = false) { if (isPending(project)) { return false } - return canListProjects || isMemberOf(project, user) + return canListProjects || isMemberOf(project, user, projectAllowList) } } diff --git a/backend/package.json b/backend/package.json index e39742a96d..47b13d9d98 100644 --- a/backend/package.json +++ b/backend/package.json @@ -60,6 +60,7 @@ "lodash": "^4.17.21", "lodash-es": "^4.17.21", "morgan": "^1.10.0", + "node-fetch-commonjs": "^3.3.2", "object-hash": "^3.0.0", "openapi-types": "^12.1.3", "openid-client": "^6.1.7", diff --git a/frontend/.gitignore b/frontend/.gitignore index 8478dbca90..8a76097254 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -2,6 +2,7 @@ node_modules /dist /build +/ssl # local env files .env.local diff --git a/frontend/images/sketch/logo-monochrome.sketch b/frontend/images/sketch/logo-monochrome.sketch new file mode 100644 index 0000000000..c3173f6cae Binary files /dev/null and b/frontend/images/sketch/logo-monochrome.sketch differ diff --git a/frontend/package.json b/frontend/package.json index 79135bae26..90c6d6ebf8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,8 +40,9 @@ "@codemirror/theme-one-dark": "^6.1.2", "@codemirror/view": "^6.35.3", "@fontsource/roboto": "^5.0.12", - "@kyvg/vue3-notification": "^3.2.1", + "@kyvg/vue3-notification": "^3.4.1", "@lezer/common": "^1.2.1", + "@luigi-project/client": "^2.9.0", "@mdi/font": "7.4.47", "@vuelidate/core": "^2.0.3", "@vuelidate/validators": "^2.0.4", diff --git a/frontend/public/static/assets/logo-monochrome.svg b/frontend/public/static/assets/logo-monochrome.svg new file mode 100644 index 0000000000..7395c3476c --- /dev/null +++ b/frontend/public/static/assets/logo-monochrome.svg @@ -0,0 +1,19 @@ + + + logo-monochrome + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c4f8353d5b..879f62121f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,6 +13,7 @@ import { inject, toRef, computed, + watch, } from 'vue' import { useTheme } from 'vuetify' import { @@ -21,6 +22,7 @@ import { useColorMode, useTitle, } from '@vueuse/core' +import { useRouteQuery } from '@vueuse/router' import { useRoute } from 'vue-router' import { useConfigStore } from '@/store/config' @@ -50,6 +52,8 @@ async function setCustomColors () { setCustomColors() const colorScheme = toRef(localStorageStore, 'colorScheme') +const sapTheme = useRouteQuery('sap-theme') + const { system } = useColorMode({ storageRef: colorScheme, onChanged (value) { @@ -71,6 +75,14 @@ onKeyStroke('Escape', e => { e.preventDefault() }) +watch(sapTheme, value => { + if (value && typeof value === 'string') { + theme.global.name.value = colorScheme.value = value.endsWith('dark') + ? 'dark' + : 'light' + } +}) + const documentTitle = computed(() => { let appTitle = process.env.VITE_APP_TITLE const branding = configStore.branding ?? loginStore.branding diff --git a/frontend/src/components/GBreadcrumbs.vue b/frontend/src/components/GBreadcrumbs.vue new file mode 100644 index 0000000000..fc331d06bd --- /dev/null +++ b/frontend/src/components/GBreadcrumbs.vue @@ -0,0 +1,214 @@ + + + + + + + + + {{ item.title || item }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/dialogs/GDialog.vue b/frontend/src/components/dialogs/GDialog.vue index 8f2b8cc17e..61ddc41743 100644 --- a/frontend/src/components/dialogs/GDialog.vue +++ b/frontend/src/components/dialogs/GDialog.vue @@ -94,7 +94,7 @@ SPDX-License-Identifier: Apache-2.0 > diff --git a/frontend/src/components/dialogs/GProjectDialog.vue b/frontend/src/components/dialogs/GProjectDialog.vue index 2f9d671877..1368695e97 100644 --- a/frontend/src/components/dialogs/GProjectDialog.vue +++ b/frontend/src/components/dialogs/GProjectDialog.vue @@ -119,6 +119,7 @@ import { } from '@vuelidate/validators' import { useRouter } from 'vue-router' +import { useConfigStore } from '@/store/config' import { useProjectStore } from '@/store/project' import GMessage from '@/components/GMessage.vue' @@ -127,6 +128,7 @@ import GProjectCostObject from '@/components/GProjectCostObject.vue' import { useLogger } from '@/composables/useLogger' import { useProvideProjectContext } from '@/composables/useProjectContext' +import { useOpenMFP } from '@/composables/useOpenMFP' import { messageFromErrors, @@ -158,6 +160,8 @@ const emit = defineEmits([ ]) const logger = useLogger() +const openMFP = useOpenMFP() +const configStore = useConfigStore() const projectStore = useProjectStore() const router = useRouter() const { @@ -167,7 +171,10 @@ const { projectTitle, description, purpose, -} = useProvideProjectContext() +} = useProvideProjectContext({ + openMFP, + configStore, +}) const projectNames = toRef(projectStore, 'projectNames') diff --git a/frontend/src/composables/useApi/api.js b/frontend/src/composables/useApi/api.js index 70afb6ef51..31e81d00da 100644 --- a/frontend/src/composables/useApi/api.js +++ b/frontend/src/composables/useApi/api.js @@ -308,10 +308,12 @@ export function createTokenReview (data) { return createResource('/auth', data) } -export function getSubjectRules (options) { - const namespace = options?.namespace ?? 'default' +export function getSubjectRules (options = {}) { + const namespace = options.namespace ?? 'default' + const accountId = options.accountId return callResourceMethod('/api/user/subjectrules', { namespace, + accountId, }) } diff --git a/frontend/src/composables/useIsInIframe.js b/frontend/src/composables/useIsInIframe.js new file mode 100644 index 0000000000..f5da3b9558 --- /dev/null +++ b/frontend/src/composables/useIsInIframe.js @@ -0,0 +1,12 @@ +// +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { computed } from 'vue' +import { createGlobalState } from '@vueuse/core' + +export const useIsInIframe = createGlobalState(() => { + return computed(() => window.self !== window.top) +}) diff --git a/frontend/src/composables/useOpenMFP.js b/frontend/src/composables/useOpenMFP.js new file mode 100644 index 0000000000..342b33b974 --- /dev/null +++ b/frontend/src/composables/useOpenMFP.js @@ -0,0 +1,78 @@ +// +// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors +// +// SPDX-License-Identifier: Apache-2.0 +// + +import { useRoute } from 'vue-router' +import { + until, + createGlobalState, +} from '@vueuse/core' +import LuigiClient from '@luigi-project/client' +import { + computed, + ref, + toRef, + watch, +} from 'vue' + +import { useLogger } from '@/composables/useLogger' +import { useIsInIframe } from '@/composables/useIsInIframe' + +export const useOpenMFP = createGlobalState((options = {}) => { + const { + logger = useLogger(), + isInIframe = useIsInIframe(), + route = useRoute(), + } = options + + const luigiContext = ref(null) + + if (isInIframe.value) { + logger.debug('Registering listener for Luigi context initialization and context updates') + LuigiClient.addInitListener(context => setLuigiContext(context)) + LuigiClient.addContextUpdateListener(context => setLuigiContext(context)) + const pathname = toRef(route, 'path') + watch(pathname, value => { + if (value) { + logger.debug('Navigating Luigi Client to path:', value) + LuigiClient.linkManager().fromVirtualTreeRoot().withoutSync().navigate(value) + } + }, { + immediate: true, + }) + } + + function setLuigiContext (value) { + luigiContext.value = value + } + + const accountId = computed(() => luigiContext.value?.accountId) + + async function getLuigiContext () { + if (!isInIframe.value) { + return null + } + if (luigiContext.value !== null) { + return luigiContext.value + } + const timeout = 3000 + try { + await until(luigiContext).toBeTruthy({ + timeout, + throwOnTimeout: true, + }) + return luigiContext.value + } catch (err) { + logger.error('The initialization of the Luigi Client has timed out after %d milliseconds', timeout) + return null + } + } + + return { + accountId, + luigiContext, + getLuigiContext, + } +}) diff --git a/frontend/src/composables/useProjectContext.js b/frontend/src/composables/useProjectContext.js index 68defbb22c..a1c4baab19 100644 --- a/frontend/src/composables/useProjectContext.js +++ b/frontend/src/composables/useProjectContext.js @@ -12,18 +12,27 @@ import { provide, } from 'vue' -import { cleanup } from '@/composables/helper' +import { useConfigStore } from '@/store/config' -import { useProjectShootCustomFields } from './useProjectShootCustomFields' -import { useProjectMetadata } from './useProjectMetadata' -import { useProjectCostObject } from './useProjectCostObject' +import { cleanup } from '@/composables/helper' +import { useOpenMFP } from '@/composables/useOpenMFP' +import { useProjectShootCustomFields } from '@/composables/useProjectShootCustomFields' +import { useProjectMetadata } from '@/composables/useProjectMetadata' +import { useProjectCostObject } from '@/composables/useProjectCostObject' import cloneDeep from 'lodash/cloneDeep' import get from 'lodash/get' import isEqual from 'lodash/isEqual' import set from 'lodash/set' -export function createProjectContextComposable () { +export function createProjectContextComposable (options = {}) { + const { + openMFP = useOpenMFP(), + configStore = useConfigStore(), + } = options + + const { accountId } = openMFP + function normalizeManifest (value) { const object = Object.assign({ apiVersion: 'core.gardener.cloud/v1beta1', @@ -55,6 +64,10 @@ export function createProjectContextComposable () { function createProjectManifest () { manifest.value = {} + if (accountId.value) { + set(manifest.value, ['metadata', 'label', 'openmfp.org/managed-by'], 'true') + set(manifest.value, ['metadata', 'annotations', 'openmfp.org/account-id'], accountId.value) + } initialManifest.value = cloneDeep(normalizedManifest.value) } @@ -107,10 +120,14 @@ export function createProjectContextComposable () { /* costObject */ const { + costObject, costObjectType, getCostObjectPatchDocument, - } = useProjectCostObject(manifest, { projectMetadataComposable }) + } = useProjectCostObject(manifest, { + configStore, + projectMetadataComposable, + }) return { /* manifest */ diff --git a/frontend/src/composables/useProjectMetadata/index.js b/frontend/src/composables/useProjectMetadata/index.js index 66ec00d2fb..4ef3f50a1c 100644 --- a/frontend/src/composables/useProjectMetadata/index.js +++ b/frontend/src/composables/useProjectMetadata/index.js @@ -15,7 +15,7 @@ import { annotations } from '@/utils/annotations.js' import { useObjectMetadata } from '../useObjectMetadata.js' -export const useProjectMetadata = (projectItem, options = {}) => { +export const useProjectMetadata = projectItem => { const { name: projectName, creationTimestamp: projectCreationTimestamp, diff --git a/frontend/src/layouts/GDefault.vue b/frontend/src/layouts/GDefault.vue index c88fc73192..c19ce4b366 100644 --- a/frontend/src/layouts/GDefault.vue +++ b/frontend/src/layouts/GDefault.vue @@ -17,8 +17,11 @@ SPDX-License-Identifier: Apache-2.0 - - + + + + + @@ -42,12 +45,15 @@ import GMainNavigation from '@/components/GMainNavigation.vue' import GMainToolbar from '@/components/GMainToolbar.vue' import GMainContent from '@/components/GMainContent.vue' import GNotify from '@/components/GNotify.vue' +import GBreadcrumbs from '@/components/GBreadcrumbs.vue' import { useLogger } from '@/composables/useLogger' +import { useIsInIframe } from '@/composables/useIsInIframe' import get from 'lodash/get' const logger = useLogger() +const isInIframe = useIsInIframe() const appStore = useAppStore() const authnStore = useAuthnStore() diff --git a/frontend/src/router/breadcrumbs.js b/frontend/src/router/breadcrumbs.js index 8f48413fa8..92e0ffeb47 100644 --- a/frontend/src/router/breadcrumbs.js +++ b/frontend/src/router/breadcrumbs.js @@ -31,6 +31,14 @@ export function accountBreadcrumbs () { ] } +export function projectsBreadcrumbs () { + return [ + { + title: 'Projects', + }, + ] +} + export function settingsBreadcrumbs () { return [ { diff --git a/frontend/src/router/guards.js b/frontend/src/router/guards.js index 5e395999bb..76fe438de0 100644 --- a/frontend/src/router/guards.js +++ b/frontend/src/router/guards.js @@ -18,10 +18,13 @@ import { useSeedStore } from '@/store/seed' import { useShootStore } from '@/store/shoot' import { useTerminalStore } from '@/store/terminal' +import { useOpenMFP } from '@/composables/useOpenMFP' import { useLogger } from '@/composables/useLogger' +import { useApi } from '@/composables/useApi' export function createGlobalBeforeGuards () { const logger = useLogger() + const api = useApi() const appStore = useAppStore() const authnStore = useAuthnStore() const configStore = useConfigStore() @@ -32,7 +35,7 @@ export function createGlobalBeforeGuards () { const kubeconfigStore = useKubeconfigStore() function ensureUserAuthenticatedForNonPublicRoutes () { - return to => { + return async to => { const { meta = {}, fullPath: redirectPath, @@ -48,6 +51,24 @@ export function createGlobalBeforeGuards () { return true } + const openMFP = useOpenMFP() + const context = await openMFP.getLuigiContext() + if (context) { + logger.debug('Luigi context:', context) + const token = context.token + if (token) { + try { + await api.createTokenReview({ token }) + authnStore.$reset() + if (!authnStore.isExpired()) { + return true + } + } catch (err) { + logger.error('Luigi token review error: %s', err.message) + } + } + } + const message = !authnStore.user ? 'User not found' : 'Session has expired' @@ -112,8 +133,10 @@ export function createGlobalResolveGuards () { } try { + const { accountId } = useOpenMFP() + const namespace = to.params.namespace ?? to.query.namespace - await refreshRules(authzStore, namespace) + await refreshRules(authzStore, namespace, accountId.value ?? to.query.accountId) if (namespace && namespace !== '_all' && !projectStore.namespaces.includes(namespace)) { authzStore.$reset() @@ -128,7 +151,7 @@ export function createGlobalResolveGuards () { switch (to.name) { case 'Home': case 'ProjectList': { - // no action required for redirect routes + await projectStore.fetchProjects() break } case 'Credentials': diff --git a/frontend/src/router/routes.js b/frontend/src/router/routes.js index 7a9f0fd326..db5258d0f6 100644 --- a/frontend/src/router/routes.js +++ b/frontend/src/router/routes.js @@ -16,6 +16,7 @@ import GDefault from '@/layouts/GDefault.vue' /* Views */ import GError from '@/views/GError.vue' import GNotFound from '@/views/GNotFound.vue' +import GProjectList from '@/views/GProjectList.vue' import GProjectPlaceholder from '@/views/GProjectPlaceholder.vue' import GNewShootPlaceholder from '@/views/GNewShootPlaceholder.vue' import GNewShootEditor from '@/views/GNewShootEditor.vue' @@ -24,9 +25,12 @@ import GShootItemEditor from '@/views/GShootItemEditor.vue' import GAccount from '@/views/GAccount.vue' import GSettings from '@/views/GSettings.vue' +import { useIsInIframe } from '@/composables/useIsInIframe' + import { homeBreadcrumbs, newProjectBreadcrumbs, + projectsBreadcrumbs, accountBreadcrumbs, settingsBreadcrumbs, shootListBreadcrumbs, @@ -204,7 +208,14 @@ export function createRoutes () { projectScope: false, breadcrumbs: homeBreadcrumbs, }, - beforeEnter: redirectToShootList, + beforeEnter () { + const isInIframe = useIsInIframe() + + return isInIframe.value + ? { name: 'ProjectList' } + : redirectToShootList() + }, + } } @@ -226,7 +237,13 @@ export function createRoutes () { return { path, name: 'ProjectList', - beforeEnter: redirectToShootList, + component: GProjectList, + alias: 'projects', + meta: { + namespaced: false, + projectScope: false, + breadcrumbs: projectsBreadcrumbs, + }, } } diff --git a/frontend/src/store/app.js b/frontend/src/store/app.js index 698d9d386b..a527217cd0 100644 --- a/frontend/src/store/app.js +++ b/frontend/src/store/app.js @@ -29,6 +29,7 @@ export const useAppStore = defineStore('app', () => { const splitpaneResize = ref(0) const fromRoute = ref(null) const routerError = ref(null) + const { notify } = useNotification() function updateSplitpaneResize () { diff --git a/frontend/src/store/authz.js b/frontend/src/store/authz.js index 17e546f8b3..d912e4b999 100644 --- a/frontend/src/store/authz.js +++ b/frontend/src/store/authz.js @@ -143,13 +143,16 @@ export const useAuthzStore = defineStore('authz', () => { }) // reuse function not exported - async function getRules (namespace) { - const body = { namespace } + async function getRules (namespace, accountId) { + const body = { + namespace, + accountId, + } const response = await api.getSubjectRules(body) status.value = response.data } - async function fetchRules (namespace) { + async function fetchRules (namespace, accountId) { /** * The value of `spec.value?.namespace` is: * - undefined if no rules have been fetched yet @@ -159,18 +162,22 @@ export const useAuthzStore = defineStore('authz', () => { if (!namespace) { namespace = null } - if (spec.value?.namespace !== namespace) { - await getRules(namespace) - this.setNamespace(namespace) + if ( + spec.value?.namespace !== namespace || + spec.value?.accountId !== accountId + ) { + await getRules(namespace, accountId) + spec.value = { namespace, accountId } } } function refreshRules () { - return getRules(spec.value?.namespace) + return getRules(spec.value?.namespace, spec.value?.accountId) } function setNamespace (namespace) { - spec.value = { namespace } + spec.value = spec.value || {} + spec.value.namespace = namespace } function $reset () { diff --git a/frontend/src/views/GAdministration.vue b/frontend/src/views/GAdministration.vue index 865ee69e4b..bb1745d6db 100644 --- a/frontend/src/views/GAdministration.vue +++ b/frontend/src/views/GAdministration.vue @@ -536,6 +536,7 @@ import GShootCustomFieldsConfiguration from '@/components/GShootCustomFieldsConf import GResourceQuotaHelp from '@/components/GResourceQuotaHelp.vue' import GTextRouterLink from '@/components/GTextRouterLink.vue' +import { useOpenMFP } from '@/composables/useOpenMFP' import { useProvideProjectItem } from '@/composables/useProjectItem' import { useProvideProjectContext } from '@/composables/useProjectContext' import { useLogger } from '@/composables/useLogger' @@ -549,10 +550,12 @@ import { import { errorDetailsFromError } from '@/utils/error' import { annotations } from '@/utils/annotations.js' -import includes from 'lodash/includes' +import get from 'lodash/get' import set from 'lodash/set' +import includes from 'lodash/includes' const logger = useLogger() +const openMFP = useOpenMFP() const appStore = useAppStore() const configStore = useConfigStore() const quotaStore = useQuotaStore() @@ -565,7 +568,12 @@ const kubeconfigStore = useKubeconfigStore() const route = useRoute() const router = useRouter() -useProvideProjectContext() +useProvideProjectContext({ + openMFP, + configStore, +}) + +const { accountId } = openMFP const color = ref('primary') const errorMessage = ref(undefined) @@ -671,6 +679,10 @@ async function updateProperty (path, value, options = {}) { metadata: { name }, spec: { namespace }, } + if (accountId.value && !get(projectStore.project, ['metadata', 'annotations', 'openmfp.org/account-id'])) { + set(mergePatchDocument, ['metadata', 'labels', 'openmfp.org/managed-by'], 'true') + set(mergePatchDocument, ['metadata', 'annotations', 'openmfp.org/account-id'], accountId.value) + } set(mergePatchDocument, path, value) await projectStore.patchProject(mergePatchDocument) } catch (err) { diff --git a/frontend/src/views/GProjectList.vue b/frontend/src/views/GProjectList.vue new file mode 100644 index 0000000000..9cab807ea0 --- /dev/null +++ b/frontend/src/views/GProjectList.vue @@ -0,0 +1,165 @@ + + + + + + + + Project List + + + + + + + Create Project + + + + + + + + + Name + + + Clusters + + + Owner + + + Created At + + + Description + + + Purpose + + + Phase + + + + + + + No projects available + + + + + + + + + {{ getNumberOfShoots(item) }} + + + + + + + + + + {{ item.spec.description ?? '-' }} + + + {{ item.spec.purpose ?? '-' }} + + + + {{ item.status.phase ?? '-' }} + + + + + + + + + + + diff --git a/frontend/src/views/GSettings.vue b/frontend/src/views/GSettings.vue index 5f478354b9..5ff5f784b9 100644 --- a/frontend/src/views/GSettings.vue +++ b/frontend/src/views/GSettings.vue @@ -24,7 +24,10 @@ SPDX-License-Identifier: Apache-2.0 - + Color Scheme @@ -166,12 +169,14 @@ SPDX-License-Identifier: Apache-2.0