permission-node: refactor integration router to be mutable
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-permission-node': patch
|
||||
---
|
||||
|
||||
The returned router from `createPermissionIntegrationRouter` is now mutable, allowing for permissions and resources to be added after creation of the router.
|
||||
@@ -124,7 +124,7 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3 extends string,
|
||||
TResource3,
|
||||
>(
|
||||
options:
|
||||
options?:
|
||||
| {
|
||||
permissions: Array<Permission>;
|
||||
}
|
||||
@@ -140,7 +140,15 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3,
|
||||
TResource3
|
||||
>,
|
||||
): express.Router;
|
||||
): express.Router & {
|
||||
addPermissions(permissions: Permission[]): void;
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): void;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type CreatePermissionIntegrationRouterResourceOptions<
|
||||
|
||||
@@ -981,6 +981,94 @@ describe('createPermissionIntegrationRouter', () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a list of basic permissions together with permissions and rules from multiple resource types with mutation', async () => {
|
||||
const aPermission = createPermission({
|
||||
name: 'a.permission',
|
||||
attributes: {},
|
||||
});
|
||||
|
||||
const router = createPermissionIntegrationRouter();
|
||||
|
||||
const responseBefore = await request(express().use(router)).get(
|
||||
'/.well-known/backstage/permissions/metadata',
|
||||
);
|
||||
|
||||
expect(responseBefore.status).toEqual(200);
|
||||
expect(responseBefore.body).toEqual({
|
||||
permissions: [],
|
||||
rules: [],
|
||||
});
|
||||
|
||||
router.addPermissions([aPermission, testPermission]);
|
||||
|
||||
router.addResourceType({
|
||||
resourceType: 'test-resource',
|
||||
permissions: [testPermission],
|
||||
getResources: defaultMockedGetResources1,
|
||||
rules: [testRule1, testRule2],
|
||||
});
|
||||
|
||||
router.addResourceType({
|
||||
resourceType: 'test-resource-2',
|
||||
permissions: [testPermission2],
|
||||
getResources: defaultMockedGetResources2,
|
||||
rules: [testRule3],
|
||||
});
|
||||
|
||||
const responseAfter = await request(express().use(router)).get(
|
||||
'/.well-known/backstage/permissions/metadata',
|
||||
);
|
||||
|
||||
expect(responseAfter.status).toEqual(200);
|
||||
expect(responseAfter.body).toEqual({
|
||||
permissions: [aPermission, testPermission, testPermission2],
|
||||
rules: [
|
||||
{
|
||||
name: testRule1.name,
|
||||
description: testRule1.description,
|
||||
resourceType: testRule1.resourceType,
|
||||
paramsSchema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
description: 'bar',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['foo', 'bar'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testRule2.name,
|
||||
description: testRule2.description,
|
||||
resourceType: testRule2.resourceType,
|
||||
paramsSchema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: testRule3.name,
|
||||
description: testRule3.description,
|
||||
resourceType: testRule3.resourceType,
|
||||
paramsSchema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
additionalProperties: false,
|
||||
properties: {},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createConditionAuthorizer', () => {
|
||||
|
||||
@@ -250,6 +250,102 @@ export type PermissionIntegrationRouterOptions<
|
||||
>;
|
||||
};
|
||||
|
||||
class PermissionIntegrationMetadataStore {
|
||||
readonly #rulesByTypeByName = new Map<
|
||||
string,
|
||||
Map<string, PermissionRule<unknown, unknown, string>>
|
||||
>();
|
||||
readonly #permissionsByName = new Map<string, Permission>();
|
||||
readonly #resourcesByType = new Map<
|
||||
string,
|
||||
CreatePermissionIntegrationRouterResourceOptions<string, unknown>
|
||||
>();
|
||||
readonly #serializedRules = new Array<MetadataResponseSerializedRule>();
|
||||
|
||||
getSerializedMetadata(): MetadataResponse {
|
||||
return {
|
||||
permissions: Array.from(this.#permissionsByName.values()),
|
||||
rules: this.#serializedRules,
|
||||
};
|
||||
}
|
||||
|
||||
hasResourceType(type: string): boolean {
|
||||
return this.#resourcesByType.has(type);
|
||||
}
|
||||
|
||||
async getResources(
|
||||
resourceType: string,
|
||||
refs: string[],
|
||||
): Promise<Record<string, unknown>> {
|
||||
const resource = this.#resourcesByType.get(resourceType);
|
||||
if (!resource?.getResources) {
|
||||
throw new NotImplementedError(
|
||||
`This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`,
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueRefs = Array.from(new Set(refs));
|
||||
const resources = await resource.getResources(uniqueRefs);
|
||||
return Object.fromEntries(
|
||||
uniqueRefs.map((ref, index) => [ref, resources[index]]),
|
||||
);
|
||||
}
|
||||
|
||||
getRuleMapper(resourceType: string) {
|
||||
return (name: string): PermissionRule<unknown, unknown, string> => {
|
||||
const rule = this.#rulesByTypeByName.get(resourceType)?.get(name);
|
||||
if (!rule) {
|
||||
throw new Error(`Unexpected permission rule: ${name}`);
|
||||
}
|
||||
return rule;
|
||||
};
|
||||
}
|
||||
|
||||
addPermissions(permissions: Permission[]) {
|
||||
for (const permission of permissions) {
|
||||
// Permission naming conflicts are silently ignored
|
||||
this.#permissionsByName.set(permission.name, permission);
|
||||
}
|
||||
}
|
||||
|
||||
addResourceType(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<string, unknown>,
|
||||
) {
|
||||
const { resourceType } = resource;
|
||||
|
||||
if (this.#resourcesByType.has(resourceType)) {
|
||||
throw new Error(
|
||||
`Refused to add permission resource with type '${resourceType}' because it already exists`,
|
||||
);
|
||||
}
|
||||
this.#resourcesByType.set(resourceType, resource);
|
||||
|
||||
for (const rule of resource.rules) {
|
||||
const rulesByName =
|
||||
this.#rulesByTypeByName.get(resourceType) ?? new Map();
|
||||
this.#rulesByTypeByName.set(resourceType, rulesByName);
|
||||
|
||||
if (rulesByName.has(rule.name)) {
|
||||
throw new Error(
|
||||
`Refused to add permission rule for type '${resourceType}' with name '${rule.name}' because it already exists`,
|
||||
);
|
||||
}
|
||||
rulesByName.set(rule.name, rule);
|
||||
|
||||
this.#serializedRules.push({
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
resourceType: rule.resourceType,
|
||||
paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})),
|
||||
});
|
||||
}
|
||||
|
||||
if (resource.permissions) {
|
||||
this.addPermissions(resource.permissions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an express Router which provides an authorization route to allow
|
||||
* integration between the permission backend and other Backstage backend
|
||||
@@ -299,7 +395,7 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3 extends string,
|
||||
TResource3,
|
||||
>(
|
||||
options:
|
||||
options?:
|
||||
| { permissions: Array<Permission> }
|
||||
| CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
@@ -313,167 +409,80 @@ export function createPermissionIntegrationRouter<
|
||||
TResourceType3,
|
||||
TResource3
|
||||
>,
|
||||
): express.Router {
|
||||
const optionsWithResources = options as PermissionIntegrationRouterOptions;
|
||||
const allOptions = [
|
||||
optionsWithResources.resources ? optionsWithResources.resources : options,
|
||||
].flat();
|
||||
const allRules = allOptions.flatMap(
|
||||
option =>
|
||||
(
|
||||
option as CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>
|
||||
).rules || [],
|
||||
);
|
||||
): express.Router & {
|
||||
addPermissions(permissions: Permission[]): void;
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): void;
|
||||
} {
|
||||
const store = new PermissionIntegrationMetadataStore();
|
||||
|
||||
const allPermissions = Array.from(
|
||||
new Map(
|
||||
[
|
||||
...((options as { permissions: Permission[] }).permissions || []),
|
||||
...(optionsWithResources.resources?.flatMap(o => o.permissions || []) ||
|
||||
[]),
|
||||
].map(i => [i.name, i]),
|
||||
).values(),
|
||||
);
|
||||
if (options) {
|
||||
if ('resources' in options) {
|
||||
// Not technically allowed by types, but it's historically been covered by tests
|
||||
if ('permissions' in options) {
|
||||
store.addPermissions(options.permissions as Permission[]);
|
||||
}
|
||||
|
||||
const allResourceTypes = allOptions.reduce((acc, option) => {
|
||||
if (
|
||||
isCreatePermissionIntegrationRouterResourceOptions(
|
||||
option as
|
||||
| { permissions: Array<Permission> }
|
||||
| CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>,
|
||||
)
|
||||
) {
|
||||
acc.push(
|
||||
(
|
||||
option as CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>
|
||||
).resourceType,
|
||||
);
|
||||
for (const resource of options.resources) {
|
||||
store.addResourceType(resource);
|
||||
}
|
||||
} else if ('resourceType' in options) {
|
||||
store.addResourceType(options);
|
||||
} else {
|
||||
store.addPermissions(options.permissions);
|
||||
}
|
||||
return acc;
|
||||
}, [] as string[]);
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
router.use('/.well-known/backstage/permissions/', express.json());
|
||||
|
||||
router.get('/.well-known/backstage/permissions/metadata', (_, res) => {
|
||||
const serializedRules: MetadataResponseSerializedRule[] = allRules.map(
|
||||
rule => ({
|
||||
name: rule.name,
|
||||
description: rule.description,
|
||||
resourceType: rule.resourceType,
|
||||
paramsSchema: zodToJsonSchema(rule.paramsSchema ?? z.object({})),
|
||||
}),
|
||||
);
|
||||
|
||||
const responseJson: MetadataResponse = {
|
||||
permissions: allPermissions,
|
||||
rules: serializedRules,
|
||||
};
|
||||
|
||||
return res.json(responseJson);
|
||||
res.json(store.getSerializedMetadata());
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/.well-known/backstage/permissions/apply-conditions',
|
||||
async (req, res: Response<ApplyConditionsResponse | string>) => {
|
||||
const ruleMapByResourceType: Record<
|
||||
string,
|
||||
ReturnType<typeof createGetRule>
|
||||
> = {};
|
||||
const getResourcesByResourceType: Record<
|
||||
string,
|
||||
CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>['getResources']
|
||||
> = {};
|
||||
|
||||
for (let option of allOptions) {
|
||||
option = option as
|
||||
| { permissions: Array<Permission> }
|
||||
| CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType1,
|
||||
TResource1
|
||||
>;
|
||||
if (isCreatePermissionIntegrationRouterResourceOptions(option)) {
|
||||
ruleMapByResourceType[option.resourceType] = createGetRule(
|
||||
option.rules,
|
||||
);
|
||||
|
||||
getResourcesByResourceType[option.resourceType] = option.getResources;
|
||||
}
|
||||
}
|
||||
|
||||
const assertValidResourceTypes = (
|
||||
requests: ApplyConditionsRequestEntry[],
|
||||
) => {
|
||||
const invalidResourceTypes = requests
|
||||
.filter(request => !allResourceTypes.includes(request.resourceType))
|
||||
.map(request => request.resourceType);
|
||||
|
||||
if (invalidResourceTypes.length) {
|
||||
throw new InputError(
|
||||
`Unexpected resource types: ${invalidResourceTypes.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const parseResult = applyConditionsRequestSchema.safeParse(req.body);
|
||||
|
||||
if (!parseResult.success) {
|
||||
throw new InputError(parseResult.error.toString());
|
||||
}
|
||||
|
||||
const body = parseResult.data;
|
||||
const { items: requests } = parseResult.data;
|
||||
|
||||
assertValidResourceTypes(body.items);
|
||||
|
||||
const resourceRefsByResourceType = body.items.reduce<
|
||||
Record<string, Set<string>>
|
||||
>((acc, item) => {
|
||||
if (!acc[item.resourceType]) {
|
||||
acc[item.resourceType] = new Set();
|
||||
}
|
||||
acc[item.resourceType].add(item.resourceRef);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const resourcesByResourceType: Record<string, Record<string, any>> = {};
|
||||
for (const resourceType of Object.keys(resourceRefsByResourceType)) {
|
||||
const getResources = getResourcesByResourceType[resourceType];
|
||||
if (!getResources) {
|
||||
throw new NotImplementedError(
|
||||
`This plugin does not expose any permission rule or can't evaluate the conditions request for ${resourceType}`,
|
||||
);
|
||||
}
|
||||
const resourceRefs = Array.from(
|
||||
resourceRefsByResourceType[resourceType],
|
||||
const invalidResourceTypes = requests.filter(
|
||||
i => !store.hasResourceType(i.resourceType),
|
||||
);
|
||||
if (invalidResourceTypes.length) {
|
||||
throw new InputError(
|
||||
`Unexpected resource types: ${invalidResourceTypes
|
||||
.map(i => i.resourceType)
|
||||
.join(', ')}.`,
|
||||
);
|
||||
const resources = await getResources(resourceRefs);
|
||||
resourceRefs.forEach((resourceRef, index) => {
|
||||
if (!resourcesByResourceType[resourceType]) {
|
||||
resourcesByResourceType[resourceType] = {};
|
||||
}
|
||||
resourcesByResourceType[resourceType][resourceRef] = resources[index];
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
items: body.items.map(request => ({
|
||||
const resourcesByType: Record<string, Record<string, any>> = {};
|
||||
for (const requestedType of new Set(requests.map(i => i.resourceType))) {
|
||||
resourcesByType[requestedType] = await store.getResources(
|
||||
requestedType,
|
||||
requests
|
||||
.filter(r => r.resourceType === requestedType)
|
||||
.map(i => i.resourceRef),
|
||||
);
|
||||
}
|
||||
|
||||
res.json({
|
||||
items: requests.map(request => ({
|
||||
id: request.id,
|
||||
result: applyConditions(
|
||||
request.conditions,
|
||||
resourcesByResourceType[request.resourceType][request.resourceRef],
|
||||
ruleMapByResourceType[request.resourceType],
|
||||
resourcesByType[request.resourceType][request.resourceRef],
|
||||
store.getRuleMapper(request.resourceType),
|
||||
)
|
||||
? AuthorizeResult.ALLOW
|
||||
: AuthorizeResult.DENY,
|
||||
@@ -482,29 +491,17 @@ export function createPermissionIntegrationRouter<
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
function isCreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType extends string,
|
||||
TResource,
|
||||
>(
|
||||
options:
|
||||
| { permissions: Array<Permission> }
|
||||
| CreatePermissionIntegrationRouterResourceOptions<
|
||||
return Object.assign(router, {
|
||||
addPermissions(permissions: Permission[]) {
|
||||
store.addPermissions(permissions);
|
||||
},
|
||||
addResourceType<const TResourceType extends string, TResource>(
|
||||
resource: CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>,
|
||||
): options is CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
> {
|
||||
return (
|
||||
(
|
||||
options as CreatePermissionIntegrationRouterResourceOptions<
|
||||
TResourceType,
|
||||
TResource
|
||||
>
|
||||
).resourceType !== undefined
|
||||
);
|
||||
) {
|
||||
store.addResourceType(resource);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user