diff --git a/cypress/e2e/specs/spec_renderer.spec.ts b/cypress/e2e/specs/spec_renderer.spec.ts index 2f410406..2b401fcc 100644 --- a/cypress/e2e/specs/spec_renderer.spec.ts +++ b/cypress/e2e/specs/spec_renderer.spec.ts @@ -1,3 +1,4 @@ +import { ProductActionsResponse } from '@kong/sdk-portal-js' import { product, versions } from '../fixtures/consts' import petstoreJson from '../fixtures/oas_specs/petstoreJson.json' import petstoreJson3 from '../fixtures/oas_specs/petstoreJson3.0.json' @@ -330,18 +331,23 @@ describe('Spec Renderer Page', () => { rbac_enabled: true }).as('getPortalContext') - cy.intercept('GET', 'api/v2/portals/*/developers/me/permissions', { + const response: ProductActionsResponse = { + actions: { + register: false, + view: false, + view_documentation: false + } + } + + cy.intercept('GET', '/api/v2/products/*/actions', { statusCode: 200, - body: [{ - resource: 'krn:konnect:reg/*:org/*:portals/*/services/*', - actions: [] - }], + body: response, delay: 300 - }).as('getPermissions') + }).as('getProductActions') cy.visit(`/spec/${product.id}`) - cy.wait('@getPermissions') + cy.wait('@getProductActions') cy.get('[data-testid="forbidden"]').should('exist') }) @@ -351,21 +357,23 @@ describe('Spec Renderer Page', () => { rbac_enabled: true }).as('getPortalContext') - cy.intercept('GET', 'api/v2/portals/*/developers/me/permissions', { + const response: ProductActionsResponse = { + actions: { + register: true, + view: true, + view_documentation: true + } + } + + cy.intercept('GET', '/api/v2/products/*/actions', { statusCode: 200, - body: [{ - resource: 'krn:konnect:reg/*:org/*:portals/*/services/*', - actions: [ - '#view', - '#consume' - ] - }], + body: response, delay: 300 - }).as('getPermissions') + }).as('getProductActions') cy.visit(`/spec/${product.id}`) - cy.wait('@getPermissions') + cy.wait('@getProductActions') cy.get('[data-testid="kong-public-ui-spec-details-swagger"]', { timeout: 12000 }) .get('.info h2').should('contain', 'Swagger Petstore') @@ -373,17 +381,17 @@ describe('Spec Renderer Page', () => { cy.get('[data-testid="register-button"]').should('exist') }) - it('does not call developers/me/permissions if rbac not enabled', () => { + it('does not retrieve product actions if rbac not enabled', () => { cy.intercept('GET', '**/api/v2/portal', { rbac_enabled: false }).as('getPortalContext') - cy.intercept('get', 'api/v2/portals/*/developers/me/permissions', cy.spy().as('apiNotCalled')) + cy.intercept('get', '/api/v2/products/*/actions', cy.spy().as('apiNotCalled')) cy.visit(`/spec/${product.id}`) cy.get('[data-testid="kong-public-ui-spec-details-swagger"]', { timeout: 12000 }) - .get('.info h2').should('contain', 'Swagger Petstore') + .get('.info h2').should('contain', 'Swagger Petstore') cy.get('[data-testid="register-button"]').should('exist') @@ -405,12 +413,12 @@ describe('Spec Renderer Page', () => { cy.mockAppearance() }) - it('allows seeing spec when portal is public and rbac enabled, does not call developers/me/permissions', () => { + it('allows seeing spec when portal is public and rbac enabled, does not retrieve product actions', () => { cy.intercept('GET', '**/portal_api/portal/portal_context', { rbac_enabled: true }).as('getPortalContext') - cy.intercept('get', 'api/v2/portals/*/developers/me/permissions', cy.spy().as('apiNotCalled')) + cy.intercept('get', '/api/v2/products/*/actions', cy.spy().as('apiNotCalled')) cy.visit(`/spec/${product.id}`) diff --git a/package.json b/package.json index 65b12c44..8de3a691 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "@kong-ui-public/document-viewer": "0.10.5", "@kong-ui-public/spec-renderer": "0.13.1", "@kong/kong-auth-elements": "2.8.0", - "@kong/kongponents": "8.123.3", + "@kong/kongponents": "8.126.1", "@kong/sdk-portal-js": "2.3.6", "@xstate/vue": "2.0.0", "axios": "1.6.0", diff --git a/src/components/AuthValidate.vue b/src/components/AuthValidate.vue deleted file mode 100644 index 90781396..00000000 --- a/src/components/AuthValidate.vue +++ /dev/null @@ -1,91 +0,0 @@ - diff --git a/src/helpers/permissions.ts b/src/helpers/permissions.ts deleted file mode 100644 index 23eb0184..00000000 --- a/src/helpers/permissions.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Determine if the resource path from a KRN from the API matches a requested resource path guard. Substitutes UUID for `*` matches. - * @param {ParsedKrn} parsedKrn A parsed krn (from the API) - * @param {string} requestedResourcePath The resource path being accessed - * @returns {boolean} - */ -export const resourcePathMatches = (parsedKrn, requestedResourcePath: string) => { - const parsedKrnResourcePathArray = parsedKrn?.resourcePath?.split('/') - const requestedResourcePathArray = requestedResourcePath?.split('/') - - // If the krns do not have the same path, exit early - if (parsedKrnResourcePathArray?.length !== requestedResourcePathArray?.length) { - return false - } - - const pathsMatch = [] - - requestedResourcePathArray?.forEach((requestedPathValue, index) => { - // Set the pathMatches to false by default - let pathMatches = false - - // If odd index, requestedPathValue is UUID or wildcard '*' - if (index % 2) { - pathMatches = requestedPathValue === parsedKrnResourcePathArray[index] || parsedKrnResourcePathArray[index] === '*' && requestedPathValue !== '' - } else { - // If index is even number, requestedPathValue is the static entity, e.g. 'services', 'runtimegroups' - pathMatches = requestedPathValue === parsedKrnResourcePathArray[index] - } - - // Push boolean (true/false) depending on if either globMatches and entityMatches === true - pathsMatch.push(pathMatches) - }) - - return pathsMatch?.every(matchFromPath => matchFromPath === true) || false -} - -/** - * @description Does the krnArg include the required properties and have a valid resource path - * @param {(RequestedPermissionKrn|RequestedPermissionDictionary)} krnArg The object to validate - * @return {*} {boolean} - */ - -export const krnArgIsValid = (krnArg) => { - // If all object properties are valid krn args, and required properties are set - // and ensure args.resourcePath does not include invalid characters in the path - return objectIsKrnArg(krnArg) && krnResourcePathIsValid(krnArg.resourcePath) -} - -/** - * @description Returns true if the potentialKrnArgs object is of type RequestedPermissionKrn and not a dictionary - * @param {(RequestedPermissionKrn|RequestedPermissionDictionary)} potentialKrnArgs The object to validate - * @return {*} {boolean} Is the object a single krn arg, rather than a dictionary - */ - -export const objectIsKrnArg = (potentialKrnArgs) => { - const keys = Object.keys(potentialKrnArgs) - const values = Object.values(potentialKrnArgs) - - return keys.every( - (key) => key === 'service' || key === 'action' || key === 'resourcePath' - ) && - values.every(value => !!value) -} - -/** - * @description Is the krn resourcePath (if present) valid (doesn't contain any restricted characters) - * @param {string} [resourcePath] The krnArg resource path - * @return {*} {boolean} - */ - -export const krnResourcePathIsValid = (resourcePath) => { - if (resourcePath.includes('}') || resourcePath.includes('{')) { - // Log error to help developer find invalid array - console.error(`Invalid krn resourcePath value: ${resourcePath}`) - - return false - } - - return true -} diff --git a/src/router/index.ts b/src/router/index.ts index e03caf1c..04823a22 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -87,10 +87,9 @@ export const portalRouter = () => { name: 'spec', meta: { title: helpText.specTitle, - isAuthorized: (route, { portalId }) => canUserAccess({ - service: 'konnect', - action: '#view', - resourcePath: `portals/${portalId}/services/${route.params.product}` + isAuthorized: (route) => canUserAccess({ + action: 'view', + productId: route.params.product }) }, component: () => import('../views/Spec.vue') @@ -100,10 +99,9 @@ export const portalRouter = () => { name: 'api-documentation-page', meta: { title: helpText.docsTitle, - isAuthorized: (route, { portalId }) => canUserAccess({ - service: 'konnect', - action: '#view', - resourcePath: `portals/${portalId}/services/${route.params.product}` + isAuthorized: (route) => canUserAccess({ + action: 'view', + productId: route.params.product }) }, component: () => import('../views/ApiDocumentationPage.vue') diff --git a/src/router/route-utils.ts b/src/router/route-utils.ts index 8ef25bc3..09a0ee80 100644 --- a/src/router/route-utils.ts +++ b/src/router/route-utils.ts @@ -1,5 +1,5 @@ import useLDFeatureFlag from '@/hooks/useLDFeatureFlag' -import { usePermissionsStore } from '@/stores' +import { ProductAction, usePermissionsStore } from '@/stores' export const AUTH_ROUTES = { login: true, @@ -18,7 +18,7 @@ export const PRIVATE_ROUTES = { ...AUTH_ROUTES } -export function canUserAccess (krnArgs) { +export function canUserAccess (krnArgs: { action: ProductAction; productId: string }) { const { canUserAccess } = usePermissionsStore() return canUserAccess(krnArgs) diff --git a/src/services/SessionCookie.ts b/src/services/SessionCookie.ts index d16336f1..b42f723c 100644 --- a/src/services/SessionCookie.ts +++ b/src/services/SessionCookie.ts @@ -1,6 +1,4 @@ import { authApi } from '@/services' -import { usePermissionsStore, useAppStore } from '@/stores' -import { storeToRefs } from 'pinia' /** * @typedef {Object} SessionUser @@ -76,10 +74,6 @@ export default class SessionCookie { } async saveData (data: Record, force = true) { - const appStore = useAppStore() - const permissionsStore = usePermissionsStore() - const { portalId, isRbacEnabled, isPublic } = storeToRefs(appStore) - this.data = data const sessionExists = this.exists() @@ -88,25 +82,6 @@ export default class SessionCookie { if (force || (!force && !sessionExists)) { localStorage.setItem(this.sessionName, this.encode(this.data)) } - - if (sessionExists && !isPublic.value && isRbacEnabled.value) { - try { - const { data: developerPermissions } = await authApi.client.get(`/api/v2/portals/${portalId.value}/developers/me/permissions`) - - // response can be a JSON (object) or string - // when permissions feature flag is not enabled, string with HTTP 200 is returned - if (typeof developerPermissions === 'object') { - // Add permission krns to the store - await permissionsStore.addKrns({ - krns: developerPermissions, - replaceAll: true - }) - } - } catch (e) { - // eslint-disable-next-line no-console - console.error('Failed to fetch permissions', e) - } - } } getUser () { diff --git a/src/stores/permissions.ts b/src/stores/permissions.ts index b83166f1..4ddbb9f9 100644 --- a/src/stores/permissions.ts +++ b/src/stores/permissions.ts @@ -1,134 +1,28 @@ import { defineStore, storeToRefs } from 'pinia' -import { ref } from 'vue' -import { resourcePathMatches, krnArgIsValid } from '@/helpers/permissions' import { useAppStore } from '@/stores' +import { portalApiV2 } from '@/services' +import { ProductActionsResponseActions } from '@kong/sdk-portal-js' -const KRN_STRUCT = { - prefix: 'krn', - regionBlockPrefix: 'reg/', - orgBlockPrefix: 'org/', - blockDelimeter: ':', - pathDelimeter: '/' -} +export type ProductAction = keyof ProductActionsResponseActions export const usePermissionsStore = defineStore('permissions', () => { - const krns = ref([]) - - const addKrns = (payload) => { - // If replaceAll is true, first clear all stored krns - if (payload.replaceAll === true) { - krns.value = [] - } - - // Store new krns - krns.value = [...new Set([...krns.value, ...payload.krns].map(krn => JSON.stringify(krn)))].map(krn => JSON.parse(krn)) - } - - const parseKrn = (krnResource) => { - const parsedKrn = { - service: null, - region: null, - organization: null, - resourcePath: null - } - - // If not a valid krn, exit early - if (!krnResource.startsWith(`${KRN_STRUCT.prefix}${KRN_STRUCT.blockDelimeter}`)) { - console.error('parseKrn: Invalid KRN prefix') - - return null - } - - const krnBlocks = krnResource.split(KRN_STRUCT.blockDelimeter) - - const [serviceBlock, regionBlock, orgBlock, resourceBlock] = krnBlocks.slice(1) - - // length of 4 gives: krn block, service block, geo block, and org block - if (krnBlocks.length < 4) { - console.error('parseKrn: Invalid number of KRN blocks') - - return null - } - - parsedKrn.service = serviceBlock - parsedKrn.region = regionBlock.replace(KRN_STRUCT.regionBlockPrefix, '') - parsedKrn.organization = orgBlock.replace(KRN_STRUCT.orgBlockPrefix, '') - - if (resourceBlock !== undefined) { - parsedKrn.resourcePath = resourceBlock - } - - return parsedKrn - } - - const canUserAccess = async (requestedPermission) => { + const canUserAccess = async (requestedPermission: { action: ProductAction; productId: string }) => { const appStore = useAppStore() - const { portalId, isRbacEnabled, isPublic } = storeToRefs(appStore) + const { isRbacEnabled, isPublic } = storeToRefs(appStore) if (isPublic.value || !isRbacEnabled.value) { return true } - // If object is invalid KRN exist early - if (!krnArgIsValid(requestedPermission)) { - return false - } - - let requestedResourcePath - - const { service: requestedService, action, resourcePath } = requestedPermission - - // Block below is to simplify usage of `canUserAccess` by allowing to omit - // first part which is static and contains always `portals/` - if (resourcePath.startsWith('portals')) { - // full path provided, omit adding prefix `portals/` - requestedResourcePath = resourcePath - } else { - // simplify path - adding prefix `portals/` - requestedResourcePath = `portals/${portalId.value}/${resourcePath}` - } - - // If set, ensure the requestedResourcePath does not include an `undefined` string - // An `undefined` string is expected when evaluating the route guards in some scenarios as - // provided route params will not be set at initial evaluation (e.g. when the Sidebar evaluates - // permissions on app hydration); however, the params _will_ be present and valid in the beforeEach hook. - if (requestedResourcePath.includes('/undefined')) { - // Return false since this is an invalid resourcePath - return false - } - - // Ensure action starts with a hash '#' - const requestedAction = action && action.startsWith('#') ? action : `#${action}` - - // Check if any krns exist in the store that match the requestedService and requestedResourcePath - let matchingResources = krns.value.filter((krn) => { - const parsedKrn = parseKrn(krn.resource) - - // If the requested service does not equal the krn service, exit early - if (requestedService !== parsedKrn.service) { - return false - } - - return resourcePathMatches(parsedKrn, requestedResourcePath) - }) - - // If the requestedService is defined and the user has no matching resource paths - // we return false as we fetch all of permissions for now and we do not need to refetch - // permissions if requested one is not a part of ours. - // Might change in the fututre - if (matchingResources.length === 0) { - return false - } + const { action, productId } = requestedPermission - // Filter the resources with matching resourcePath by the requested action(s) - matchingResources = matchingResources.filter((krn) => krn.actions.some(action => !!requestedAction && requestedAction === action)) + const { data } = await portalApiV2.service.productsApi.getProductActions({ productId }) - // Return true if the matchingResources contains an allowed action - return matchingResources.length > 0 + // make sure the requested action is true on the response + return data.actions[action] } return { - addKrns, canUserAccess } }) diff --git a/src/views/Spec.vue b/src/views/Spec.vue index 814779d0..56ff44d3 100644 --- a/src/views/Spec.vue +++ b/src/views/Spec.vue @@ -115,7 +115,7 @@ export default defineComponent({ ] const applicationRegistrationEnabled = computed(() => { - return currentVersion.value.registration_configs?.length && isAllowedToRegister.value + return Boolean(currentVersion.value.registration_configs?.length && isAllowedToRegister.value) }) const helpText = useI18nStore().state.helpText @@ -161,11 +161,14 @@ export default defineComponent({ } }) - watch(() => props.product, async () => { + watch(() => props.product, async (newProduct, oldProduct) => { + if (newProduct?.id === oldProduct?.id) { + return + } + isAllowedToRegister.value = await canUserAccess({ - service: 'konnect', - action: '#consume', - resourcePath: `services/${$route.params.product}` + action: 'register', + productId: $route.params.product.toString() }) await processProduct() @@ -186,12 +189,11 @@ export default defineComponent({ } }) - watch(() => $route.params.product_version, async (productVersionId) => { - if (productVersionId) { + watch(() => $route.params.product_version, async (productVersionId, oldValue) => { + if (productVersionId && (oldValue !== productVersionId)) { isAllowedToRegister.value = await canUserAccess({ - service: 'konnect', - action: '#consume', - resourcePath: `services/${$route.params.product}` + action: 'register', + productId: $route.params.product.toString() }) // this is not called on page load, but will be called when back button clicked and on select @@ -202,9 +204,8 @@ export default defineComponent({ onMounted(async () => { isAllowedToRegister.value = await canUserAccess({ - service: 'konnect', - action: '#consume', - resourcePath: `services/${$route.params.product}` + action: 'register', + productId: $route.params.product.toString() }) await processProduct() diff --git a/yarn.lock b/yarn.lock index e74ea6be..e8500c6d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1045,7 +1045,23 @@ vue-recaptcha "^2.0.3" xstate "^4.38.2" -"@kong/kongponents@8.123.3", "@kong/kongponents@^8.32.0": +"@kong/kongponents@8.126.1": + version "8.126.1" + resolved "https://registry.yarnpkg.com/@kong/kongponents/-/kongponents-8.126.1.tgz#f5f68e534a38df68c21c231c2e8ff728fbd2bd07" + integrity sha512-8GdHy+/pcALzabuTplnZZSsAnb8rBZhaMAx4nADfqrt6uPvfDHI6AQfLZg6Fc+D3CPjo9q2PL+o1kf2a5nuYLg== + dependencies: + date-fns "^2.30.0" + date-fns-tz "^2.0.0" + focus-trap "^7.5.2" + focus-trap-vue "^4.0.2" + popper.js "^1.16.1" + sortablejs "^1.15.0" + swrv "^1.0.4" + uuid "^9.0.0" + v-calendar "3.0.0-alpha.8" + vue-draggable-next "^2.2.1" + +"@kong/kongponents@^8.32.0": version "8.123.3" resolved "https://registry.yarnpkg.com/@kong/kongponents/-/kongponents-8.123.3.tgz#038f5019f958d4a5da0db57a7b84dc1ab90b8212" integrity sha512-nE0q69Imr5C5Q3rOW5mu+5h18Pi8WmnA1+s+yZNHlC5xvy3zSPo/xbpTfBqkk/VmQiu2oXqtQMMmxgtFoKF0Tg== @@ -1407,6 +1423,11 @@ "@pnpm/network.ca-file" "^1.0.1" config-chain "^1.1.11" +"@popperjs/core@2.4.0": + version "2.4.0" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.4.0.tgz#0e1bdf8d021e7ea58affade33d9d607e11365915" + integrity sha512-NMrDy6EWh9TPdSRiHmHH2ye1v5U0gBD7pRYwSwJvomx7Bm4GG04vu63dYiVzebLOx2obPpJugew06xVP0Nk7hA== + "@semantic-release/changelog@6.0.3": version "6.0.3" resolved "https://registry.yarnpkg.com/@semantic-release/changelog/-/changelog-6.0.3.tgz#6195630ecbeccad174461de727d5f975abc23eeb" @@ -8953,7 +8974,7 @@ pluralize@^8.0.0: resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1" integrity sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA== -popper.js@^1.15.0: +popper.js@^1.15.0, popper.js@^1.16.1: version "1.16.1" resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b" integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ== @@ -11383,6 +11404,17 @@ uuid@^9.0.0, uuid@^9.0.1: resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== +v-calendar@3.0.0-alpha.8: + version "3.0.0-alpha.8" + resolved "https://registry.yarnpkg.com/v-calendar/-/v-calendar-3.0.0-alpha.8.tgz#3bc8c69f4788fb527c39706f41fd2a502a17c827" + integrity sha512-T23H5UbK0EomrwArlF/jrT2LFbV/lu+Bp9JroZ1paN6rPoaMyvE+HrLxvAmUgi+pODrdTURDMzM3+WPgeFKEBQ== + dependencies: + "@popperjs/core" "2.4.0" + "@types/lodash" "^4.14.165" + date-fns "^2.16.1" + date-fns-tz "^1.0.12" + lodash "^4.17.20" + v-calendar@^3.0.0-alpha.8: version "3.0.3" resolved "https://registry.yarnpkg.com/v-calendar/-/v-calendar-3.0.3.tgz#f04c625d3c3352d5685099bb4ad3e24d0530a7d4" @@ -11468,6 +11500,11 @@ vue-draggable-next@^2.1.1: resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.2.0.tgz#cdefc345f950d64afb013436bf3111c7d87239f3" integrity sha512-JQ7Ac4knnpsA47/acUhRR7negDHNZDLdpbXRR+n89f516rJDt+eHh48tfqTe80q2UfnLymQ46zi81gCMKFU4DQ== +vue-draggable-next@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/vue-draggable-next/-/vue-draggable-next-2.2.1.tgz#adbe98c74610cca8f4eb63f92042681f96920451" + integrity sha512-EAMS1IRHF0kZO0o5PMOinsQsXIqsrKT1hKmbICxG3UEtn7zLFkLxlAtajcCcUTisNvQ6TtCB5COjD9a1raNADw== + vue-eslint-parser@^9.3.1: version "9.3.1" resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.3.1.tgz#429955e041ae5371df5f9e37ebc29ba046496182"