diff --git a/.vscode/bookmarks.json b/.vscode/bookmarks.json new file mode 100644 index 0000000..cf0f66a --- /dev/null +++ b/.vscode/bookmarks.json @@ -0,0 +1,44 @@ +{ + "files": [ + { + "path": "node_modules/@backstage/plugin-catalog-backend/dist/cjs/CatalogBuilder-CGSl8LEN.cjs.js", + "bookmarks": [ + { + "line": 2369, + "column": 0, + "label": "FilterQueryBuilder" + }, + { + "line": 2452, + "column": 0, + "label": "DB Query execution" + }, + { + "line": 5559, + "column": 0, + "label": "Catalog entities router " + }, + { + "line": 7111, + "column": 0, + "label": "Catalog permission router" + }, + { + "line": 7170, + "column": 0, + "label": "CatalogRoute creation" + } + ] + }, + { + "path": "node_modules/@backstage/plugin-permission-node/dist/index.cjs.js", + "bookmarks": [ + { + "line": 26, + "column": 0, + "label": "Permission JSON creation fn" + } + ] + } + ] +} \ No newline at end of file diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2916455..b431f68 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -382,7 +382,11 @@ const routes = ( */} } + element={ + + + + } > {entityPage} diff --git a/packages/backend/src/extensions/catalogPermissionRules.ts b/packages/backend/src/extensions/catalogPermissionRules.ts new file mode 100644 index 0000000..2237e34 --- /dev/null +++ b/packages/backend/src/extensions/catalogPermissionRules.ts @@ -0,0 +1,16 @@ +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogPermissionExtensionPoint } from '@backstage/plugin-catalog-node/alpha'; +import { isHaveRepositoryAccess } from '../premissions/repository.rule'; + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'permission-rules', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogPermissionExtensionPoint }, + async init({ catalog }) { + catalog.addPermissionRules(isHaveRepositoryAccess); + }, + }); + }, +}); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index f9cce33..8913237 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -37,6 +37,7 @@ backend.add(import('@backstage/plugin-catalog-backend/alpha')); backend.add( import('@backstage/plugin-catalog-backend-module-scaffolder-entity-model'), ); +backend.add(import('./extensions/catalogPermissionRules')); // See https://backstage.io/docs/features/software-catalog/configuration#subscribing-to-catalog-errors backend.add(import('@backstage/plugin-catalog-backend-module-logs')); diff --git a/packages/backend/src/plugins/permission.ts b/packages/backend/src/plugins/permission.ts index 6a77d6c..7755374 100644 --- a/packages/backend/src/plugins/permission.ts +++ b/packages/backend/src/plugins/permission.ts @@ -3,14 +3,11 @@ import { isPermission, type PolicyDecision, } from '@backstage/plugin-permission-common'; -import type { - PermissionPolicy, - PolicyQuery, -} from '@backstage/plugin-permission-node'; import { - catalogConditions, - createCatalogConditionalDecision, -} from '@backstage/plugin-catalog-backend/alpha'; + type PermissionPolicy, + type PolicyQuery, +} from '@backstage/plugin-permission-node'; +import { createCatalogConditionalDecision } from '@backstage/plugin-catalog-backend/alpha'; import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/alpha'; import type { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import type { PaginatingEndpoints } from '@octokit/plugin-paginate-rest'; @@ -26,6 +23,7 @@ import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha'; import { coreServices } from '@backstage/backend-plugin-api'; import { Octokit } from 'octokit'; import * as _ from 'lodash'; +import { RepositoryAccessCondition as repositoryAccessCondition } from '../premissions/repository.rule'; class RequestPermissionPolicy implements PermissionPolicy { readonly orgRepositories: Promise< @@ -73,7 +71,7 @@ class RequestPermissionPolicy implements PermissionPolicy { } this.logger.info("didn't received any handler for the policy request", { - request, + request: JSON.stringify(request, null, 2), }); return { result: AuthorizeResult.ALLOW }; } @@ -89,9 +87,9 @@ class RequestPermissionPolicy implements PermissionPolicy { org: String(process.env.GITHUB_ORGANIZATION), }, )) { - //! will use status or header to validate success out.push(...data); } + this.logger.info('***GithubRequest GET /orgs/{org}/repos', {}); this.logger.debug('Github Repo List resolution benchmark', { totalTimeInMilliSeconds: startTimeBenchmark - performance.now(), }); @@ -113,6 +111,10 @@ class RequestPermissionPolicy implements PermissionPolicy { Authorization: `Bearer ${token}`, }, }); + this.logger.info( + '***CatalogRequest GET /entities?filter=kind=component', + {}, + ); const data = await req.json(); // Get Name a repo name not the repo full_name return new Set( @@ -120,6 +122,40 @@ class RequestPermissionPolicy implements PermissionPolicy { ); } + protected async fetchUserRole(userEntityRef: string) { + const { name: username } = parseEntityRef(userEntityRef); + + const response = await this.octokit + .request('GET /orgs/{org}/teams/{team_slug}/memberships/{username}', { + org: String(process.env.GITHUB_ORGANIZATION), + team_slug: String(process.env.REPO_CREATOR_TEAM), + username, + }) + .catch(e => { + this.logger.debug('Issue while fetching details for the username', { + error: JSON.stringify(e), + }); + }); + + this.logger.info( + '***GithubRequest GET /orgs/{org}/teams/{team_slug}/memberships/{username}', + { + org: String(process.env.GITHUB_ORGANIZATION), + team_slug: String(process.env.REPO_CREATOR_TEAM), + }, + ); + const ok = 200; + if ( + response && + response.status === ok && + response.data.state === 'active' + ) { + return response.data.role ?? 'null'; + } + + return 'null'; + } + protected async resolveAuthorizedRepoList( userEntityRef: string, ): Promise { @@ -141,24 +177,42 @@ class RequestPermissionPolicy implements PermissionPolicy { this.userRepoPermissions[userEntityRef].push(repo.name); } - for (const repos of _.chunk(privateCatalogRepos, 10)) { - const permissions = await Promise.all( - repos.map(repo => - this.octokit.rest.repos - .getCollaboratorPermissionLevel({ - owner: String(process.env.GITHUB_ORGANIZATION), - repo: repo.name, - username: usernameEntity.name, - }) - .then(resp => ({ - repo, - ...resp.data, - })), - ), - ); + const userRole = await this.fetchUserRole(userEntityRef); - for (const permission of permissions) { - this.userRepoPermissions[userEntityRef].push(permission.repo.name); + if (['member', 'admin', 'maintainer'].includes(userRole)) { + for (const repos of privateCatalogRepos) { + this.userRepoPermissions[userEntityRef].push(repos.name); + } + } else { + // will fetch individual repo permissions + for (const repos of _.chunk(privateCatalogRepos, 10)) { + const permissions = await Promise.all( + repos.map(repo => + this.octokit.rest.repos + .getCollaboratorPermissionLevel({ + owner: String(process.env.GITHUB_ORGANIZATION), + repo: repo.name, + username: usernameEntity.name, + }) + .then(resp => ({ + repo, + ...resp.data, + })) + .finally(() => { + this.logger.info( + '***GithubRequest GET /repos/{owner}/{repo}/collaborators/{username}/permission', + { + owner: String(process.env.GITHUB_ORGANIZATION), + repo: repo.name, + }, + ); + }), + ), + ); + + for (const permission of permissions) { + this.userRepoPermissions[userEntityRef].push(permission.repo.name); + } } } @@ -179,8 +233,15 @@ class RequestPermissionPolicy implements PermissionPolicy { user.identity.userEntityRef, ); - if (!userPermission) { + if (!userPermission?.length) { // permission not resolved from the Github API + this.logger.info( + "Not able to fetch user Permission or Github didn't have repos for the user", + { + user: user.identity.userEntityRef, + resolvedPermission: JSON.stringify(userPermission), + }, + ); return { result: AuthorizeResult.DENY }; } this.logger.debug('Permission resolution benchmark', { @@ -189,13 +250,11 @@ class RequestPermissionPolicy implements PermissionPolicy { this.logger.debug('Permission resolution benchmark', { totalTimeInMilliSeconds: startTimeBenchmark - performance.now(), }); - return createCatalogConditionalDecision(request.permission, { - //@ts-ignore - anyOf: userPermission.map(value => - //@ts-ignore - catalogConditions.hasMetadata({ key: 'name', value }), - ), - }); + const condition = createCatalogConditionalDecision( + request.permission, + repositoryAccessCondition({ repos: userPermission }), + ); + return condition; } } @@ -214,6 +273,8 @@ export default createBackendModule({ async init({ policy, cache, discovery, auth, logger }) { const octokit = new Octokit({ auth: String(process.env.GITHUB_TOKEN), + // in case of custom option for throttle follow below + // https://github.com/octokit/plugin-throttling.js/#readme // throttle: {}, }); policy.setPolicy( diff --git a/packages/backend/src/premissions/repository.rule.ts b/packages/backend/src/premissions/repository.rule.ts new file mode 100644 index 0000000..03f38c9 --- /dev/null +++ b/packages/backend/src/premissions/repository.rule.ts @@ -0,0 +1,42 @@ +import type { Entity } from '@backstage/catalog-model'; +import { z } from 'zod'; +import { createCatalogPermissionRule } from '@backstage/plugin-catalog-backend/alpha'; +import { createConditionFactory } from '@backstage/plugin-permission-node'; + +export const isHaveRepositoryAccess = createCatalogPermissionRule({ + name: 'IS_HAVE_REPO_ACCESS', + description: 'Checks if entity have repository access', + resourceType: 'catalog-entity', + paramsSchema: z.object({ + repos: z.string().array().describe('name of repositories to check'), + }), + apply: (resource: Entity, { repos }) => { + if (!resource.relations) { + return false; + } + + return resource.relations + .filter(relation => relation.type === 'partOf') + .some(relation => repos.includes(relation.targetRef)); + }, + toQuery: ({ repos }) => { + // it will add sql query to check repo or all the templates + // "metadata.name" in ["repoName"] or kind in ['template'] + return { + anyOf: [ + { + key: 'metadata.name', + values: repos, + }, + { + // Skip from the check it will allow all the templates + key: 'kind', + values: ['template'], + }, + ], + }; + }, +}); +export const RepositoryAccessCondition = createConditionFactory( + isHaveRepositoryAccess, +); diff --git a/plugins/jenkins-with-reporting-backend-backend/src/service/router.ts b/plugins/jenkins-with-reporting-backend-backend/src/service/router.ts index 26dacc2..ff1acb5 100644 --- a/plugins/jenkins-with-reporting-backend-backend/src/service/router.ts +++ b/plugins/jenkins-with-reporting-backend-backend/src/service/router.ts @@ -155,6 +155,7 @@ export async function createRouter( }, ); + const middleware = MiddlewareFactory.create({ logger, config }); router.use(middleware.error()); diff --git a/plugins/jenkins-with-reporting/src/api/JenkinsApi.ts b/plugins/jenkins-with-reporting/src/api/JenkinsApi.ts index 0118391..2ec66ef 100644 --- a/plugins/jenkins-with-reporting/src/api/JenkinsApi.ts +++ b/plugins/jenkins-with-reporting/src/api/JenkinsApi.ts @@ -164,6 +164,7 @@ export class JenkinsClient implements JenkinsApi { const url = new URL( `${await this.discoveryApi.getBaseUrl( 'jenkins-with-reporting-backend', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( entity.kind, )}/${encodeURIComponent(entity.name)}/projects`,