diff --git a/.dockerignore b/.dockerignore index cf5b80a..da554c4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,5 @@ node_modules #packages !packages/backend/dist -plugins .env.local* *.pem diff --git a/Dockerfile b/Dockerfile index fceb4bd..b60f3c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ COPY package.json yarn.lock ./ COPY packages packages COPY patches patches # Comment this out if you don't have any internal plugins -#COPY plugins plugins +COPY plugins plugins RUN find packages \! -name "package.json" -mindepth 2 -maxdepth 2 -exec rm -rf {} \+ @@ -51,8 +51,10 @@ COPY --from=build /app/yarn.lock /app/package.json /app/packages/backend/dist/sk RUN tar xzf skeleton.tar.gz && rm skeleton.tar.gz COPY --from=packages /app . +COPY ./plugins ./plugins RUN yarn install --network-timeout 600000 && rm -rf "$(yarn cache dir)" COPY ./patches ./patches + RUN yarn run postinstall # Copy the built packages from the build stage diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 3055213..918cbb9 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -8,6 +8,7 @@ import { ThemeProvider } from '@material-ui/core/styles'; import { BackstageOverrides } from '@backstage/core-components'; import loginBg from './assets/images/login-bg.jpg'; import sfLogoMinimal from './assets/images/sf-minimal-logo.png'; +import { PermissionWrapper } from './PermissionWrapper'; import { CatalogEntityPage, @@ -393,6 +394,14 @@ const routes = ( } /> + + + + } + /> } /> { // NOSONAR + const hasPermission = usePermissionCheck(permission as string); // NOSONAR + + if (hasPermission === null) { + return
Loading...
; // Render a loading state while checking permissions + } + + if (!hasPermission) { + return
Permission Denied......
; // Render a "Permission Denied" message + } + + return children; +}; diff --git a/packages/app/src/usePermissionCheck.tsx b/packages/app/src/usePermissionCheck.tsx new file mode 100644 index 0000000..f0d09c7 --- /dev/null +++ b/packages/app/src/usePermissionCheck.tsx @@ -0,0 +1,41 @@ +// usePermissionCheck.js + +import { useEffect, useState } from 'react'; +import { + useApi, + identityApiRef, + configApiRef, +} from '@backstage/core-plugin-api'; + +export const usePermissionCheck = (permission: string) => { + const config = useApi(configApiRef); + const [hasPermission, setHasPermission] = useState(null); + const identityApi = useApi(identityApiRef); + + useEffect(() => { + const checkPermission = async () => { + const authDetail = await identityApi.getCredentials(); + const backendUrl = config.getOptionalString('backend.baseUrl'); + const response = await fetch( + `${backendUrl}/api/validate-access/validateuser`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + authorization: authDetail.token as string, + }, + }, + ); + if (response.ok) { + const { allowed } = await response.json(); + setHasPermission(allowed); + } else { + setHasPermission(null); + } + }; + + checkPermission(); + }, [config, identityApi, permission]); + + return hasPermission; +}; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index c4736a5..a170f5b 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -31,6 +31,7 @@ import search from './plugins/search'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; +import validateAccessPlugin from '@internal/plugin-validate-access-backend'; function makeCreateEnv(config: Config) { const root = getRootLogger(); @@ -85,6 +86,9 @@ async function main() { const techdocsEnv = useHotMemoize(module, () => createEnv('techdocs')); const searchEnv = useHotMemoize(module, () => createEnv('search')); const appEnv = useHotMemoize(module, () => createEnv('app')); + const validateAccessPluginEnv = useHotMemoize(module, () => + createEnv('validateAccessPlugin'), + ); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -93,6 +97,10 @@ async function main() { apiRouter.use('/techdocs', await techdocs(techdocsEnv)); apiRouter.use('/proxy', await proxy(proxyEnv)); apiRouter.use('/search', await search(searchEnv)); + apiRouter.use( + '/validate-access', + await validateAccessPlugin(validateAccessPluginEnv), + ); // Add backends ABOVE this line; this 404 handler is the catch-all fallback apiRouter.use(notFoundHandler()); diff --git a/plugins/validate-access-backend/.eslintrc.js b/plugins/validate-access-backend/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/plugins/validate-access-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/validate-access-backend/README.md b/plugins/validate-access-backend/README.md new file mode 100644 index 0000000..2106379 --- /dev/null +++ b/plugins/validate-access-backend/README.md @@ -0,0 +1,15 @@ +# validate-access + +Welcome to the validate-access backend plugin! + +_This plugin was created through the Backstage CLI_ +This plugin used to validate users access. Ex: To check if the user is part of the team who can create, manage, delete github repos. + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn +start` in the root directory, and then navigating to [/validate-access](http://localhost:3000/validate-access). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. diff --git a/plugins/validate-access-backend/package.json b/plugins/validate-access-backend/package.json new file mode 100644 index 0000000..53a370e --- /dev/null +++ b/plugins/validate-access-backend/package.json @@ -0,0 +1,51 @@ +{ + "name": "@internal/plugin-validate-access-backend", + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "^0.18.5", + "@backstage/backend-plugin-api": "^0.6.18", + "@backstage/backend-tasks": "^0.5.23", + "@backstage/config": "^1.0.7", + "@backstage/core-plugin-api": "^1.9.2", + "@backstage/plugin-auth-node": "^0.4.13", + "@backstage/plugin-permission-common": "^0.7.13", + "@types/express": "*", + "axios": "^1.7.2", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "jose": "^5.3.0", + "node-fetch": "^2.6.7", + "winston": "^3.2.1", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.22.7", + "@types/supertest": "^2.0.12", + "msw": "^1.0.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/validate-access-backend/src/index.ts b/plugins/validate-access-backend/src/index.ts new file mode 100644 index 0000000..79a5f55 --- /dev/null +++ b/plugins/validate-access-backend/src/index.ts @@ -0,0 +1,9 @@ +export * from './service/router'; +import { createRouter } from './service/router'; +import { PluginEnvironment } from './types'; + +export default async function createPlugin(env: PluginEnvironment) { + return await createRouter({ + logger: env.logger, + }); +} diff --git a/plugins/validate-access-backend/src/plugin.ts b/plugins/validate-access-backend/src/plugin.ts new file mode 100644 index 0000000..09b4fe2 --- /dev/null +++ b/plugins/validate-access-backend/src/plugin.ts @@ -0,0 +1,8 @@ +import { createRouter } from './service/router'; +import { PluginEnvironment } from './types'; + +export default async function createPlugin(env: PluginEnvironment) { + return await createRouter({ + logger: env.logger, + }); +} diff --git a/plugins/validate-access-backend/src/run.ts b/plugins/validate-access-backend/src/run.ts new file mode 100644 index 0000000..d0c72b8 --- /dev/null +++ b/plugins/validate-access-backend/src/run.ts @@ -0,0 +1,21 @@ +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const DEFAULT_PORT = 7007; + +const port = process.env.PLUGIN_PORT + ? Number(process.env.PLUGIN_PORT) + : DEFAULT_PORT; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/validate-access-backend/src/service/router.test.ts b/plugins/validate-access-backend/src/service/router.test.ts new file mode 100644 index 0000000..4d55e7e --- /dev/null +++ b/plugins/validate-access-backend/src/service/router.test.ts @@ -0,0 +1,31 @@ +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; + +const STATUS_OK = 200; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(STATUS_OK); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/validate-access-backend/src/service/router.ts b/plugins/validate-access-backend/src/service/router.ts new file mode 100644 index 0000000..bb7112b --- /dev/null +++ b/plugins/validate-access-backend/src/service/router.ts @@ -0,0 +1,39 @@ +import { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { isUserAllowed } from './validateRepositoryManager'; +import * as jose from 'jose'; + +export interface RouterOptions { + logger: Logger; +} + +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + router.get('/validateuser', async (_, res) => { + const token = _.headers?.authorization as string; + if (token !== '') { + const userIdentityDetails = jose.decodeJwt(token); + const userAllowed = await isUserAllowed( + userIdentityDetails?.sub?.split('/')[1] as string, + ); + res.json({ allowed: userAllowed }); + } + res.json({ allowed: false }); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/validate-access-backend/src/service/standaloneServer.ts b/plugins/validate-access-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000..009bb76 --- /dev/null +++ b/plugins/validate-access-backend/src/service/standaloneServer.ts @@ -0,0 +1,34 @@ +import { createServiceBuilder } from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions +): Promise { + const logger = options.logger.child({ service: 'my-backend-plugin-backend' }); + logger.debug('Starting application server...'); + const router = await createRouter({ + logger, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/validate-access-plugin', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/validate-access-backend/src/service/validateRepositoryManager.ts b/plugins/validate-access-backend/src/service/validateRepositoryManager.ts new file mode 100644 index 0000000..fcdd86e --- /dev/null +++ b/plugins/validate-access-backend/src/service/validateRepositoryManager.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; + +// Function to check if a user is allowed +export const isUserAllowed = async ( + user: string +): Promise => { + try { + const token = process.env.GITHUB_TOKEN; // GitHub token with appropriate permissions + const response = await axios.get( + `${process.env.GITHUB_API_URL}/orgs/${process.env.GITHUB_ORGANIZATION}/teams/${process.env.REPO_CREATOR_TEAM}/memberships/${user}`, + { + headers: { + Authorization: `token ${token}`, + }, + }, + ); + + const userAccess = response.data; + return ( + userAccess.role && + ['member', 'admin'].includes(userAccess.role) && + userAccess.state === 'active' + ); + } catch (error) { + return false; + } +}; diff --git a/plugins/validate-access-backend/src/setupTests.ts b/plugins/validate-access-backend/src/setupTests.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/plugins/validate-access-backend/src/setupTests.ts @@ -0,0 +1 @@ +export {}; diff --git a/plugins/validate-access-backend/src/types.ts b/plugins/validate-access-backend/src/types.ts new file mode 100644 index 0000000..9cd2c74 --- /dev/null +++ b/plugins/validate-access-backend/src/types.ts @@ -0,0 +1,25 @@ +import { Logger } from 'winston'; +import { Config } from '@backstage/config'; +import { + PluginCacheManager, + PluginDatabaseManager, + PluginEndpointDiscovery, + TokenManager, + UrlReader, +} from '@backstage/backend-common'; +import { PluginTaskScheduler } from '@backstage/backend-tasks'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { IdentityApi } from '@backstage/plugin-auth-node'; + +export type PluginEnvironment = { + logger: Logger; + database: PluginDatabaseManager; + cache: PluginCacheManager; + config: Config; + reader: UrlReader; + discovery: PluginEndpointDiscovery; + tokenManager: TokenManager; + scheduler: PluginTaskScheduler; + permissions: PermissionEvaluator; + identity: IdentityApi; +}; diff --git a/terraform/data.tf b/terraform/data.tf index 39a5e8b..a5a7a07 100644 --- a/terraform/data.tf +++ b/terraform/data.tf @@ -34,3 +34,8 @@ data "aws_subnets" "private" { ] } } + +# Define data sources for SSM parameters +data "aws_ssm_parameter" "repo_name" { + name = "/backstage/${var.environment}/repo/creator/name" +} diff --git a/terraform/locals.tf b/terraform/locals.tf index 1a561b3..544d731 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -1,4 +1,18 @@ locals { route_53_zone = trimprefix(var.acm_domain_name, "*.") health_check_domain = "healthcheck-${var.namespace}-${var.environment}.${local.route_53_zone}" + environment_variables = [ + { + name = "GITHUB_API_URL" + value = "https://api.github.com" + }, + { + name = "GITHUB_ORGANIZATION" + value = "sourcefuse" + }, + { + name = "REPO_CREATOR_TEAM" + value = data.aws_ssm_parameter.repo_name.value + }, + ] } diff --git a/terraform/main.tf b/terraform/main.tf index 624cec4..a130f59 100644 --- a/terraform/main.tf +++ b/terraform/main.tf @@ -45,7 +45,7 @@ module "ecs_common_data" { ################################################################################ module "backstage" { source = "sourcefuse/arc-backstage-ecs-app/aws" - version = "0.2.6" + version = "0.2.7" alb_dns_name = module.ecs_common_data.alb_dns_name alb_zone_id = module.ecs_common_data.alb_dns_zone_id app_host_name = var.app_host_name @@ -66,4 +66,5 @@ module "backstage" { private_key_secret_name = var.private_key_secret_name health_check_path_pattern = "/healthcheck" desired_count = 2 + environment_variables = local.environment_variables }