permissions: ensure returned conditions match permission in PermissionPolicy#handle (#10075)

* permission-node: fix signature of permission rule in test suites

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* permission-common: add isPermission helper for comparing permissions

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* permission-node: adjust createConditionExports for more type safety

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* permissions: add resourceType property to PermissionCondition and PermissionRule

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* catalog: handle changes to PermissionCondition and PermissionRule types

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* catalog-backend: avoid re-exporting alpha import

cf. https://github.com/backstage/backstage/pull/10128

Signed-off-by: Mike Lewis <mtlewis@users.noreply.github.com>

* Update changeset

Signed-off-by: Joe Porpeglia <josephp@spotify.com>

* Resolve api-report conflict

Signed-off-by: Joon Park <joonp@spotify.com>

Co-authored-by: Joe Porpeglia <josephp@spotify.com>
Co-authored-by: Joon Park <joonp@spotify.com>
This commit is contained in:
MT Lewis
2022-03-28 11:06:38 +01:00
committed by GitHub
parent ea14b21dc0
commit 8012ac46a0
38 changed files with 676 additions and 193 deletions
+28
View File
@@ -0,0 +1,28 @@
---
'@backstage/plugin-catalog-backend': minor
---
**BREAKING (alpha api):** Replace `createCatalogPolicyDecision` export with `createCatalogConditionalDecision`, which accepts a permission parameter of type `ResourcePermission<'catalog-entity'>` along with conditions. The permission passed is expected to be the handled permission in `PermissionPolicy#handle`, whose type must first be narrowed using methods like `isPermission` and `isResourcePermission`:
```typescript
class TestPermissionPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery<Permission>,
_user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (
// Narrow type of `request.permission` to `ResourcePermission<'catalog-entity'>
isResourcePermission(request.permission, RESOURCE_TYPE_CATALOG_ENTITY)
) {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner(
_user?.identity.ownershipEntityRefs ?? [],
),
);
}
return {
result: AuthorizeResult.ALLOW,
};
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-common': minor
---
Add `resourceType` property to `PermissionCondition` type to allow matching them with `ResourcePermission` instances.
+63
View File
@@ -0,0 +1,63 @@
---
'@backstage/plugin-permission-node': minor
---
**BREAKING**: Stronger typing in `PermissionPolicy` 🎉.
Previously, it was entirely the responsibility of the `PermissionPolicy` author to only return `CONDITIONAL` decisions for permissions that are associated with a resource, and to return the correct kind of `PermissionCondition` instances inside the decision. Now, the policy authoring helpers provided in this package now ensure that the decision and permission match.
**For policy authors**: rename and adjust api of `createConditionExports`. Previously, the function returned a factory for creating conditional decisions named `createPolicyDecision`, which had a couple of drawbacks:
1. The function always creates a _conditional_ policy decision, but this was not reflected in the name.
2. Conditional decisions should only ever be returned from `PermissionPolicy#handle` for resource permissions, but there was nothing in the API that encoded this constraint.
This change addresses the drawbacks above by making the following changes for policy authors:
- The `createPolicyDecision` method has been renamed to `createConditionalDecision`.
- Along with conditions, the method now accepts a permission, which must be a `ResourcePermission`. This is expected to be the handled permission in `PermissionPolicy#handle`, whose type must first be narrowed using methods like `isPermission` and `isResourcePermission`:
```typescript
class TestPermissionPolicy implements PermissionPolicy {
async handle(
request: PolicyQuery<Permission>,
_user?: BackstageIdentityResponse,
): Promise<PolicyDecision> {
if (
// Narrow type of `request.permission` to `ResourcePermission<'catalog-entity'>
isResourcePermission(request.permission, RESOURCE_TYPE_CATALOG_ENTITY)
) {
return createCatalogConditionalDecision(
request.permission,
catalogConditions.isEntityOwner(
_user?.identity.ownershipEntityRefs ?? [],
),
);
}
return {
result: AuthorizeResult.ALLOW,
};
```
**BREAKING**: when creating `PermissionRule`s, provide a `resourceType`.
```diff
export const isEntityOwner = createCatalogPermissionRule({
name: 'IS_ENTITY_OWNER',
description: 'Allow entities owned by the current user',
+ resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: (resource: Entity, claims: string[]) => {
if (!resource.relations) {
return false;
}
return resource.relations
.filter(relation => relation.type === RELATION_OWNED_BY)
.some(relation => claims.includes(relation.targetRef));
},
toQuery: (claims: string[]) => ({
key: 'relations.ownedBy',
values: claims,
}),
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-backend': minor
---
**BREAKING:** Mark CatalogBuilder#addPermissionRules as @alpha.
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-catalog-backend': patch
---
Handle changes to @alpha permission-related types.
- All exported permission rules and conditions now have a `resourceType`.
- `createCatalogConditionalDecision` now expects supplied conditions to have the appropriate `resourceType`.
- `createCatalogPermissionRule` now expects `resourceType` as part of the supplied rule object.
- Introduce new `CatalogPermissionRule` convenience type.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-node': patch
---
Fix signature of permission rule in test suites
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-permission-common': patch
---
Add `isPermission` helper method.
+48 -17
View File
@@ -25,6 +25,7 @@ import { PermissionRule } from '@backstage/plugin-permission-node';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Readable } from 'stream';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { Router } from 'express';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { TokenManager } from '@backstage/backend-common';
@@ -109,13 +110,8 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
export class CatalogBuilder {
addEntityPolicy(...policies: EntityPolicy[]): CatalogBuilder;
addEntityProvider(...providers: EntityProvider[]): CatalogBuilder;
addPermissionRules(
...permissionRules: PermissionRule<
Entity,
EntitiesSearchFilter,
unknown[]
>[]
): void;
// @alpha
addPermissionRules(...permissionRules: CatalogPermissionRule[]): void;
addProcessor(...processors: CatalogProcessor[]): CatalogBuilder;
build(): Promise<{
processingEngine: CatalogProcessingEngine;
@@ -143,23 +139,37 @@ export const catalogConditions: Conditions<{
hasAnnotation: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[annotation: string]
>;
hasLabel: PermissionRule<Entity, EntitiesSearchFilter, [label: string]>;
hasLabel: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[label: string]
>;
hasMetadata: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[key: string, value?: string | undefined]
>;
hasSpec: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[key: string, value?: string | undefined]
>;
isEntityKind: PermissionRule<Entity, EntitiesSearchFilter, [kinds: string[]]>;
isEntityKind: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[kinds: string[]]
>;
isEntityOwner: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[claims: string[]]
>;
}>;
@@ -173,6 +183,10 @@ export type CatalogEnvironment = {
permissions: PermissionAuthorizer;
};
// @alpha
export type CatalogPermissionRule<TParams extends unknown[] = unknown[]> =
PermissionRule<Entity, EntitiesSearchFilter, 'catalog-entity', TParams>;
// @public (undocumented)
export interface CatalogProcessingEngine {
// (undocumented)
@@ -277,14 +291,17 @@ export class CodeOwnersProcessor implements CatalogProcessor {
}
// @alpha
export const createCatalogPermissionRule: <TParams extends unknown[]>(
rule: PermissionRule<Entity, EntitiesSearchFilter, TParams>,
) => PermissionRule<Entity, EntitiesSearchFilter, TParams>;
export const createCatalogConditionalDecision: (
permission: ResourcePermission<'catalog-entity'>,
conditions: PermissionCriteria<
PermissionCondition<'catalog-entity', unknown[]>
>,
) => ConditionalPolicyDecision;
// @alpha
export const createCatalogPolicyDecision: (
conditions: PermissionCriteria<PermissionCondition<unknown[]>>,
) => ConditionalPolicyDecision;
export const createCatalogPermissionRule: <TParams extends unknown[]>(
rule: PermissionRule<Entity, EntitiesSearchFilter, 'catalog-entity', TParams>,
) => PermissionRule<Entity, EntitiesSearchFilter, 'catalog-entity', TParams>;
// @public
export function createRandomProcessingInterval(options: {
@@ -469,23 +486,37 @@ export const permissionRules: {
hasAnnotation: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[annotation: string]
>;
hasLabel: PermissionRule<Entity, EntitiesSearchFilter, [label: string]>;
hasLabel: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[label: string]
>;
hasMetadata: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[key: string, value?: string | undefined]
>;
hasSpec: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[key: string, value?: string | undefined]
>;
isEntityKind: PermissionRule<Entity, EntitiesSearchFilter, [kinds: string[]]>;
isEntityKind: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[kinds: string[]]
>;
isEntityOwner: PermissionRule<
Entity,
EntitiesSearchFilter,
'catalog-entity',
[claims: string[]]
>;
};
@@ -18,41 +18,49 @@ import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { createConditionExports } from '@backstage/plugin-permission-node';
import { permissionRules } from './rules';
const conditionExports = createConditionExports({
const { conditions, createConditionalDecision } = createConditionExports({
pluginId: 'catalog',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
rules: permissionRules,
});
/**
* These conditions are used when creating conditional decisions that are returned
* by authorization policies.
* These conditions are used when creating conditional decisions for catalog
* entities that are returned by authorization policies.
*
* @alpha
*/
export const catalogConditions = conditionExports.conditions;
export const catalogConditions = conditions;
/**
* `createCatalogPolicyDecision` can be used when authoring policies to create
* conditional decisions.
* `createCatalogConditionalDecision` can be used when authoring policies to
* create conditional decisions. It requires a permission of type
* `ResourcePermission<'catalog-entity'>` to be passed as the first parameter.
* It's recommended that you use the provided `isResourcePermission` and
* `isPermission` helper methods to narrow the type of the permission passed to
* the handle method as shown below.
*
* ```
* // MyAuthorizationPolicy.ts
* ...
* import { createCatalogPolicyDecision } from '@backstage/plugin-catalog-backend';
* import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
*
* class MyAuthorizationPolicy implements PermissionPolicy {
* async handle(request, user) {
* ...
*
* return createCatalogPolicyDecision({
* anyOf: [...insert conditions here...],
* });
* }
* if (isResourcePermission(request.permission, RESOURCE_TYPE_CATALOG_ENTITY)) {
* return createCatalogConditionalDecision(
* request.permission,
* { anyOf: [...insert conditions here...] }
* );
* }
*
* ...
* }
* ```
*
* @alpha
*/
export const createCatalogPolicyDecision =
conditionExports.createPolicyDecision;
export const createCatalogConditionalDecision = createConditionalDecision;
@@ -16,6 +16,6 @@
export {
catalogConditions,
createCatalogPolicyDecision,
createCatalogConditionalDecision,
} from './conditionExports';
export * from './rules';
@@ -16,12 +16,14 @@
import { get } from 'lodash';
import { Entity } from '@backstage/catalog-model';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { createCatalogPermissionRule } from './util';
export const createPropertyRule = (propertyType: 'metadata' | 'spec') =>
createCatalogPermissionRule({
name: `HAS_${propertyType.toUpperCase()}`,
description: `Allow entities which have the specified ${propertyType} subfield.`,
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: (resource: Entity, key: string, value?: string) => {
const foundValue = get(resource[propertyType], key);
if (value !== undefined) {
@@ -15,6 +15,7 @@
*/
import { Entity } from '@backstage/catalog-model';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { createCatalogPermissionRule } from './util';
/**
@@ -27,6 +28,7 @@ export const hasAnnotation = createCatalogPermissionRule({
name: 'HAS_ANNOTATION',
description:
'Allow entities which are annotated with the specified annotation',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: (resource: Entity, annotation: string) =>
!!resource.metadata.annotations?.hasOwnProperty(annotation),
toQuery: (annotation: string) => ({
@@ -15,6 +15,7 @@
*/
import { Entity } from '@backstage/catalog-model';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { createCatalogPermissionRule } from './util';
/**
@@ -25,6 +26,7 @@ import { createCatalogPermissionRule } from './util';
export const hasLabel = createCatalogPermissionRule({
name: 'HAS_LABEL',
description: 'Allow entities which have the specified label metadata.',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: (resource: Entity, label: string) =>
!!resource.metadata.labels?.hasOwnProperty(label),
toQuery: (label: string) => ({
@@ -36,4 +36,5 @@ export const permissionRules = {
isEntityOwner,
};
export type { CatalogPermissionRule } from './util';
export { createCatalogPermissionRule } from './util';
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { EntitiesSearchFilter } from '../../catalog/types';
import { createCatalogPermissionRule } from './util';
@@ -25,6 +26,7 @@ import { createCatalogPermissionRule } from './util';
export const isEntityKind = createCatalogPermissionRule({
name: 'IS_ENTITY_KIND',
description: 'Allow entities with the specified kind',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply(resource: Entity, kinds: string[]) {
const resourceKind = resource.kind.toLocaleLowerCase('en-US');
return kinds.some(kind => kind.toLocaleLowerCase('en-US') === resourceKind);
@@ -15,6 +15,7 @@
*/
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import { createCatalogPermissionRule } from './util';
/**
@@ -26,6 +27,7 @@ import { createCatalogPermissionRule } from './util';
export const isEntityOwner = createCatalogPermissionRule({
name: 'IS_ENTITY_OWNER',
description: 'Allow entities owned by the current user',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: (resource: Entity, claims: string[]) => {
if (!resource.relations) {
return false;
@@ -15,9 +15,23 @@
*/
import { Entity } from '@backstage/catalog-model';
import { makeCreatePermissionRule } from '@backstage/plugin-permission-node';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
import {
makeCreatePermissionRule,
PermissionRule,
} from '@backstage/plugin-permission-node';
import { EntitiesSearchFilter } from '../../catalog/types';
/**
* Convenience type for {@link @backstage/plugin-permission-node#PermissionRule}
* instances with the correct resource type, resource, and filter to work with
* the catalog.
*
* @alpha
*/
export type CatalogPermissionRule<TParams extends unknown[] = unknown[]> =
PermissionRule<Entity, EntitiesSearchFilter, 'catalog-entity', TParams>;
/**
* Helper function for creating correctly-typed
* {@link @backstage/plugin-permission-node#PermissionRule}s for the
@@ -27,5 +41,6 @@ import { EntitiesSearchFilter } from '../../catalog/types';
*/
export const createCatalogPermissionRule = makeCreatePermissionRule<
Entity,
EntitiesSearchFilter
EntitiesSearchFilter,
typeof RESOURCE_TYPE_CATALOG_ENTITY
>();
@@ -14,15 +14,11 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { NotAllowedError } from '@backstage/errors';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
createConditionTransformer,
PermissionRule,
} from '@backstage/plugin-permission-node';
import { EntitiesSearchFilter } from '../catalog/types';
import { createConditionTransformer } from '@backstage/plugin-permission-node';
import { isEntityKind } from '../permissions/rules/isEntityKind';
import { CatalogPermissionRule } from '../permissions/rules';
import { AuthorizedEntitiesCatalog } from './AuthorizedEntitiesCatalog';
describe('AuthorizedEntitiesCatalog', () => {
@@ -36,9 +32,7 @@ describe('AuthorizedEntitiesCatalog', () => {
authorize: jest.fn(),
};
const createCatalog = (
...rules: PermissionRule<Entity, EntitiesSearchFilter, unknown[]>[]
) =>
const createCatalog = (...rules: CatalogPermissionRule[]) =>
new AuthorizedEntitiesCatalog(
fakeCatalog,
fakePermissionApi,
@@ -17,7 +17,6 @@
import { PluginDatabaseManager, UrlReader } from '@backstage/backend-common';
import {
DefaultNamespaceEntityPolicy,
Entity,
EntityPolicies,
EntityPolicy,
FieldFormatEntityPolicy,
@@ -32,7 +31,6 @@ import { ScmIntegrations } from '@backstage/integration';
import { createHash } from 'crypto';
import { Router } from 'express';
import lodash, { keyBy } from 'lodash';
import { EntitiesSearchFilter } from '../catalog';
import {
CatalogProcessor,
@@ -77,10 +75,12 @@ import { DefaultCatalogRulesEnforcer } from '../ingestion/CatalogRules';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import { connectEntityProviders } from '../processing/connectEntityProviders';
import { permissionRules as catalogPermissionRules } from '../permissions/rules';
import {
CatalogPermissionRule,
permissionRules as catalogPermissionRules,
} from '../permissions/rules';
import { PermissionAuthorizer } from '@backstage/plugin-permission-common';
import {
PermissionRule,
createConditionTransformer,
createPermissionIntegrationRouter,
} from '@backstage/plugin-permission-node';
@@ -135,11 +135,7 @@ export class CatalogBuilder {
maxSeconds: 150,
});
private locationAnalyzer: LocationAnalyzer | undefined = undefined;
private permissionRules: PermissionRule<
Entity,
EntitiesSearchFilter,
unknown[]
>[];
private permissionRules: CatalogPermissionRule[];
/**
* Creates a catalog builder.
@@ -339,14 +335,9 @@ export class CatalogBuilder {
* {@link @backstage/plugin-permission-node#PermissionRule}.
*
* @param permissionRules - Additional permission rules
* @alpha
*/
addPermissionRules(
...permissionRules: PermissionRule<
Entity,
EntitiesSearchFilter,
unknown[]
>[]
) {
addPermissionRules(...permissionRules: CatalogPermissionRule[]) {
this.permissionRules.push(...permissionRules);
}
@@ -26,7 +26,10 @@ import { LocationInput, LocationService, RefreshService } from './types';
import { basicEntityFilter } from './request';
import { createRouter } from './createRouter';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import {
createPermissionIntegrationRouter,
createPermissionRule,
} from '@backstage/plugin-permission-node';
import { RESOURCE_TYPE_CATALOG_ENTITY } from '@backstage/plugin-catalog-common';
describe('createRouter readonly disabled', () => {
@@ -568,12 +571,13 @@ describe('NextRouter permissioning', () => {
let app: express.Express;
let refreshService: RefreshService;
const fakeRule = {
const fakeRule = createPermissionRule({
name: 'FAKE_RULE',
description: 'fake rule',
resourceType: RESOURCE_TYPE_CATALOG_ENTITY,
apply: () => true,
toQuery: () => ({ key: '', values: [] }),
};
});
beforeAll(async () => {
entitiesCatalog = {
@@ -631,7 +635,11 @@ describe('NextRouter permissioning', () => {
id: '123',
resourceType: 'catalog-entity',
resourceRef: 'component:default/spidey-sense',
conditions: { rule: 'FAKE_RULE', params: ['user:default/spiderman'] },
conditions: {
rule: 'FAKE_RULE',
resourceType: 'catalog-entity',
params: ['user:default/spiderman'],
},
},
],
};
@@ -25,7 +25,10 @@ import {
PermissionCondition,
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import {
createPermissionIntegrationRouter,
createPermissionRule,
} from '@backstage/plugin-permission-node';
import { PermissionIntegrationClient } from './PermissionIntegrationClient';
describe('PermissionIntegrationClient', () => {
@@ -35,8 +38,8 @@ describe('PermissionIntegrationClient', () => {
const mockConditions: PermissionCriteria<PermissionCondition> = {
not: {
allOf: [
{ rule: 'RULE_1', params: [] },
{ rule: 'RULE_2', params: ['abc'] },
{ rule: 'RULE_1', resourceType: 'test-resource', params: [] },
{ rule: 'RULE_2', resourceType: 'test-resource', params: ['abc'] },
],
},
};
@@ -272,22 +275,24 @@ describe('PermissionIntegrationClient', () => {
id: resourceRef,
})),
rules: [
{
createPermissionRule({
name: 'RULE_1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: (_resource: any, input: 'yes' | 'no') => input === 'yes',
toQuery: () => {
throw new Error('Not implemented');
},
},
{
}),
createPermissionRule({
name: 'RULE_2',
description: 'Test rule 2',
resourceType: 'test-resource',
apply: (_resource: any, input: 'yes' | 'no') => input === 'yes',
toQuery: () => {
throw new Error('Not implemented');
},
},
}),
],
}),
);
@@ -336,7 +341,11 @@ describe('PermissionIntegrationClient', () => {
id: '123',
resourceRef: 'testResource1',
resourceType: 'test-resource',
conditions: { rule: 'RULE_1', params: ['no'] },
conditions: {
rule: 'RULE_1',
resourceType: 'test-resource',
params: ['no'],
},
},
]),
).resolves.toEqual([{ id: '123', result: AuthorizeResult.DENY }]);
@@ -353,15 +362,33 @@ describe('PermissionIntegrationClient', () => {
allOf: [
{
allOf: [
{ rule: 'RULE_1', params: ['yes'] },
{ not: { rule: 'RULE_2', params: ['no'] } },
{
rule: 'RULE_1',
resourceType: 'test-resource',
params: ['yes'],
},
{
not: {
rule: 'RULE_2',
resourceType: 'test-resource',
params: ['no'],
},
},
],
},
{
not: {
allOf: [
{ rule: 'RULE_1', params: ['no'] },
{ rule: 'RULE_2', params: ['yes'] },
{
rule: 'RULE_1',
resourceType: 'test-resource',
params: ['no'],
},
{
rule: 'RULE_2',
resourceType: 'test-resource',
params: ['yes'],
},
],
},
},
+11 -1
View File
@@ -89,6 +89,12 @@ export function isCreatePermission(permission: Permission): boolean;
// @public
export function isDeletePermission(permission: Permission): boolean;
// @public
export function isPermission<T extends Permission>(
permission: Permission,
comparedPermission: T,
): permission is T;
// @public
export function isReadPermission(permission: Permission): boolean;
@@ -141,7 +147,11 @@ export class PermissionClient implements PermissionAuthorizer {
}
// @public
export type PermissionCondition<TParams extends unknown[] = unknown[]> = {
export type PermissionCondition<
TResourceType extends string = string,
TParams extends unknown[] = unknown[],
> = {
resourceType: TResourceType;
rule: string;
params: TParams;
};
@@ -41,6 +41,7 @@ const permissionCriteriaSchema: z.ZodSchema<
z
.object({
rule: z.string(),
resourceType: z.string(),
params: z.array(z.unknown()),
})
.strict()
@@ -16,6 +16,17 @@
import { Permission, ResourcePermission } from '../types';
/**
* Check if the two parameters are equivalent permissions.
* @public
*/
export function isPermission<T extends Permission>(
permission: Permission,
comparedPermission: T,
): permission is T {
return permission.name === comparedPermission.name;
}
/**
* Check if a given permission is a {@link ResourcePermission}. When
* `resourceType` is supplied as the second parameter, also checks if
+5 -1
View File
@@ -98,7 +98,11 @@ export type PolicyDecision =
* claims from a identity token.
* @public
*/
export type PermissionCondition<TParams extends unknown[] = unknown[]> = {
export type PermissionCondition<
TResourceType extends string = string,
TParams extends unknown[] = unknown[],
> = {
resourceType: TResourceType;
rule: string;
params: TParams;
};
+40 -23
View File
@@ -21,6 +21,7 @@ import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionCriteria } from '@backstage/plugin-permission-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { PolicyDecision } from '@backstage/plugin-permission-common';
import { ResourcePermission } from '@backstage/plugin-permission-common';
import { TokenManager } from '@backstage/backend-common';
// @public
@@ -48,14 +49,15 @@ export type ApplyConditionsResponseEntry =
export type Condition<TRule> = TRule extends PermissionRule<
any,
any,
infer TResourceType,
infer TParams
>
? (...params: TParams) => PermissionCondition<TParams>
? (...params: TParams) => PermissionCondition<TResourceType, TParams>
: never;
// @public
export type Conditions<
TRules extends Record<string, PermissionRule<any, any>>,
TRules extends Record<string, PermissionRule<any, any, any>>,
> = {
[Name in keyof TRules]: Condition<TRules[Name]>;
};
@@ -67,39 +69,49 @@ export type ConditionTransformer<TQuery> = (
// @public
export const createConditionExports: <
TResourceType extends string,
TResource,
TRules extends Record<string, PermissionRule<TResource, any, unknown[]>>,
TRules extends Record<
string,
PermissionRule<TResource, any, TResourceType, unknown[]>
>,
>(options: {
pluginId: string;
resourceType: string;
resourceType: TResourceType;
rules: TRules;
}) => {
conditions: Conditions<TRules>;
createPolicyDecision: (
conditions: PermissionCriteria<PermissionCondition>,
createConditionalDecision: (
permission: ResourcePermission<TResourceType>,
conditions: PermissionCriteria<
PermissionCondition<TResourceType, unknown[]>
>,
) => ConditionalPolicyDecision;
};
// @public
export const createConditionFactory: <TParams extends any[]>(
rule: PermissionRule<unknown, unknown, TParams>,
) => (...params: TParams) => {
rule: string;
params: TParams;
};
export const createConditionFactory: <
TResourceType extends string,
TParams extends any[],
>(
rule: PermissionRule<unknown, unknown, TResourceType, TParams>,
) => (...params: TParams) => PermissionCondition<TResourceType, TParams>;
// @public
export const createConditionTransformer: <
TQuery,
TRules extends PermissionRule<any, TQuery, unknown[]>[],
TRules extends PermissionRule<any, TQuery, string, unknown[]>[],
>(
permissionRules: [...TRules],
) => ConditionTransformer<TQuery>;
// @public
export const createPermissionIntegrationRouter: <TResource>(options: {
resourceType: string;
rules: PermissionRule<TResource, any, unknown[]>[];
export const createPermissionIntegrationRouter: <
TResourceType extends string,
TResource,
>(options: {
resourceType: TResourceType;
rules: PermissionRule<TResource, any, NoInfer<TResourceType>, unknown[]>[];
getResources: (resourceRefs: string[]) => Promise<(TResource | undefined)[]>;
}) => express.Router;
@@ -107,10 +119,11 @@ export const createPermissionIntegrationRouter: <TResource>(options: {
export const createPermissionRule: <
TResource,
TQuery,
TResourceType extends string,
TParams extends unknown[],
>(
rule: PermissionRule<TResource, TQuery, TParams>,
) => PermissionRule<TResource, TQuery, TParams>;
rule: PermissionRule<TResource, TQuery, TResourceType, TParams>,
) => PermissionRule<TResource, TQuery, TResourceType, TParams>;
// @alpha
export const isAndCriteria: <T>(
@@ -128,11 +141,13 @@ export const isOrCriteria: <T>(
) => criteria is AnyOfCriteria<T>;
// @public
export const makeCreatePermissionRule: <TResource, TQuery>() => <
TParams extends unknown[],
>(
rule: PermissionRule<TResource, TQuery, TParams>,
) => PermissionRule<TResource, TQuery, TParams>;
export const makeCreatePermissionRule: <
TResource,
TQuery,
TResourceType extends string,
>() => <TParams extends unknown[]>(
rule: PermissionRule<TResource, TQuery, TResourceType, TParams>,
) => PermissionRule<TResource, TQuery, TResourceType, TParams>;
// @public
export interface PermissionPolicy {
@@ -147,10 +162,12 @@ export interface PermissionPolicy {
export type PermissionRule<
TResource,
TQuery,
TResourceType extends string,
TParams extends unknown[] = unknown[],
> = {
name: string;
description: string;
resourceType: TResourceType;
apply(resource: TResource, ...params: TParams): boolean;
toQuery(...params: TParams): PermissionCriteria<TQuery>;
};
@@ -14,17 +14,22 @@
* limitations under the License.
*/
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import {
AuthorizeResult,
createPermission,
} from '@backstage/plugin-permission-common';
import { createConditionExports } from './createConditionExports';
import { createPermissionRule } from './createPermissionRule';
const testIntegration = () =>
createConditionExports({
pluginId: 'test-plugin',
resourceType: 'test-resource',
rules: {
testRule1: {
testRule1: createPermissionRule({
name: 'testRule1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: jest.fn(
(_resource: any, _firstParam: string, _secondParam: number) => true,
),
@@ -32,16 +37,17 @@ const testIntegration = () =>
query: 'testRule1',
params: [firstParam, secondParam],
})),
},
testRule2: {
}),
testRule2: createPermissionRule({
name: 'testRule2',
description: 'Test rule 2',
apply: jest.fn((_firstParam: object) => false),
resourceType: 'test-resource',
apply: jest.fn((_resource: any, _firstParam: object) => false),
toQuery: jest.fn((firstParam: object) => ({
query: 'testRule2',
params: [firstParam],
})),
},
}),
},
});
@@ -52,11 +58,13 @@ describe('createConditionExports', () => {
expect(conditions.testRule1('a', 1)).toEqual({
rule: 'testRule1',
resourceType: 'test-resource',
params: ['a', 1],
});
expect(conditions.testRule2({ baz: 'quux' })).toEqual({
rule: 'testRule2',
resourceType: 'test-resource',
params: [{ baz: 'quux' }],
});
});
@@ -64,18 +72,35 @@ describe('createConditionExports', () => {
describe('createPolicyDecisions', () => {
it('wraps conditions in an object with resourceType and pluginId', () => {
const { createPolicyDecision } = testIntegration();
const { createConditionalDecision } = testIntegration();
const testPermission = createPermission({
name: 'test.permission',
attributes: {},
resourceType: 'test-resource',
});
expect(
createPolicyDecision({
allOf: [{ rule: 'testRule1', params: ['a', 1] }],
createConditionalDecision(testPermission, {
allOf: [
{
rule: 'testRule1',
resourceType: 'test-resource',
params: ['a', 1],
},
],
}),
).toEqual({
result: AuthorizeResult.CONDITIONAL,
pluginId: 'test-plugin',
resourceType: 'test-resource',
conditions: {
allOf: [{ rule: 'testRule1', params: ['a', 1] }],
allOf: [
{
rule: 'testRule1',
resourceType: 'test-resource',
params: ['a', 1],
},
],
},
});
});
@@ -19,6 +19,7 @@ import {
ConditionalPolicyDecision,
PermissionCondition,
PermissionCriteria,
ResourcePermission,
} from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
import { createConditionFactory } from './createConditionFactory';
@@ -32,9 +33,10 @@ import { createConditionFactory } from './createConditionFactory';
export type Condition<TRule> = TRule extends PermissionRule<
any,
any,
infer TResourceType,
infer TParams
>
? (...params: TParams) => PermissionCondition<TParams>
? (...params: TParams) => PermissionCondition<TResourceType, TParams>
: never;
/**
@@ -44,39 +46,44 @@ export type Condition<TRule> = TRule extends PermissionRule<
* @public
*/
export type Conditions<
TRules extends Record<string, PermissionRule<any, any>>,
TRules extends Record<string, PermissionRule<any, any, any>>,
> = {
[Name in keyof TRules]: Condition<TRules[Name]>;
};
/**
* Creates the recommended condition-related exports for a given plugin based on the built-in
* {@link PermissionRule}s it supports.
* Creates the recommended condition-related exports for a given plugin based on
* the built-in {@link PermissionRule}s it supports.
*
* @remarks
*
* The function returns a `conditions` object containing a
* {@link @backstage/plugin-permission-common#PermissionCondition} factory for each of the
* supplied {@link PermissionRule}s, along with a `createConditions` function which builds the
* wrapper object needed to enclose conditions when authoring {@link PermissionPolicy} implementations.
* {@link @backstage/plugin-permission-common#PermissionCondition} factory for
* each of the supplied {@link PermissionRule}s, along with a
* `createConditionalDecision` function which builds the wrapper object needed
* to enclose conditions when authoring {@link PermissionPolicy}
* implementations.
*
* Plugin authors should generally call this method with all the built-in {@link PermissionRule}s
* the plugin supports, and export the resulting `conditions` object and `createConditions`
* function so that they can be used by {@link PermissionPolicy} authors.
* Plugin authors should generally call this method with all the built-in
* {@link PermissionRule}s the plugin supports, and export the resulting
* `conditions` object and `createConditionalDecision` function so that they can
* be used by {@link PermissionPolicy} authors.
*
* @public
*/
export const createConditionExports = <
TResourceType extends string,
TResource,
TRules extends Record<string, PermissionRule<TResource, any>>,
TRules extends Record<string, PermissionRule<TResource, any, TResourceType>>,
>(options: {
pluginId: string;
resourceType: string;
resourceType: TResourceType;
rules: TRules;
}): {
conditions: Conditions<TRules>;
createPolicyDecision: (
conditions: PermissionCriteria<PermissionCondition>,
createConditionalDecision: (
permission: ResourcePermission<TResourceType>,
conditions: PermissionCriteria<PermissionCondition<TResourceType>>,
) => ConditionalPolicyDecision;
} => {
const { pluginId, resourceType, rules } = options;
@@ -89,7 +96,8 @@ export const createConditionExports = <
}),
{} as Conditions<TRules>,
),
createPolicyDecision: (
createConditionalDecision: (
_permission: ResourcePermission<TResourceType>,
conditions: PermissionCriteria<PermissionCondition>,
) => ({
result: AuthorizeResult.CONDITIONAL,
@@ -15,14 +15,16 @@
*/
import { createConditionFactory } from './createConditionFactory';
import { createPermissionRule } from './createPermissionRule';
describe('createConditionFactory', () => {
const testRule = {
const testRule = createPermissionRule({
name: 'test-rule',
description: 'test-description',
resourceType: 'test-resource',
apply: jest.fn(),
toQuery: jest.fn(),
};
});
it('returns a function', () => {
expect(createConditionFactory(testRule)).toEqual(expect.any(Function));
@@ -33,6 +35,7 @@ describe('createConditionFactory', () => {
const conditionFactory = createConditionFactory(testRule);
expect(conditionFactory('a', 'b', 1, 2)).toEqual({
rule: 'test-rule',
resourceType: 'test-resource',
params: ['a', 'b', 1, 2],
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { PermissionCondition } from '@backstage/plugin-permission-common';
import { PermissionRule } from '../types';
/**
@@ -33,8 +34,11 @@ import { PermissionRule } from '../types';
* @public
*/
export const createConditionFactory =
<TParams extends any[]>(rule: PermissionRule<unknown, unknown, TParams>) =>
(...params: TParams) => ({
<TResourceType extends string, TParams extends any[]>(
rule: PermissionRule<unknown, unknown, TResourceType, TParams>,
) =>
(...params: TParams): PermissionCondition<TResourceType, TParams> => ({
rule: rule.name,
resourceType: rule.resourceType,
params,
});
@@ -19,25 +19,28 @@ import {
PermissionCriteria,
} from '@backstage/plugin-permission-common';
import { createConditionTransformer } from './createConditionTransformer';
import { createPermissionRule } from './createPermissionRule';
const transformConditions = createConditionTransformer([
{
createPermissionRule({
name: 'test-rule-1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: jest.fn(),
toQuery: jest.fn(
(firstParam: string, secondParam: number) =>
`test-rule-1:${firstParam}/${secondParam}`,
),
},
{
}),
createPermissionRule({
name: 'test-rule-2',
description: 'Test rule 2',
resourceType: 'test-resource',
apply: jest.fn(),
toQuery: jest.fn(
(firstParam: object) => `test-rule-2:${JSON.stringify(firstParam)}`,
),
},
}),
]);
describe('createConditionTransformer', () => {
@@ -46,18 +49,30 @@ describe('createConditionTransformer', () => {
expectedResult: PermissionCriteria<string>;
}[] = [
{
conditions: { rule: 'test-rule-1', params: ['abc', 123] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['abc', 123],
},
expectedResult: 'test-rule-1:abc/123',
},
{
conditions: { rule: 'test-rule-2', params: [{ foo: 0 }] },
conditions: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ foo: 0 }],
},
expectedResult: 'test-rule-2:{"foo":0}',
},
{
conditions: {
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{ rule: 'test-rule-2', resourceType: 'test-resource', params: [{}] },
],
},
expectedResult: {
@@ -67,8 +82,12 @@ describe('createConditionTransformer', () => {
{
conditions: {
allOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{ rule: 'test-rule-2', resourceType: 'test-resource', params: [{}] },
],
},
expectedResult: {
@@ -77,7 +96,11 @@ describe('createConditionTransformer', () => {
},
{
conditions: {
not: { rule: 'test-rule-2', params: [{}] },
not: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{}],
},
},
expectedResult: {
not: 'test-rule-2:{}',
@@ -88,15 +111,31 @@ describe('createConditionTransformer', () => {
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{}],
},
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['b', 2] },
{ rule: 'test-rule-2', params: [{ c: 3 }] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['b', 2],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ c: 3 }],
},
],
},
},
@@ -120,15 +159,33 @@ describe('createConditionTransformer', () => {
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{ b: 2 }] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ b: 2 }],
},
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['c', 3] },
{ not: { rule: 'test-rule-2', params: [{ d: 4 }] } },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['c', 3],
},
{
not: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ d: 4 }],
},
},
],
},
},
@@ -29,7 +29,7 @@ import {
const mapConditions = <TQuery>(
criteria: PermissionCriteria<PermissionCondition>,
getRule: (name: string) => PermissionRule<unknown, TQuery>,
getRule: (name: string) => PermissionRule<unknown, TQuery, string>,
): PermissionCriteria<TQuery> => {
if (isAndCriteria(criteria)) {
return {
@@ -70,7 +70,7 @@ export type ConditionTransformer<TQuery> = (
*/
export const createConditionTransformer = <
TQuery,
TRules extends PermissionRule<any, TQuery>[],
TRules extends PermissionRule<any, TQuery, string>[],
>(
permissionRules: [...TRules],
): ConditionTransformer<TQuery> => {
@@ -18,6 +18,7 @@ import { AuthorizeResult } from '@backstage/plugin-permission-common';
import express, { Express, Router } from 'express';
import request, { Response } from 'supertest';
import { createPermissionIntegrationRouter } from './createPermissionIntegrationRouter';
import { createPermissionRule } from './createPermissionRule';
const mockGetResources: jest.MockedFunction<
Parameters<typeof createPermissionIntegrationRouter>[0]['getResources']
@@ -25,21 +26,23 @@ const mockGetResources: jest.MockedFunction<
resourceRefs.map(resourceRef => ({ id: resourceRef })),
);
const testRule1 = {
const testRule1 = createPermissionRule({
name: 'test-rule-1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: jest.fn(
(_resource: any, _firstParam: string, _secondParam: number) => true,
),
toQuery: jest.fn(),
};
});
const testRule2 = {
const testRule2 = createPermissionRule({
name: 'test-rule-2',
description: 'Test rule 2',
apply: jest.fn((_firstParam: object) => false),
resourceType: 'test-resource',
apply: jest.fn((_resource: any, _firstParam: object) => false),
toQuery: jest.fn(),
};
});
describe('createPermissionIntegrationRouter', () => {
let app: Express;
@@ -65,29 +68,57 @@ describe('createPermissionIntegrationRouter', () => {
describe('POST /.well-known/backstage/permissions/apply-conditions', () => {
it.each([
{ rule: 'test-rule-1', params: ['abc', 123] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['abc', 123],
},
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{ rule: 'test-rule-2', resourceType: 'test-resource', params: [{}] },
],
},
{
not: { rule: 'test-rule-2', params: [{}] },
not: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{}],
},
},
{
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{}],
},
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['b', 2] },
{ rule: 'test-rule-2', params: [{ c: 3 }] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['b', 2],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ c: 3 }],
},
],
},
},
@@ -119,26 +150,52 @@ describe('createPermissionIntegrationRouter', () => {
});
it.each([
{ rule: 'test-rule-2', params: [{ foo: 0 }] },
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ foo: 0 }],
},
{
allOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{}] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{ rule: 'test-rule-2', resourceType: 'test-resource', params: [{}] },
],
},
{
allOf: [
{
anyOf: [
{ rule: 'test-rule-1', params: ['a', 1] },
{ rule: 'test-rule-2', params: [{ b: 2 }] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['a', 1],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ b: 2 }],
},
],
},
{
not: {
allOf: [
{ rule: 'test-rule-1', params: ['c', 3] },
{ not: { rule: 'test-rule-2', params: [{ d: 4 }] } },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: ['c', 3],
},
{
not: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [{ d: 4 }],
},
},
],
},
},
@@ -179,25 +236,45 @@ describe('createPermissionIntegrationRouter', () => {
id: '123',
resourceRef: 'default:test/resource-1',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-1', params: [] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
{
id: '234',
resourceRef: 'default:test/resource-1',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-2', params: [] },
conditions: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [],
},
},
{
id: '345',
resourceRef: 'default:test/resource-2',
resourceType: 'test-resource',
conditions: { not: { rule: 'test-rule-1', params: [] } },
conditions: {
not: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
},
{
id: '456',
resourceRef: 'default:test/resource-3',
resourceType: 'test-resource',
conditions: { not: { rule: 'test-rule-2', params: [] } },
conditions: {
not: {
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [],
},
},
},
{
id: '567',
@@ -205,8 +282,16 @@ describe('createPermissionIntegrationRouter', () => {
resourceType: 'test-resource',
conditions: {
anyOf: [
{ rule: 'test-rule-1', params: [] },
{ rule: 'test-rule-2', params: [] },
{
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
{
rule: 'test-rule-2',
resourceType: 'test-resource',
params: [],
},
],
},
},
@@ -248,6 +333,7 @@ describe('createPermissionIntegrationRouter', () => {
resourceType: 'test-incorrect-resource-1',
conditions: {
rule: 'test-rule-1',
resourceType: 'test-incorrect-resource-1',
params: [{}],
},
},
@@ -257,6 +343,7 @@ describe('createPermissionIntegrationRouter', () => {
resourceType: 'test-resource',
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [{}],
},
},
@@ -266,6 +353,7 @@ describe('createPermissionIntegrationRouter', () => {
resourceType: 'test-incorrect-resource-2',
conditions: {
rule: 'test-rule-1',
resourceType: 'test-incorrect-resource-2',
params: [{}],
},
},
@@ -291,7 +379,11 @@ describe('createPermissionIntegrationRouter', () => {
id: '123',
resourceRef: 'default:test/resource',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-1', params: [] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
],
});
@@ -324,19 +416,31 @@ describe('createPermissionIntegrationRouter', () => {
id: '123',
resourceRef: 'default:test/resource-1',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-1', params: [] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
{
id: '234',
resourceRef: 'default:test/missing-resource',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-1', params: [] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
{
id: '345',
resourceRef: 'default:test/resource-2',
resourceType: 'test-resource',
conditions: { rule: 'test-rule-1', params: [] },
conditions: {
rule: 'test-rule-1',
resourceType: 'test-resource',
params: [],
},
},
],
});
@@ -377,6 +481,17 @@ describe('createPermissionIntegrationRouter', () => {
],
},
{ items: [{ conditions: { anyOf: [] } }] },
{
items: [
{ conditions: { rule: 'TEST_RULE', resourceType: 'test-resource' } },
],
},
{ items: [{ conditions: { rule: 'TEST_RULE', params: ['foo'] } }] },
{
items: [
{ conditions: { resourceType: 'test-resource', params: ['foo'] } },
],
},
])(`returns 400 for invalid input %#`, async input => {
const response = await request(app)
.post('/.well-known/backstage/permissions/apply-conditions')
@@ -44,6 +44,7 @@ const permissionCriteriaSchema: z.ZodSchema<
z
.object({
rule: z.string(),
resourceType: z.string(),
params: z.array(z.unknown()),
})
.strict(),
@@ -100,10 +101,10 @@ export type ApplyConditionsResponse = {
items: ApplyConditionsResponseEntry[];
};
const applyConditions = <TResource>(
criteria: PermissionCriteria<PermissionCondition>,
const applyConditions = <TResourceType extends string, TResource>(
criteria: PermissionCriteria<PermissionCondition<TResourceType>>,
resource: TResource | undefined,
getRule: (name: string) => PermissionRule<TResource, unknown>,
getRule: (name: string) => PermissionRule<TResource, unknown, TResourceType>,
): boolean => {
// If resource was not found, deny. This avoids leaking information from the
// apply-conditions API which would allow a user to differentiate between
@@ -127,6 +128,14 @@ const applyConditions = <TResource>(
return getRule(criteria.rule).apply(resource, ...criteria.params);
};
/**
* Prevent use of type parameter from contributing to type inference.
*
* https://github.com/Microsoft/TypeScript/issues/14829#issuecomment-980401795
* @ignore
*/
type NoInfer<T> = T extends infer S ? S : never;
/**
* Create an express Router which provides an authorization route to allow
* integration between the permission backend and other Backstage backend
@@ -162,9 +171,16 @@ const applyConditions = <TResource>(
*
* @public
*/
export const createPermissionIntegrationRouter = <TResource>(options: {
resourceType: string;
rules: PermissionRule<TResource, any>[];
export const createPermissionIntegrationRouter = <
TResourceType extends string,
TResource,
>(options: {
resourceType: TResourceType;
// Do not infer value of TResourceType from supplied rules.
// instead only consider the resourceType parameter, and
// consider any rules whose resource type does not match
// to be an error.
rules: PermissionRule<TResource, any, NoInfer<TResourceType>>[];
getResources: (
resourceRefs: string[],
) => Promise<Array<TResource | undefined>>;
@@ -24,9 +24,10 @@ import { PermissionRule } from '../types';
export const createPermissionRule = <
TResource,
TQuery,
TResourceType extends string,
TParams extends unknown[],
>(
rule: PermissionRule<TResource, TQuery, TParams>,
rule: PermissionRule<TResource, TQuery, TResourceType, TParams>,
) => rule;
/**
@@ -38,8 +39,8 @@ export const createPermissionRule = <
* @public
*/
export const makeCreatePermissionRule =
<TResource, TQuery>() =>
<TResource, TQuery, TResourceType extends string>() =>
<TParams extends unknown[]>(
rule: PermissionRule<TResource, TQuery, TParams>,
rule: PermissionRule<TResource, TQuery, TResourceType, TParams>,
) =>
createPermissionRule(rule);
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createPermissionRule } from './createPermissionRule';
import {
createGetRule,
isAndCriteria,
@@ -25,19 +26,21 @@ describe('permission integration utils', () => {
describe('createGetRule', () => {
let getRule: ReturnType<typeof createGetRule>;
const testRule1 = {
const testRule1 = createPermissionRule({
name: 'test-rule-1',
description: 'Test rule 1',
resourceType: 'test-resource',
apply: jest.fn(),
toQuery: jest.fn(),
};
});
const testRule2 = {
const testRule2 = createPermissionRule({
name: 'test-rule-2',
description: 'Test rule 2',
resourceType: 'test-resource',
apply: jest.fn(),
toQuery: jest.fn(),
};
});
beforeEach(() => {
getRule = createGetRule([testRule1, testRule2]);
@@ -62,11 +62,11 @@ export const isNotCriteria = <T>(
Object.prototype.hasOwnProperty.call(criteria, 'not');
export const createGetRule = <TResource, TQuery>(
rules: PermissionRule<TResource, TQuery>[],
rules: PermissionRule<TResource, TQuery, string>[],
) => {
const rulesMap = new Map(Object.values(rules).map(rule => [rule.name, rule]));
return (name: string): PermissionRule<TResource, TQuery> => {
return (name: string): PermissionRule<TResource, TQuery, string> => {
const rule = rulesMap.get(name);
if (!rule) {
+2
View File
@@ -35,10 +35,12 @@ import type { PermissionCriteria } from '@backstage/plugin-permission-common';
export type PermissionRule<
TResource,
TQuery,
TResourceType extends string,
TParams extends unknown[] = unknown[],
> = {
name: string;
description: string;
resourceType: TResourceType;
/**
* Apply this rule to a resource already loaded from a backing data source. The params are