Merge pull request #31103 from backstage/catalog-add-type-in-rules-allow

Catalog: add optional spec.type to allow rules
This commit is contained in:
Vincenzo Scamporlino
2025-09-15 13:15:21 +02:00
committed by GitHub
4 changed files with 104 additions and 8 deletions
+25
View File
@@ -0,0 +1,25 @@
---
'@backstage/plugin-catalog-backend': minor
---
Add support for specifying an entity `spec.type` in `catalog.rules` and `catalog.locations.rules` within the catalog configuration.
For example, this enables allowing all `Template` entities with the type `website`:
```diff
catalog:
rules:
- allow:
- Component
- API
- Resource
- System
- Domain
- Location
+ - allow:
+ - kind: Template
+ spec.type: website
locations:
- type: url
pattern: https://github.com/org/*\/blob/master/*.yaml
```
+4 -1
View File
@@ -38,8 +38,11 @@ export interface Config {
* Allow entities of these particular kinds.
*
* E.g. ["Component", "API", "Template", "Location"]
*
* You can also specify the type of the entity by using an object with `kind` and optional `spec.type` properties.
* E.g. [{ kind: "Component", 'spec.type': "service" }]
*/
allow: Array<string>;
allow: Array<string | { kind: string; 'spec.type'?: string }>;
/**
* Limit this rule to a specific location
*
@@ -27,8 +27,14 @@ const entity = {
kind: 'Group',
} as Entity,
component: {
kind: 'component',
kind: 'Component',
} as Entity,
componentWithType: {
kind: 'Component',
spec: {
type: 'service',
},
} as unknown as Entity,
location: {
kind: 'Location',
} as Entity,
@@ -164,6 +170,9 @@ describe('DefaultCatalogRulesEnforcer', () => {
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.componentWithType, location.z)).toBe(
true,
);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
@@ -268,6 +277,33 @@ describe('DefaultCatalogRulesEnforcer', () => {
});
});
it('should only allow components with a specific type', () => {
const enforcer = DefaultCatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [
{
allow: [{ kind: 'Component', 'spec.type': 'service' }],
locations: [{ type: 'url', pattern: 'https://github.com/b/**' }],
},
],
},
}),
);
expect(enforcer.isAllowed(entity.component, location.w)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.componentWithType, location.w)).toBe(true);
expect(enforcer.isAllowed(entity.componentWithType, location.y)).toBe(
false,
);
expect(enforcer.isAllowed(entity.componentWithType, location.z)).toBe(
false,
);
});
it('should allow locations with a hidden folder', () => {
const enforcer = DefaultCatalogRulesEnforcer.fromConfig(
new ConfigReader({
@@ -19,6 +19,7 @@ import { Entity } from '@backstage/catalog-model';
import path from 'path';
import { LocationSpec } from '@backstage/plugin-catalog-common';
import { minimatch } from 'minimatch';
import { z } from 'zod';
/**
* Rules to apply to catalog entities.
@@ -26,9 +27,7 @@ import { minimatch } from 'minimatch';
* An undefined list of matchers means match all, an empty list of matchers means match none.
*/
export type CatalogRule = {
allow: Array<{
kind: string;
}>;
allow: CatalogRuleAllow[];
locations?: Array<{
exact?: string;
type: string;
@@ -36,6 +35,11 @@ export type CatalogRule = {
}>;
};
type CatalogRuleAllow = {
kind: string;
'spec.type'?: string;
};
/**
* Decides whether an entity from a given location is allowed to enter the
* catalog, according to some rule set.
@@ -44,6 +48,16 @@ export type CatalogRulesEnforcer = {
isAllowed(entity: Entity, location: LocationSpec): boolean;
};
const allowRuleParser = z.array(
z
.object({
kind: z.string(),
'spec.type': z.string().optional(),
})
.or(z.string())
.transform(val => (typeof val === 'string' ? { kind: val } : val)),
);
/**
* Implements the default catalog rule set, consuming the config keys
* `catalog.rules` and `catalog.locations.[].rules`.
@@ -78,6 +92,9 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer {
* catalog:
* rules:
* - allow: [Component, API]
* - allow:
* - kind: Resource
* 'spec.type': database
* - allow: [Template]
* locations:
* - type: url
@@ -105,7 +122,7 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer {
const globalRules = config
.getConfigArray('catalog.rules')
.map(ruleConf => ({
allow: ruleConf.getStringArray('allow').map(kind => ({ kind })),
allow: allowRuleParser.parse(ruleConf.get('allow')),
locations: ruleConf
.getOptionalConfigArray('locations')
?.map(locationConfig => {
@@ -199,16 +216,31 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer {
return false;
}
private matchEntity(entity: Entity, matchers?: { kind: string }[]): boolean {
private matchEntity(entity: Entity, matchers?: CatalogRuleAllow[]): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (entity?.kind?.toLowerCase() !== matcher.kind.toLowerCase()) {
if (
entity.kind?.toLocaleLowerCase('en-US') !==
matcher.kind.toLocaleLowerCase('en-US')
) {
continue;
}
if (matcher['spec.type']) {
if (typeof entity.spec?.type !== 'string') {
continue;
}
if (
matcher['spec.type'].toLocaleLowerCase('en-US') !==
entity.spec.type.toLocaleLowerCase('en-US')
) {
continue;
}
}
return true;
}