Merge pull request #6678 from backstage/timbonicus/catalog-claims

Add a method to look up additional claims from the catalog in a `signInResolver`
This commit is contained in:
Tim Hansen
2021-08-11 19:19:13 -06:00
committed by GitHub
4 changed files with 210 additions and 5 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Added `resolveCatalogMembership` utility to query the catalog for additional authentication claims within sign-in resolvers.
+42
View File
@@ -111,6 +111,48 @@ 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 fullEnt = await ctx.catalogIdentityClient.resolveCatalogMembership({
entityRefs: [sub].concat(ent),
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub, ent: fullEnt },
});
return { sub, token };
},
},
}),
...
```
The `resolveCatalogMembership` method will retrieve the referenced 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,88 @@ describe('CatalogIdentityClient', () => {
},
});
});
it('resolveCatalogMembership 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.resolveCatalogMembership({
entityRefs: ['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([
'user:default/inigom',
'user:default/imontoya',
'user:reality/mpatinkin',
'group:default/team-a',
'group:reality/screen-actors-guild',
]);
});
});
@@ -14,15 +14,27 @@
* 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 {
EntityName,
parseEntityRef,
RELATION_MEMBER_OF,
stringifyEntityRef,
UserEntity,
} from '@backstage/catalog-model';
import { TokenIssuer } from '../../identity';
type UserQuery = {
annotations: Record<string, string>;
};
type MemberClaimQuery = {
entityRefs: string[];
logger?: Logger;
};
/**
* A catalog client tailored for reading out identity data from the catalog.
*/
@@ -64,4 +76,62 @@ export class CatalogIdentityClient {
return items[0] as UserEntity;
}
/**
* Resolve additional entity claims from the catalog, using the passed-in entity names. 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 superset of the entity names that can be passed directly to `issueToken` as `ent`.
*/
async resolveCatalogMembership({
entityRefs,
logger,
}: MemberClaimQuery): Promise<string[]> {
const resolvedEntityRefs = entityRefs
.map((ref: string) => {
try {
const parsedRef = parseEntityRef(ref.toLocaleLowerCase('en-US'), {
defaultKind: 'user',
defaultNamespace: 'default',
});
return parsedRef;
} catch {
logger?.warn(`Failed to parse entityRef from ${ref}, ignoring`);
return null;
}
})
.filter((ref): ref is EntityName => ref !== null);
const filter = resolvedEntityRefs.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 = resolvedEntityRefs
.map(stringifyEntityRef)
.filter(s => !foundEntityNames.includes(s));
logger?.debug(`Entities not found for refs ${missingEntityNames.join()}`);
}
const memberOf = entities.flatMap(
e =>
e!.relations
?.filter(r => r.type === RELATION_MEMBER_OF)
.map(r => r.target) ?? [],
);
const newEntityRefs = [
...new Set(resolvedEntityRefs.concat(memberOf).map(stringifyEntityRef)),
];
logger?.debug(`Found catalog membership: ${newEntityRefs.join()}`);
return newEntityRefs;
}
}