Add resolveCatalogMemberClaims method

Signed-off-by: Tim Hansen <timbonicus@gmail.com>
This commit is contained in:
Tim Hansen
2021-08-01 20:38:17 -06:00
parent 0e9fef8e3f
commit 29f7cfffb7
4 changed files with 227 additions and 6 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added `resolveCatalogMemberClaims` utility to query the catalog for additional authentication claims within sign-in resolvers.
+41
View File
@@ -111,6 +111,47 @@ export default async function createPlugin({
...
```
## Resolving membership through the catalog
If you want to provide additional claims through Sign-In resolvers but still
have the software catalog handle group (and transitive group) membership, you
can do this using the `CatalogIdentityClient` provided as context to Sign-In
resolvers:
```ts
export default async function createPlugin({
...
}: PluginEnvironment): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
const [sub] = email?.split('@') ?? '';
// Fetch from an external system that returns entity claims like:
// 'user:default/breanna.davison'
const ent = await externalSystemClient.getUsernames(email);
// Resolve group membership from the Backstage catalog
const claims = await ctx.catalogIdentityClient.resolveCatalogMemberClaims(
sub,
ent,
ctx.logger,
);
const token = await ctx.tokenIssuer.issueToken(claims);
return { sub, token };
},
},
}),
...
```
The `resolveCatalogMemberClaims` method will retrieve the `sub` and `ent`
entities from the catalog, if possible, and check for
[memberOf](../features/software-catalog/well-known-relations.md#memberof-and-hasmember)
relations to add additional entity claims.
## AuthHandler
Similar to a custom sign-in resolver, you can also write a custom auth handler
@@ -15,7 +15,11 @@
*/
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import {
RELATION_MEMBER_OF,
UserEntity,
UserEntityV1alpha1,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from './CatalogIdentityClient';
@@ -37,12 +41,12 @@ describe('CatalogIdentityClient', () => {
afterEach(() => jest.resetAllMocks());
it('passes through the correct search params', async () => {
it('findUser passes through the correct search params', async () => {
catalogApi.getEntities.mockResolvedValueOnce({ items: [{} as UserEntity] });
tokenIssuer.issueToken.mockResolvedValue('my-token');
const client = new CatalogIdentityClient({
catalogApi: catalogApi,
tokenIssuer: tokenIssuer,
catalogApi,
tokenIssuer,
});
await client.findUser({ annotations: { key: 'value' } });
@@ -62,4 +66,93 @@ describe('CatalogIdentityClient', () => {
},
});
});
it('resolveCatalogMemberClaims resolves membership', async () => {
const mockUsers: Array<UserEntityV1alpha1> = [
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'inigom',
},
spec: {
memberOf: ['team-a'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'default',
name: 'team-a',
},
},
],
},
{
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'mpatinkin',
namespace: 'reality',
},
spec: {
memberOf: ['screen-actors-guild'],
},
relations: [
{
type: RELATION_MEMBER_OF,
target: {
kind: 'Group',
namespace: 'reality',
name: 'screen-actors-guild',
},
},
],
},
];
catalogApi.getEntities.mockResolvedValueOnce({ items: mockUsers });
const client = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const claims = await client.resolveCatalogMemberClaims('inigom', [
'User:default/imontoya',
'User:reality/mpatinkin',
]);
expect(catalogApi.getEntities).toHaveBeenCalledWith({
filter: [
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'inigom',
},
{
kind: 'user',
'metadata.namespace': 'default',
'metadata.name': 'imontoya',
},
{
kind: 'user',
'metadata.namespace': 'reality',
'metadata.name': 'mpatinkin',
},
],
});
expect(claims).toMatchObject({
claims: {
sub: 'inigom',
ent: [
'user:default/imontoya',
'user:reality/mpatinkin',
'group:default/team-a',
'group:reality/screen-actors-guild',
],
},
});
});
});
@@ -14,10 +14,17 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { CatalogApi } from '@backstage/catalog-client';
import { UserEntity } from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
import {
EntityName,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenIssuer, TokenParams } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
@@ -64,4 +71,79 @@ export class CatalogIdentityClient {
return items[0] as UserEntity;
}
/**
* Resolve additional entity claims from the catalog, using the passed-in subject and entity
* claims. Designed to be used within a `signInResolver` where additional entity claims might be
* provided, but group membership and transient group membership lean on imported catalog
* relations.
*
* Returns a claim structure that can be passed directly to `issueToken`, with the same sub and a
* superset of `ent` claims.
*/
async resolveCatalogMemberClaims(
sub: string,
ent: string[],
logger?: Logger,
): Promise<TokenParams> {
const subRef: EntityName = parseEntityRef(sub, {
defaultKind: 'user',
defaultNamespace: 'default',
});
let entityRefs: Array<EntityName> = [];
if (ent) {
entityRefs = ent
.map((ref: string) => {
try {
const parsedRef = parseEntityRef(ref.toLocaleLowerCase('en-US'));
return parsedRef;
} catch {
logger?.debug(
`Failed to parse entityRef from '${sub}' ent claim: ${ref}, ignoring`,
);
return null;
}
})
.filter((ref): ref is EntityName => ref !== null);
}
const filter = [subRef].concat(entityRefs).map(ref => ({
kind: ref.kind,
'metadata.namespace': ref.namespace,
'metadata.name': ref.name,
}));
const entities = await this.catalogApi
.getEntities({ filter })
.then(r => r.items);
if (entityRefs.length !== entities.length) {
const foundEntityNames = entities.map(stringifyEntityRef);
const missingEntityNames = entityRefs
.map(stringifyEntityRef)
.filter(s => !foundEntityNames.includes(s));
logger?.debug(
`Entities not found for '${sub}' claims: ${missingEntityNames.join()}`,
);
}
const memberOf = entities.flatMap(
e =>
e!.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.target) ?? [],
);
const newEnt = [...new Set(entityRefs.concat(memberOf))].map(
stringifyEntityRef,
);
logger?.debug(`Found claims for ${sub} in the catalog: ${newEnt.join()}`);
return {
claims: {
sub,
ent: newEnt,
},
};
}
}