diff --git a/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts new file mode 100644 index 0000000000..7f2ccdc254 --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.test.ts @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { LocationSpec, Entity } from '@backstage/catalog-model'; +import { CatalogRulesEnforcer } from './CatalogRules'; + +const entity = { + user: { + kind: 'User', + } as Entity, + group: { + kind: 'Group', + } as Entity, + component: { + kind: 'component', + } as Entity, +}; + +const location: Record = { + x: { + type: 'github', + target: 'https://github.com/a/b/blob/master/x.yaml', + }, + y: { + type: 'github', + target: 'https://github.com/a/b/blob/master/y.yaml', + }, + z: { + type: 'file', + target: '/root/z.yaml', + }, +}; + +describe('CatalogRulesEnforcer', () => { + it('should allow by default', () => { + const enforcer = new CatalogRulesEnforcer([]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should deny all', () => { + const enforcer = new CatalogRulesEnforcer([{ allow: [] }]); + 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(false); + }); + + it('should allow all with override', () => { + const enforcer = new CatalogRulesEnforcer([{ allow: [] }, { deny: [] }]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should deny groups', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [], deny: [{ kind: 'Group' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(false); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should deny groups from github', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [], deny: [{ kind: 'Group' }], locations: [{ type: 'github' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should override to allow groups from files', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [], deny: [{ kind: 'Group' }] }, + { allow: [{ kind: 'Group' }], deny: [], locations: [{ type: 'file' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).toBe(true); + expect(enforcer.isAllowed(entity.component, location.z)).toBe(true); + }); + + it('should not be sensitive to kind case', () => { + const enforcer = new CatalogRulesEnforcer([ + { allow: [], deny: [{ kind: 'group' }] }, + { allow: [], deny: [{ kind: 'Component' }] }, + ]); + expect(enforcer.isAllowed(entity.user, location.x)).toBe(true); + expect(enforcer.isAllowed(entity.group, location.x)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.y)).toBe(false); + expect(enforcer.isAllowed(entity.group, location.z)).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 new file mode 100644 index 0000000000..a03d93852a --- /dev/null +++ b/plugins/catalog-backend/src/ingestion/CatalogRules.ts @@ -0,0 +1,187 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Config } from '@backstage/config'; +import { LocationSpec, Entity } from '@backstage/catalog-model'; + +/** + * A structure for matching entities to a given rule. + */ +type EntityMatcher = { + kind: string; +}; + +/** + * A structure for matching locations to a given rule. + */ +type LocationMatcher = { + target?: string; + type: string; +}; + +/** + * Rules to apply to catalog entities + * + * An undefined list of matchers means match all, an empty list of matchers means match none + */ +type CatalogRule = { + deny?: EntityMatcher[]; + allow?: EntityMatcher[]; + locations?: LocationMatcher[]; +}; + +export class CatalogRulesEnforcer { + /** + * Default rules used by the catalog. + * + * Denies any location from specifying user or group entities. + */ + static readonly defaultRules: CatalogRule[] = [ + { + deny: [{ kind: 'User' }, { kind: 'Group' }], + allow: [], + }, + ]; + + /** + * Loads catalog rules from config. + * + * This reads `catalog.rules` and defaults to the default rules if no value is present. + * The value of the config should be a list of config objects, each with a single `deny` + * field which in turn is a list of entity kind to deny. + * + * It also reads in rules from `catalog.locations`, where each location can have a list + * of allowed entity for the location, specified in an `allow` field. + * + * For example: + * + * ```yaml + * catalog: + * rules: + * - deny: [User, Group, System] + * + * locations: + * - type: github + * target: https://github.com/org/repo/blob/master/users.yaml + * allow: [User, Group] + * - type: github + * target: https://github.com/org/repo/blob/master/systems.yaml + * allow: [System] + * ``` + */ + static fromConfig(config: Config) { + const rules = new Array(); + + if (config.has('catalog.rules')) { + const globalRules = config.getConfigArray('catalog.rules').map(sub => ({ + deny: sub.getStringArray('deny').map(kind => ({ kind })), + allow: [], + })); + rules.push(...globalRules); + } else { + rules.push(...CatalogRulesEnforcer.defaultRules); + } + + if (config.has('catalog.locations')) { + const locationRules = config + .getConfigArray('catalog.locations') + .flatMap(sub => { + if (!sub.has('allow')) { + return []; + } + + return [ + { + deny: [], + allow: sub.getStringArray('allow').map(kind => ({ kind })), + locations: [ + { + type: sub.getString('type'), + target: sub.getString('target'), + }, + ], + }, + ]; + }); + + rules.push(...locationRules); + } + + return new CatalogRulesEnforcer(rules); + } + + constructor(private readonly rules: CatalogRule[]) {} + + /** + * Checks wether a specific entity/location combination is allowed + * according to the configured rules. + */ + isAllowed(entity: Entity, location: LocationSpec) { + let result = true; + + for (const rule of this.rules) { + if (!this.matchLocation(location, rule.locations)) { + continue; + } + + if (this.matchEntity(entity, rule.allow)) { + result = true; + } + if (this.matchEntity(entity, rule.deny)) { + result = false; + } + } + + return result; + } + + private matchLocation( + location: LocationSpec, + matchers?: LocationMatcher[], + ): boolean { + if (!matchers) { + return true; + } + + for (const matcher of matchers) { + if (matcher.type !== location.type) { + continue; + } + if (matcher.target && matcher.target !== location.target) { + continue; + } + return true; + } + + return false; + } + + private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean { + if (!matchers) { + return true; + } + + for (const matcher of matchers) { + if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) { + continue; + } + + return true; + } + + return false; + } +} diff --git a/plugins/catalog-backend/src/ingestion/LocationReaders.ts b/plugins/catalog-backend/src/ingestion/LocationReaders.ts index 46fa6781f1..bddef6b4de 100644 --- a/plugins/catalog-backend/src/ingestion/LocationReaders.ts +++ b/plugins/catalog-backend/src/ingestion/LocationReaders.ts @@ -47,6 +47,7 @@ import { } from './processors/types'; import { YamlProcessor } from './processors/YamlProcessor'; import { LocationReader, ReadLocationResult } from './types'; +import { CatalogRulesEnforcer } from './CatalogRules'; // The max amount of nesting depth of generated work items const MAX_DEPTH = 10; @@ -63,6 +64,7 @@ type Options = { export class LocationReaders implements LocationReader { private readonly logger: Logger; private readonly processors: LocationProcessor[]; + private readonly rulesEnforcer: CatalogRulesEnforcer; static defaultProcessors(options: { config?: Config; @@ -96,6 +98,9 @@ export class LocationReaders implements LocationReader { }: Options) { this.logger = logger; this.processors = processors; + this.rulesEnforcer = config + ? CatalogRulesEnforcer.fromConfig(config) + : new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules); } async read(location: LocationSpec): Promise { @@ -112,11 +117,20 @@ export class LocationReaders implements LocationReader { } else if (item.type === 'data') { await this.handleData(item, emit); } else if (item.type === 'entity') { - const entity = await this.handleEntity(item, emit); - output.entities.push({ - entity, - location: item.location, - }); + if (this.rulesEnforcer.isAllowed(item.entity, item.location)) { + const entity = await this.handleEntity(item, emit); + output.entities.push({ + entity, + location: item.location, + }); + } else { + output.errors.push({ + location: item.location, + error: new Error( + `Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`, + ), + }); + } } else if (item.type === 'error') { await this.handleError(item, emit); output.errors.push({