diff --git a/.changeset/proud-toys-return.md b/.changeset/proud-toys-return.md new file mode 100644 index 0000000000..f93e6ef4a8 --- /dev/null +++ b/.changeset/proud-toys-return.md @@ -0,0 +1,5 @@ +--- +'@backstage/catalog-model': minor +--- + +Introduced `GroupDefaultParentEntityPolicy` to set a default group entity parent. diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 3eeb054174..9b9136fc3c 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -230,6 +230,13 @@ export function getEntitySourceLocation(entity: Entity): { target: string; }; +// @public +export class GroupDefaultParentEntityPolicy implements EntityPolicy { + constructor(parentEntityRef: string); + // (undocumented) + enforce(entity: Entity): Promise; +} + // @public interface GroupEntityV1alpha1 extends Entity { // (undocumented) diff --git a/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.test.ts b/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.test.ts new file mode 100644 index 0000000000..d5eb6145c2 --- /dev/null +++ b/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { UserEntity, GroupEntity } from '../../kinds'; +import { GroupDefaultParentEntityPolicy } from './GroupDefaultParentEntityPolicy'; + +describe('GroupDefaultParentEntityPolicy', () => { + it('should ignore non-group entities', async () => { + const p = new GroupDefaultParentEntityPolicy('name'); + const u: UserEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'n' }, + spec: { profile: {}, memberOf: ['c'] }, + }; + const result = await p.enforce(u); + expect(result).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'n' }, + spec: { profile: {}, memberOf: ['c'] }, + }); + }); + + it('should parent group entities', async () => { + const p = new GroupDefaultParentEntityPolicy('name'); + const g: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { type: 'foo', children: [] }, + }; + const result = await p.enforce(g); + expect(result).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { type: 'foo', parent: 'group:default/name', children: [] }, + }); + }); + + it('should not replace existing parents', async () => { + const p = new GroupDefaultParentEntityPolicy('namespace/name'); + const g: GroupEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { type: 'foo', parent: 'group:something/else', children: [] }, + }; + const result = await p.enforce(g); + expect(result).toEqual({ + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { name: 'n' }, + spec: { type: 'foo', parent: 'group:something/else', children: [] }, + }); + }); +}); diff --git a/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.ts b/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.ts new file mode 100644 index 0000000000..8eb0aef757 --- /dev/null +++ b/packages/catalog-model/src/entity/policies/GroupDefaultParentEntityPolicy.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2022 The Backstage Authors + * + * 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 { Entity } from '../Entity'; +import { GroupEntity } from '../../kinds'; +import { EntityPolicy } from './types'; +import { DEFAULT_NAMESPACE } from '../constants'; +import { parseEntityRef, stringifyEntityRef } from '../ref'; + +/** + * DefaultParentPolicy is an EntityPolicy that updates group entities + * with a parent of last resort. This ensures that, while we preserve + * any existing group hierarchies, we can guarantee that there is a + * single global root of the group hierarchy. + * + * @public + */ +export class GroupDefaultParentEntityPolicy implements EntityPolicy { + private readonly parentRef: string; + + constructor(parentEntityRef: string) { + const { kind, namespace, name } = parseEntityRef(parentEntityRef, { + defaultKind: 'Group', + defaultNamespace: DEFAULT_NAMESPACE, + }); + + if (kind.toLocaleUpperCase('en-US') !== 'GROUP') { + throw new TypeError('group parent must be a group'); + } + + this.parentRef = stringifyEntityRef({ + kind: kind, + namespace: namespace, + name: name, + }); + } + + async enforce(entity: Entity): Promise { + if (entity.kind !== 'Group') { + return entity; + } + + const group = entity as GroupEntity; + if (group.spec.parent) { + return group; + } + + // Avoid making the parent entity it's own parent. + if (stringifyEntityRef(group) !== this.parentRef) { + group.spec.parent = this.parentRef; + } + + return group; + } +} diff --git a/packages/catalog-model/src/entity/policies/index.ts b/packages/catalog-model/src/entity/policies/index.ts index ae14007d80..d5740c0955 100644 --- a/packages/catalog-model/src/entity/policies/index.ts +++ b/packages/catalog-model/src/entity/policies/index.ts @@ -15,6 +15,7 @@ */ export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy'; +export { GroupDefaultParentEntityPolicy } from './GroupDefaultParentEntityPolicy'; export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy'; export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy'; export { SchemaValidEntityPolicy } from './SchemaValidEntityPolicy';