diff --git a/.changeset/rotten-hairs-attack.md b/.changeset/rotten-hairs-attack.md new file mode 100644 index 0000000000..aa6dabc78c --- /dev/null +++ b/.changeset/rotten-hairs-attack.md @@ -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 +``` diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index 6329bcb111..a506476dbf 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -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; + allow: Array; /** * Limit this rule to a specific location * diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts index 19c231884c..a28ceb0d48 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -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({ diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index 8fb237df30..15e620a443 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -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; }