diff --git a/.changeset/breezy-apes-mate.md b/.changeset/breezy-apes-mate.md new file mode 100644 index 0000000000..65e7dc2bc4 --- /dev/null +++ b/.changeset/breezy-apes-mate.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend': patch +--- + +Added a new `catalog.rules[].location` configuration that makes it possible to configure catalog rules to only apply to specific locations, either via exact match or a glob pattern. diff --git a/plugins/catalog-backend/config.d.ts b/plugins/catalog-backend/config.d.ts index daa70656bc..5c5bfea753 100644 --- a/plugins/catalog-backend/config.d.ts +++ b/plugins/catalog-backend/config.d.ts @@ -38,6 +38,37 @@ export interface Config { * E.g. ["Component", "API", "Template", "Location"] */ allow: Array; + /** + * Limit this rule to a specific location + * + * Example with a fixed location + * { "type": "url", "exact": "https://github.com/a/b/blob/file.yaml"} + * + * Example using a Regex + * { "type": "url", "pattern": "https://github.com/org/*\/blob/master/*.yaml"} + * + * Using both exact and pattern will result in an error starting the application + */ + locations?: Array<{ + /** + * The type of location, e.g. "url". + */ + type: string; + /** + * The exact location, e.g. + * "https://github.com/org/repo/blob/master/users.yaml". + * + * The exact location can also be used to match on locations + * that contain glob characters themselves, e.g. + * "https://github.com/org/*\/blob/master/*.yaml". + */ + exact?: string; + /** + * The pattern allowed for the location, e.g. + * "https://github.com/org/*\/blob/master/*.yaml". + */ + pattern?: string; + }>; }>; /** diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 8455d55857..25c824dd4d 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -59,6 +59,7 @@ "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", + "minimatch": "^5.0.0", "node-fetch": "^2.6.7", "p-limit": "^3.0.2", "prom-client": "^14.0.1", diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts index 2592b8b94b..34e89fad29 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -35,6 +35,10 @@ const entity = { }; const location: Record = { + w: { + type: 'url', + target: 'https://github.com/b/c/blob/master/w.yaml', + }, x: { type: 'url', target: 'https://github.com/a/b/blob/master/x.yaml', @@ -50,6 +54,28 @@ const location: Record = { }; describe('DefaultCatalogRulesEnforcer', () => { + it('should throw an error if both pattern and exact are used', () => { + expect(() => + DefaultCatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [ + { + allow: ['Component'], + locations: [ + { + type: 'url', + pattern: 'https://github.com/b/**', + exact: 'https://github.com/a/b/blob/master/w.yaml', + }, + ], + }, + ], + }, + }), + ), + ).toThrow(/cannot have both exact and pattern values/i); + }); it('should deny by default', () => { const enforcer = new DefaultCatalogRulesEnforcer([]); expect(enforcer.isAllowed(entity.user, location.x)).toBe(false); @@ -205,7 +231,7 @@ describe('DefaultCatalogRulesEnforcer', () => { const enforcer = DefaultCatalogRulesEnforcer.fromConfig( new ConfigReader({ catalog: { - rules: [{ allow: ['Group'], locations: [{ type: 'url' }] }], + rules: [{ allow: ['Group'] }], }, }), ); @@ -216,5 +242,25 @@ describe('DefaultCatalogRulesEnforcer', () => { expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); expect(enforcer.isAllowed(entity.location, location.z)).toBe(false); }); + + it('should only allow locations that match a given pattern', () => { + const enforcer = DefaultCatalogRulesEnforcer.fromConfig( + new ConfigReader({ + catalog: { + rules: [ + { + allow: ['Component'], + locations: [ + { type: 'url', pattern: 'https://github.com/b/**' }, + ], + }, + ], + }, + }), + ); + expect(enforcer.isAllowed(entity.component, location.w)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(false); + }); }); }); diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.ts index 548c71d833..677649d4b2 100644 --- a/plugins/catalog-backend/src/ingestion/CatalogRules.ts +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -18,6 +18,7 @@ import { Config } from '@backstage/config'; import { Entity } from '@backstage/catalog-model'; import path from 'path'; import { LocationSpec } from '@backstage/plugin-catalog-common'; +import minimatch from 'minimatch'; /** * Rules to apply to catalog entities. @@ -29,8 +30,9 @@ export type CatalogRule = { kind: string; }>; locations?: Array<{ - target?: string; + exact?: string; type: string; + pattern?: string; }>; }; @@ -76,6 +78,14 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { * catalog: * rules: * - allow: [Component, API] + * - allow: [Template] + * locations: + * - type: url + * pattern: https://github.com/org/*\/blob/master/template.yaml + * - allow: [Location] + * locations: + * - type: url + * pattern: https://github.com/org/repo/blob/master/location.yaml * * locations: * - type: url @@ -92,9 +102,26 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { const rules = new Array(); if (config.has('catalog.rules')) { - const globalRules = config.getConfigArray('catalog.rules').map(sub => ({ - allow: sub.getStringArray('allow').map(kind => ({ kind })), - })); + const globalRules = config + .getConfigArray('catalog.rules') + .map(ruleConf => ({ + allow: ruleConf.getStringArray('allow').map(kind => ({ kind })), + locations: ruleConf + .getOptionalConfigArray('locations') + ?.map(locationConfig => { + const location = { + pattern: locationConfig.getOptionalString('pattern'), + type: locationConfig.getString('type'), + exact: locationConfig.getOptionalString('exact'), + }; + if (location.pattern && location.exact) { + throw new Error( + 'A catalog rule location cannot have both exact and pattern values', + ); + } + return location; + }), + })); rules.push(...globalRules); } else { rules.push(...DefaultCatalogRulesEnforcer.defaultRules); @@ -108,11 +135,11 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { return []; } const type = locConf.getString('type'); - const target = resolveTarget(type, locConf.getString('target')); + const exact = resolveTarget(type, locConf.getString('target')); return locConf.getConfigArray('rules').map(ruleConf => ({ allow: ruleConf.getStringArray('allow').map(kind => ({ kind })), - locations: [{ type, target }], + locations: [{ type, exact }], })); }); @@ -144,7 +171,7 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { private matchLocation( location: LocationSpec, - matchers?: { target?: string; type: string }[], + matchers?: { exact?: string; type: string; pattern?: string }[], ): boolean { if (!matchers) { return true; @@ -154,7 +181,13 @@ export class DefaultCatalogRulesEnforcer implements CatalogRulesEnforcer { if (matcher.type !== location?.type) { continue; } - if (matcher.target && matcher.target !== location?.target) { + if (matcher.exact && matcher.exact !== location?.target) { + continue; + } + if ( + matcher.pattern && + !minimatch(location?.target, matcher.pattern, { nocase: true }) + ) { continue; } return true; diff --git a/yarn.lock b/yarn.lock index 6fc9e20eac..f91f625f39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5263,6 +5263,7 @@ __metadata: knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 + minimatch: ^5.0.0 msw: ^0.49.0 node-fetch: ^2.6.7 p-limit: ^3.0.2