Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(plugins): validate user access for scaffold #157

Merged
merged 7 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
node_modules
#packages
!packages/backend/dist
plugins
.env.local*
*.pem
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 {} \+

Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -393,6 +394,14 @@ const routes = (
</TechDocsAddons>
</Route>
<Route path="/create" element={<ScaffolderPage />} />
<Route
path="/create/templates/default"
element={
<PermissionWrapper permission="scaffoldPermission">
<ScaffolderPage />
</PermissionWrapper>
}
/>
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route
path="/tech-radar"
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/PermissionWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// PermissionWrapper.js

import React from 'react';
import { usePermissionCheck } from './usePermissionCheck';

export const PermissionWrapper = ({ children, permission }: any) => { // NOSONAR
const hasPermission = usePermissionCheck(permission as string); // NOSONAR

if (hasPermission === null) {
return <div>Loading...</div>; // Render a loading state while checking permissions
}

if (!hasPermission) {
return <div>Permission Denied......</div>; // Render a "Permission Denied" message
}

return children;
};
41 changes: 41 additions & 0 deletions packages/app/src/usePermissionCheck.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
8 changes: 8 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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));
Expand All @@ -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());
Expand Down
1 change: 1 addition & 0 deletions plugins/validate-access-backend/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
15 changes: 15 additions & 0 deletions plugins/validate-access-backend/README.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions plugins/validate-access-backend/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
9 changes: 9 additions & 0 deletions plugins/validate-access-backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
8 changes: 8 additions & 0 deletions plugins/validate-access-backend/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
21 changes: 21 additions & 0 deletions plugins/validate-access-backend/src/run.ts
Original file line number Diff line number Diff line change
@@ -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);
});
31 changes: 31 additions & 0 deletions plugins/validate-access-backend/src/service/router.test.ts
Original file line number Diff line number Diff line change
@@ -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' });
});
});
});
39 changes: 39 additions & 0 deletions plugins/validate-access-backend/src/service/router.ts
Original file line number Diff line number Diff line change
@@ -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<express.Router> {
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;
}
34 changes: 34 additions & 0 deletions plugins/validate-access-backend/src/service/standaloneServer.ts
Original file line number Diff line number Diff line change
@@ -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<Server> {
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();
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import axios from 'axios';

// Function to check if a user is allowed
export const isUserAllowed = async (
user: string
): Promise<boolean> => {
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;
}
};
1 change: 1 addition & 0 deletions plugins/validate-access-backend/src/setupTests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
Loading
Loading