Skip to content

Commit 7d138e5

Browse files
authored
feat(extensions): Add extension permissions (#691)
* feat(marketplace): add permissions to extensions * adding conditional policies * add new service to check config authorization
1 parent 3a76aaf commit 7d138e5

17 files changed

Lines changed: 744 additions & 59 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@red-hat-developer-hub/backstage-plugin-marketplace-backend': minor
3+
'@red-hat-developer-hub/backstage-plugin-marketplace-common': minor
4+
'@red-hat-developer-hub/backstage-plugin-marketplace': minor
5+
---
6+
7+
Added extension permissions

workspaces/marketplace/plugins/marketplace-backend/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737
"@backstage/backend-plugin-api": "^1.2.0",
3838
"@backstage/catalog-client": "^1.9.1",
3939
"@backstage/catalog-model": "^1.7.3",
40-
"@backstage/errors": "^1.2.7",
4140
"@backstage/plugin-catalog-node": "^1.16.0",
41+
"@backstage/plugin-permission-common": "^0.8.4",
42+
"@backstage/plugin-permission-node": "^0.8.8",
4243
"@red-hat-developer-hub/backstage-plugin-marketplace-common": "workspace:^",
4344
"express": "^4.17.1",
44-
"express-promise-router": "^4.1.0"
45+
"express-promise-router": "^4.1.0",
46+
"zod": "^3.22.4"
4547
},
4648
"devDependencies": {
4749
"@backstage/backend-test-utils": "^1.3.0",
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/*
2+
* Copyright The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
createPermissionResourceRef,
19+
createPermissionRule,
20+
} from '@backstage/plugin-permission-node';
21+
import { z } from 'zod';
22+
import {
23+
MarketplacePlugin,
24+
RESOURCE_TYPE_EXTENSION_PLUGIN,
25+
} from '@red-hat-developer-hub/backstage-plugin-marketplace-common';
26+
27+
export type ExtentionFilter = {
28+
key: string;
29+
values: Array<string> | undefined;
30+
};
31+
32+
export type ExtentionFilters =
33+
| { anyOf: ExtentionFilters[] }
34+
| { allOf: ExtentionFilters[] }
35+
| { not: ExtentionFilters }
36+
| ExtentionFilter;
37+
38+
export const extensionPermissionResourceRef = createPermissionResourceRef<
39+
MarketplacePlugin,
40+
ExtentionFilter
41+
>().with({
42+
pluginId: 'marketplace',
43+
resourceType: RESOURCE_TYPE_EXTENSION_PLUGIN,
44+
});
45+
46+
export type ExtensionParams = {
47+
annotation: string;
48+
value?: string;
49+
pluginNames?: string[];
50+
};
51+
52+
const hasPluginName = createPermissionRule({
53+
name: 'HAS_NAME',
54+
description: 'Should allow users to install the plugin with specified name',
55+
resourceRef: extensionPermissionResourceRef,
56+
57+
paramsSchema: z.object({
58+
pluginNames: z
59+
.string()
60+
.array()
61+
.optional()
62+
.describe('List of plugin names to match on'),
63+
}),
64+
apply: (plugin: MarketplacePlugin, { pluginNames }) => {
65+
return pluginNames && pluginNames.length > 0
66+
? !!pluginNames?.find(
67+
name =>
68+
name.toLowerCase() === plugin.metadata.title?.toLowerCase() ||
69+
name.toLowerCase() === plugin.metadata.name.toLowerCase(),
70+
)
71+
: true;
72+
},
73+
toQuery: ({ pluginNames }) => ({ key: 'name', values: pluginNames }),
74+
});
75+
76+
const hasAnnotation = createPermissionRule({
77+
name: 'HAS_ANNOTATION',
78+
description:
79+
'Should allow users to install the plugin with specified annotation',
80+
resourceRef: extensionPermissionResourceRef,
81+
paramsSchema: z.object({
82+
annotation: z.string().describe('Name of the annotation to match on'),
83+
value: z
84+
.string()
85+
.optional()
86+
.describe('Value of the annotation to match on'),
87+
}),
88+
apply: (plugin: MarketplacePlugin, params: ExtensionParams) =>
89+
!!plugin.metadata.annotations?.hasOwnProperty(params.annotation) &&
90+
(params.value === undefined
91+
? true
92+
: plugin.metadata.annotations?.[params.annotation] === params.value),
93+
toQuery: ({ annotation, value }) => ({
94+
key: annotation,
95+
values: value ? [value] : undefined,
96+
}),
97+
});
98+
99+
export const rules = { hasPluginName, hasAnnotation };

workspaces/marketplace/plugins/marketplace-backend/src/plugin.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ export const marketplacePlugin = createBackendPlugin({
4141
httpAuth: coreServices.httpAuth,
4242
httpRouter: coreServices.httpRouter,
4343
discovery: coreServices.discovery,
44+
permissions: coreServices.permissions,
4445
},
45-
async init({ auth, httpAuth, httpRouter, discovery }) {
46+
async init({ auth, httpAuth, httpRouter, discovery, permissions }) {
4647
const catalogApi = new CatalogClient({ discoveryApi: discovery });
4748

4849
const marketplaceApi: MarketplaceApi = new MarketplaceCatalogClient({
@@ -54,6 +55,7 @@ export const marketplacePlugin = createBackendPlugin({
5455
await createRouter({
5556
httpAuth,
5657
marketplaceApi,
58+
permissions,
5759
}),
5860
);
5961
},

workspaces/marketplace/plugins/marketplace-backend/src/router.ts

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,78 @@
1414
* limitations under the License.
1515
*/
1616

17-
import express from 'express';
17+
import express, { Request } from 'express';
1818
import Router from 'express-promise-router';
1919

20-
import { HttpAuthService } from '@backstage/backend-plugin-api';
20+
import {
21+
HttpAuthService,
22+
PermissionsService,
23+
} from '@backstage/backend-plugin-api';
24+
import {
25+
AuthorizeResult,
26+
BasicPermission,
27+
PolicyDecision,
28+
ResourcePermission,
29+
} from '@backstage/plugin-permission-common';
2130

2231
import {
2332
decodeGetEntitiesRequest,
2433
decodeGetEntityFacetsRequest,
34+
extensionPluginCreatePermission,
35+
extensionPluginReadPermission,
2536
MarketplaceApi,
37+
MarketplacePlugin,
38+
RESOURCE_TYPE_EXTENSION_PLUGIN,
39+
extensionPermissions,
2640
} from '@red-hat-developer-hub/backstage-plugin-marketplace-common';
41+
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
2742
import { createSearchParams } from './utils/createSearchParams';
2843
import { removeVerboseSpecContent } from './utils/removeVerboseSpecContent';
44+
import { rules as extensionRules } from './permissions/rules';
45+
import { matches } from './utils/permissionUtils';
2946

3047
export async function createRouter({
3148
marketplaceApi,
49+
httpAuth,
50+
permissions,
3251
}: {
3352
httpAuth: HttpAuthService;
3453
marketplaceApi: MarketplaceApi;
54+
permissions: PermissionsService;
3555
}): Promise<express.Router> {
3656
const router = Router();
57+
const permissionsIntegrationRouter = createPermissionIntegrationRouter({
58+
resourceType: RESOURCE_TYPE_EXTENSION_PLUGIN,
59+
permissions: extensionPermissions,
60+
rules: Object.values(extensionRules),
61+
});
3762
router.use(express.json());
63+
router.use(permissionsIntegrationRouter);
64+
65+
const authorizeConditional = async (
66+
request: Request,
67+
permission:
68+
| ResourcePermission<'extension-plugin' | 'extension-package'>
69+
| BasicPermission,
70+
) => {
71+
const credentials = await httpAuth.credentials(request);
72+
let decision: PolicyDecision;
73+
if (permission.type === 'resource') {
74+
decision = (
75+
await permissions.authorizeConditional([{ permission }], {
76+
credentials,
77+
})
78+
)[0];
79+
} else {
80+
decision = (
81+
await permissions.authorize([{ permission }], {
82+
credentials,
83+
})
84+
)[0];
85+
}
86+
87+
return decision;
88+
};
3889

3990
router.get('/collections', async (req, res) => {
4091
const request = decodeGetEntitiesRequest(createSearchParams(req));
@@ -108,6 +159,112 @@ export async function createRouter({
108159
res.json(plugin);
109160
});
110161

162+
router.get(
163+
'/plugin/:namespace/:name/configuration/authorize',
164+
async (req, res) => {
165+
const [readDecision, installDecision] = await Promise.all([
166+
authorizeConditional(req, extensionPluginReadPermission),
167+
authorizeConditional(req, extensionPluginCreatePermission),
168+
]);
169+
if (
170+
readDecision.result === AuthorizeResult.DENY &&
171+
installDecision.result === AuthorizeResult.DENY
172+
) {
173+
res.status(403);
174+
return;
175+
}
176+
177+
const authorizedActions: string[] = [];
178+
let plugin: MarketplacePlugin;
179+
180+
const evaluateConditional = async (
181+
decision: PolicyDecision,
182+
action: string,
183+
) => {
184+
if (decision.result === AuthorizeResult.CONDITIONAL) {
185+
if (!plugin) {
186+
plugin = await marketplaceApi.getPluginByName(
187+
req.params.namespace,
188+
req.params.name,
189+
);
190+
}
191+
if (matches(plugin, decision.conditions)) {
192+
authorizedActions.push(action);
193+
}
194+
} else if (decision.result === AuthorizeResult.ALLOW) {
195+
authorizedActions.push(action);
196+
}
197+
};
198+
199+
await Promise.all([
200+
evaluateConditional(readDecision, 'read'),
201+
evaluateConditional(installDecision, 'create'),
202+
]);
203+
204+
if (authorizedActions.length === 0) {
205+
res.status(403);
206+
return;
207+
}
208+
res.status(200).json({ authorizedActions });
209+
},
210+
);
211+
212+
router.get('/plugin/:namespace/:name/configuration', async (req, res) => {
213+
const readDecision = await authorizeConditional(
214+
req,
215+
extensionPluginReadPermission,
216+
);
217+
if (readDecision.result === AuthorizeResult.DENY) {
218+
res.status(403).json({ error: 'Forbidden' });
219+
return;
220+
}
221+
222+
const plugin = await marketplaceApi.getPluginByName(
223+
req.params.namespace,
224+
req.params.name,
225+
);
226+
227+
const hasReadAccess =
228+
readDecision.result === AuthorizeResult.ALLOW ||
229+
(readDecision.result === AuthorizeResult.CONDITIONAL &&
230+
matches(plugin, readDecision.conditions));
231+
if (!hasReadAccess) {
232+
res.status(403).json({ error: 'Forbidden' });
233+
return;
234+
}
235+
236+
res.status(200).json({}); // This should return the configuration in YAML string
237+
});
238+
239+
router.post('/plugin/:namespace/:name/configuration', async (req, res) => {
240+
// installs the plugin
241+
const installDecision = await authorizeConditional(
242+
req,
243+
extensionPluginCreatePermission,
244+
);
245+
if (installDecision.result === AuthorizeResult.DENY) {
246+
res.status(403).json({ error: 'Forbidden' });
247+
return;
248+
}
249+
250+
const plugin = await marketplaceApi.getPluginByName(
251+
req.params.namespace,
252+
req.params.name,
253+
);
254+
255+
const hasInstallAccess =
256+
installDecision.result === AuthorizeResult.ALLOW ||
257+
(installDecision.result === AuthorizeResult.CONDITIONAL &&
258+
matches(plugin, installDecision.conditions));
259+
260+
if (!hasInstallAccess) {
261+
res.status(403).json({ error: 'Forbidden' });
262+
return;
263+
}
264+
265+
res.status(200).json({});
266+
});
267+
111268
router.get('/plugin/:namespace/:name/packages', async (req, res) => {
112269
const packages = await marketplaceApi.getPluginPackages(
113270
req.params.namespace,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright The Backstage Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {
18+
PermissionCondition,
19+
PermissionCriteria,
20+
PermissionRuleParams,
21+
} from '@backstage/plugin-permission-common';
22+
23+
import { MarketplacePlugin } from '@red-hat-developer-hub/backstage-plugin-marketplace-common';
24+
25+
import { ExtensionParams, rules as extensionRules } from '../permissions/rules';
26+
27+
export const matches = (
28+
plugin?: MarketplacePlugin,
29+
filters?: PermissionCriteria<
30+
PermissionCondition<string, PermissionRuleParams>
31+
>,
32+
): boolean => {
33+
if (!filters) {
34+
return true;
35+
}
36+
37+
if (!plugin) {
38+
return false;
39+
}
40+
41+
if ('allOf' in filters) {
42+
return filters.allOf.every(filter => matches(plugin, filter));
43+
}
44+
45+
if ('anyOf' in filters) {
46+
return filters.anyOf.some(filter => matches(plugin, filter));
47+
}
48+
49+
if ('not' in filters) {
50+
return !matches(plugin, filters.not);
51+
}
52+
return (
53+
Object.values(extensionRules)
54+
.find(r => r.name === filters.rule)
55+
?.apply(plugin, filters.params as ExtensionParams) ?? false
56+
);
57+
};

0 commit comments

Comments
 (0)