Skip to content

Commit

Permalink
feat(backend): Github based permission management (#206)
Browse files Browse the repository at this point in the history
## Description

This PR introduces a new feature to the Backstage GitHub Permission
Plugin. It enables GitHub-based permissions to be mapped with
Backstage's permission framework. This allows for more granular control
and security when integrating GitHub permissions with Backstage
applications.

Fixes #187

## Type of change

Please delete options that are not relevant.

- [ ] Bug fix (non-breaking change which fixes an issue)
- [X] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Intermediate change (work in progress)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration

- [ ] Test A
- [ ] Test B

## Checklist:

- [X] Performed a self-review of my own code
- [ ] npm test passes on your machine
- [ ] New tests added or existing tests modified to cover all changes
- [ ] Code conforms with the style guide
- [ ] API Documentation in code was updated
- [X] Any dependent changes have been merged and published in downstream
modules
  • Loading branch information
hitesh-sourcefuse authored Nov 11, 2024
1 parent 83e9508 commit 41df43e
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 18 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,5 @@ override.tf.json

# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan
# example: *tfplan*

.history
7 changes: 4 additions & 3 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ catalog:
# kubernetes:
# see https://backstage.io/docs/features/kubernetes/configuration for kubernetes configuration options
# see https://backstage.io/docs/permissions/getting-started for more on the permission framework
# permission:
# # setting this to `false` will disable permissions
# enabled: true

## setting this to `false` will disable permissions
permission:
enabled: true
7 changes: 5 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,18 @@
"pg": "^8.11.3",
"winston": "^3.2.1",
"workerpool": "^6.2.1",
"yeoman-environment": "^3.9.1"
"yeoman-environment": "^3.9.1",
"octokit": "^3.0.0",
"lodash": "4.17.21"
},
"devDependencies": {
"@backstage/cli": "^0.27.1",
"@types/dockerode": "^3.3.0",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/luxon": "^2.0.4",
"@types/workerpool": "^6.1.0"
"@types/workerpool": "^6.1.0",
"@types/lodash": "4.17.12"
},
"files": [
"dist"
Expand Down
18 changes: 5 additions & 13 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@

import { createBackend } from '@backstage/backend-defaults';


import { createBackendModule } from '@backstage/backend-plugin-api';

import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
Expand All @@ -18,15 +16,14 @@ const scaffolderModuleCustomExtensions = createBackendModule({
},
async init({ scaffolder }) {
scaffolder.addActions(
createMicroserviceAction(),
createScaffoldAction()
createMicroserviceAction(),
createScaffoldAction(),
);
}
})
},
});
},
});


const backend = createBackend();

