feat(catalog-model): add the User entity

This commit is contained in:
Fredrik Adelöw
2020-09-23 22:07:29 +02:00
parent b5e6051959
commit 54ad1c5f31
5 changed files with 389 additions and 4 deletions
@@ -22,6 +22,8 @@ humans. However, the structure and semantics is the same in both cases.
- [Kind: Component](#kind-component)
- [Kind: Template](#kind-template)
- [Kind: API](#kind-api)
- [Kind: Group](#kind-group)
- [Kind: User](#kind-user)
## Overall Shape Of An Entity
@@ -571,3 +573,167 @@ group of people in an organizational structure.
The definition of the API, based on the format defined by `spec.type`. This
field is required.
## Kind: Group
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `Group` |
A group describes an organizational entity, such as for example a team, a
business unit, or a loose collection of people in an interest group. Members of
these groups are modeled in the catalog as kind [`User`](#kind-user).
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: Group
metadata:
name: infrastructure
description: The infra business unit
spec:
type: business-unit
parent: ops
ancestors: [ops, global-synergies, acme-corp]
children: [backstage, other]
descendants: [backstage, other, team-a, team-b, team-c, team-d]
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `Group`, respectively.
### `spec.type` [required]
The type of group as a string, e.g. `team`. There is currently no enforced set
of values for this field, so it is left up to the adopting organization to
choose a nomenclature that matches their org hierarchy.
Some common values for this field could be:
- `team`
- `business-unit`
- `product-area`
- `root` - as a common virtual root of the hierarchy, if desired
### `spec.parent` [optional]
The immediate parent group in the hierarchy, if any. Not all groups must have a
parent; the catalog supports multi-root hierarchies. Groups may however not have
more than one parent.
This field is an
[entity reference](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
this field points to a group in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of that group.
### `spec.ancestors` [required]
The recursive list of parents up the hierarchy, by stepping through parents one
by one. The list must be present, but may be empty if `parent` is not present.
The first entry in the list is equal to `parent`, and then the following ones
are progressively farther up the hierarchy.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.children` [required]
The immediate child groups of this group in the hierarchy (whose `parent` field
points to this group). The list must be present, but may be empty if there are
no child groups. The items are not guaranteed to be ordered in any particular
way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
### `spec.descendants` [required]
The immediate and recursive child groups of this group in the hierarchy
(children, and children's children, etc.). The list must be present, but may be
empty if there are no child groups. The items are not guaranteed to be ordered
in any particular way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
## Kind: User
Describes the following entity kind:
| Field | Value |
| ------------ | ----------------------- |
| `apiVersion` | `backstage.io/v1alpha1` |
| `kind` | `User` |
A user describes a person, such as an employee, a contractor, or similar. Users
belong to [`Group`](#kind-group) entities in the catalog.
These catalog user entries are connected to the way that authentication within
the Backstage ecosystem works. See the [auth](https://backstage.io/docs/auth)
section of the docs for a discussion of these concepts.
Descriptor files for this kind may look as follows.
```yaml
apiVersion: backstage.io/v1alpha1
kind: User
metadata:
name: jdoe
spec:
profile:
displayName: Jenny Doe
email: jenny-doe@example.com
picture: https://example.com/staff/jenny-with-party-hat.jpeg
memberOf: [team-b, employees]
```
In addition to the [common envelope metadata](#common-to-all-kinds-the-metadata)
shape, this kind has the following structure.
### `apiVersion` and `kind` [required]
Exactly equal to `backstage.io/v1alpha1` and `User`, respectively.
### `spec.profile` [optional]
Optional profile information about the user, mainly for display purposes. All
fields of this structure are also optional. The email would be a primary email
of some form, that the user may wish to be used for contacting them. The picture
is expected to be a URL pointing to an image that's representative of the user,
and that a browser could fetch and render on a profile page or similar.
### `spec.memberOf` [required]
The list of groups that the user is a direct member of (i.e., no transitive
memberships are listed here). The list must be present, but may be empty if the
user is not member of any groups. The items are not guaranteed to be ordered in
any particular way.
The entries of this array are
[entity references](https://backstage.io/docs/features/software-catalog/references),
with the default kind `Group` and the default namespace equal to the same
namespace as the user. Only `Group` entities may be referenced. Most commonly,
these entries point to groups in the same namespace, so in those cases it is
sufficient to enter only the `metadata.name` field of those groups.
@@ -28,6 +28,7 @@ import {
GroupEntityV1alpha1Policy,
LocationEntityV1alpha1Policy,
TemplateEntityV1alpha1Policy,
UserEntityV1alpha1Policy,
} from './kinds';
import { EntityPolicy } from './types';
@@ -77,6 +78,7 @@ export class EntityPolicies implements EntityPolicy {
EntityPolicies.anyOf([
new ComponentEntityV1alpha1Policy(),
new GroupEntityV1alpha1Policy(),
new UserEntityV1alpha1Policy(),
new LocationEntityV1alpha1Policy(),
new TemplateEntityV1alpha1Policy(),
new ApiEntityV1alpha1Policy(),
@@ -0,0 +1,150 @@
/*
* 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 { EntityPolicy } from '../types';
import {
UserEntityV1alpha1,
UserEntityV1alpha1Policy,
} from './UserEntityV1alpha1';
describe('UserV1alpha1Policy', () => {
let entity: UserEntityV1alpha1;
let policy: EntityPolicy;
beforeEach(() => {
entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'User',
metadata: {
name: 'doe',
},
spec: {
profile: {
displayName: 'John Doe',
email: 'john@doe.org',
picture: 'https://doe.org/john.jpeg',
},
memberOf: ['team-a', 'developers'],
},
};
policy = new UserEntityV1alpha1Policy();
});
it('happy path: accepts valid data', async () => {
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// root
it('silently accepts v1beta1 as well', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta1';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects unknown apiVersion', async () => {
(entity as any).apiVersion = 'backstage.io/v1beta0';
await expect(policy.enforce(entity)).rejects.toThrow(/apiVersion/);
});
it('rejects unknown kind', async () => {
(entity as any).kind = 'Wizard';
await expect(policy.enforce(entity)).rejects.toThrow(/kind/);
});
it('spec accepts unknown additional fields', async () => {
(entity as any).spec.foo = 'data';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// profile
it('accepts missing profile', async () => {
delete (entity as any).spec.profile;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('rejects wrong profile', async () => {
(entity as any).spec.profile = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/profile/);
});
it('profile accepts missing displayName', async () => {
delete (entity as any).spec.profile.displayName;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong displayName', async () => {
(entity as any).spec.profile.displayName = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
});
it('profile rejects empty displayName', async () => {
(entity as any).spec.profile.displayName = '';
await expect(policy.enforce(entity)).rejects.toThrow(/displayName/);
});
it('profile accepts missing email', async () => {
delete (entity as any).spec.profile.email;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong email', async () => {
(entity as any).spec.profile.email = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/email/);
});
it('profile rejects empty email', async () => {
(entity as any).spec.profile.email = '';
await expect(policy.enforce(entity)).rejects.toThrow(/email/);
});
it('profile accepts missing picture', async () => {
delete (entity as any).spec.profile.picture;
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
it('profile rejects wrong picture', async () => {
(entity as any).spec.profile.picture = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
});
it('profile rejects empty picture', async () => {
(entity as any).spec.profile.picture = '';
await expect(policy.enforce(entity)).rejects.toThrow(/picture/);
});
it('profile accepts unknown additional fields', async () => {
(entity as any).spec.profile.foo = 'data';
await expect(policy.enforce(entity)).resolves.toBe(entity);
});
// memberOf
it('rejects missing memberOf', async () => {
delete (entity as any).spec.memberOf;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf', async () => {
(entity as any).spec.memberOf = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
it('rejects wrong memberOf item', async () => {
(entity as any).spec.memberOf[0] = 7;
await expect(policy.enforce(entity)).rejects.toThrow(/memberOf/);
});
});
@@ -0,0 +1,62 @@
/*
* 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 * as yup from 'yup';
import type { Entity } from '../entity/Entity';
import type { EntityPolicy } from '../types';
const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const;
const KIND = 'User' as const;
export interface UserEntityV1alpha1 extends Entity {
apiVersion: typeof API_VERSION[number];
kind: typeof KIND;
spec: {
profile?: {
displayName?: string;
email?: string;
picture?: string;
};
memberOf: string[];
};
}
export class UserEntityV1alpha1Policy implements EntityPolicy {
private schema: yup.Schema<any>;
constructor() {
this.schema = yup.object<Partial<UserEntityV1alpha1>>({
apiVersion: yup.string().required().oneOf(API_VERSION),
kind: yup.string().required().equals([KIND]),
spec: yup
.object({
profile: yup
.object({
displayName: yup.string().min(1).notRequired(),
email: yup.string().min(1).notRequired(),
picture: yup.string().min(1).notRequired(),
})
.notRequired(),
memberOf: yup.array(yup.string()).required(),
})
.required(),
});
}
async enforce(envelope: Entity): Promise<Entity> {
return await this.schema.validate(envelope, { strict: true });
}
}
+9 -4
View File
@@ -14,6 +14,11 @@
* limitations under the License.
*/
export { ApiEntityV1alpha1Policy } from './ApiEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
ApiEntityV1alpha1,
} from './ApiEntityV1alpha1';
export { ComponentEntityV1alpha1Policy } from './ComponentEntityV1alpha1';
export type {
ComponentEntityV1alpha1 as ComponentEntity,
@@ -34,8 +39,8 @@ export type {
TemplateEntityV1alpha1 as TemplateEntity,
TemplateEntityV1alpha1,
} from './TemplateEntityV1alpha1';
export { ApiEntityV1alpha1Policy } from './ApiEntityV1alpha1';
export { UserEntityV1alpha1Policy } from './UserEntityV1alpha1';
export type {
ApiEntityV1alpha1 as ApiEntity,
ApiEntityV1alpha1,
} from './ApiEntityV1alpha1';
UserEntityV1alpha1 as UserEntity,
UserEntityV1alpha1,
} from './UserEntityV1alpha1';