-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(backend): Github based permission management (#206)
## 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
1 parent
83e9508
commit 41df43e
Showing
5 changed files
with
241 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | ||
); | ||
}, | ||
}); | ||
}, | ||
}); |