backend.add(import('@backstage/plugin-app-backend/alpha'));
Expand All @@ -44,13 +41,9 @@ backend.add(
// See https://backstage.io/docs/features/software-catalog/configuration#subscribing-to-catalog-errors
backend.add(import('@backstage/plugin-catalog-backend-module-logs'));


// permission plugin
backend.add(import('@backstage/plugin-permission-backend/alpha'));
// See https://backstage.io/docs/permissions/getting-started for how to create your own permission policy
backend.add(
import('@backstage/plugin-permission-backend-module-allow-all-policy'),
);
backend.add(import('./plugins/permission'));

// search plugin
backend.add(import('@backstage/plugin-search-backend/alpha'));
Expand All @@ -64,7 +57,6 @@ backend.add(import('@backstage/plugin-search-backend-module-techdocs/alpha'));
backend.add(import('@internal/backstage-plugin-access-validate-backend'));
backend.add(import('@backstage/plugin-scaffolder-backend-module-github'));


backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(scaffolderModuleCustomExtensions);

Expand Down
225 changes: 225 additions & 0 deletions packages/backend/src/plugins/permission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import {
AuthorizeResult,
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';
import { catalogEntityReadPermission } from '@backstage/plugin-catalog-common/alpha';
import type { BackstageIdentityResponse } from '@backstage/plugin-auth-node';
import type { PaginatingEndpoints } from '@octokit/plugin-paginate-rest';
import { parseEntityRef } from '@backstage/catalog-model';
import {
createBackendModule,
type AuthService,
type CacheService,
type DiscoveryService,
type LoggerService,
} from '@backstage/backend-plugin-api';
import { policyExtensionPoint } from '@backstage/plugin-permission-node/alpha';
import { coreServices } from '@backstage/backend-plugin-api';
import { Octokit } from 'octokit';
import * as _ from 'lodash';

class RequestPermissionPolicy implements PermissionPolicy {
readonly orgRepositories: Promise<
PaginatingEndpoints['GET /orgs/{org}/repos']['response']['data']
>;
readonly catalogRepos: Promise<Set<string>>;
readonly userRepoPermissions: Record<string, Array<string>> = {};

constructor(
protected readonly tokenManager: AuthService,
protected readonly discovery: DiscoveryService,
protected readonly cache: CacheService,
protected readonly octokit: Octokit,
protected readonly logger: LoggerService,
) {
// async operation is handled in resolver method
this.orgRepositories = this.fetchOrganizationRepos(); // NOSONAR
this.catalogRepos = this.fetchCatalog(); // NOSONAR
}

async handle(
request: PolicyQuery,
user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (!user?.identity) {
this.logger.error('not able to found the name', {
user: JSON.stringify(user),
});
return { result: AuthorizeResult.DENY };
}

const resolvedPermission = (
await Promise.all([this.catalogPermissionHandler(request, user)])
).filter(c => c !== undefined);

if (resolvedPermission.length > 1) {
this.logger.error(
'more than 1 permission got resolved in the decision, only one is allowed',
{ resolvedPermission: JSON.stringify(resolvedPermission) },
);
}

if (resolvedPermission.length === 1) {
return resolvedPermission.pop() as PolicyDecision;
}

this.logger.info("didn't received any handler for the policy request", {
request,
});
return { result: AuthorizeResult.ALLOW };
}

protected async fetchOrganizationRepos() {
const startTimeBenchmark = performance.now();
const out: PaginatingEndpoints['GET /orgs/{org}/repos']['response']['data'] =
[];
for await (const { data } of this.octokit.paginate.iterator(
'GET /orgs/{org}/repos',
{
per_page: 100,
org: String(process.env.GITHUB_ORGANIZATION),
},
)) {
//! will use status or header to validate success
out.push(...data);
}
this.logger.debug('Github Repo List resolution benchmark', {
totalTimeInMilliSeconds: startTimeBenchmark - performance.now(),
});
return out;
}

protected async fetchCatalog() {
const base = await this.discovery.getBaseUrl('catalog');
const { token } = await this.tokenManager.getPluginRequestToken({
onBehalfOf: await this.tokenManager.getOwnServiceCredentials(),
targetPluginId: 'catalog',
});

const url = `${base}/entities?filter=kind=component`;

const req = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
});
const data = await req.json();
// Get Name a repo name not the repo full_name
return new Set<string>(
data.map((cl: { metadata: { name: string } }) => cl.metadata.name),
);
}

protected async resolveAuthorizedRepoList(
userEntityRef: string,
): Promise<string[] | undefined> {
const usernameEntity = parseEntityRef(userEntityRef);

if (userEntityRef in this.userRepoPermissions) {
return this.userRepoPermissions[userEntityRef];
}

this.userRepoPermissions[userEntityRef] = [];
const catalogRepos = await this.catalogRepos;
const orgRepos = await this.orgRepositories;
// Filtering out repo which is logged in the Catalog meta with just name of the repo
const repositories = orgRepos.filter(r => catalogRepos.has(r.name));
const privateCatalogRepos = repositories.filter(r => r.private);
const publicCatalogRepos = repositories.filter(r => r.private === false);

for (const repo of publicCatalogRepos) {
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,
})),
),
);

for (const permission of permissions) {
this.userRepoPermissions[userEntityRef].push(permission.repo.name);
}
}

//! log any meta name which is not include that means there is issue in the meta name
return this.userRepoPermissions[userEntityRef];
}

// Permission handlers
protected async catalogPermissionHandler(
request: PolicyQuery,
user: BackstageIdentityResponse,
): Promise<PolicyDecision | undefined> {
if (!isPermission(request.permission, catalogEntityReadPermission)) {
return;
}
const startTimeBenchmark = performance.now();
const userPermission = await this.resolveAuthorizedRepoList(
user.identity.userEntityRef,
);

if (!userPermission) {
// permission not resolved from the Github API
return { result: AuthorizeResult.DENY };
}
this.logger.debug('Permission resolution benchmark', {
totalTimeInMilliSeconds: startTimeBenchmark - performance.now(),
});
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 }),
),
});
}
}

export default createBackendModule({
pluginId: 'permission',
moduleId: 'permission-policy',
register(reg) {
reg.registerInit({
deps: {
auth: coreServices.auth,
discovery: coreServices.discovery,
cache: coreServices.cache,
policy: policyExtensionPoint,
logger: coreServices.logger,
},
async init({ policy, cache, discovery, auth, logger }) {
const octokit = new Octokit({
auth: String(process.env.GITHUB_TOKEN),
// throttle: {},
});
policy.setPolicy(
new RequestPermissionPolicy(auth, discovery, cache, octokit, logger),
);
},
});
},
});

0 comments on commit 41df43e

Please sign in to comment.