From 29f7cfffb7a3cb545797296aab94160a875af96b Mon Sep 17 00:00:00 2001 From: Tim Hansen Date: Sun, 1 Aug 2021 20:38:17 -0600 Subject: [PATCH] Add resolveCatalogMemberClaims method Signed-off-by: Tim Hansen --- .changeset/beige-colts-pump.md | 5 + docs/auth/identity-resolver.md | 41 +++++++ .../lib/catalog/CatalogIdentityClient.test.ts | 101 +++++++++++++++++- .../src/lib/catalog/CatalogIdentityClient.ts | 86 ++++++++++++++- 4 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 .changeset/beige-colts-pump.md diff --git a/.changeset/beige-colts-pump.md b/.changeset/beige-colts-pump.md new file mode 100644 index 0000000000..a6c5fe1e1c --- /dev/null +++ b/.changeset/beige-colts-pump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added `resolveCatalogMemberClaims` utility to query the catalog for additional authentication claims within sign-in resolvers. diff --git a/docs/auth/identity-resolver.md b/docs/auth/identity-resolver.md index 814ff63729..38d7567172 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -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 { + 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 diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 329db316ab..6f05bef5f8 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts @@ -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 = [ + { + 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', + ], + }, + }); + }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 07a7190503..c1f54f74c8 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -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; @@ -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 { + const subRef: EntityName = parseEntityRef(sub, { + defaultKind: 'user', + defaultNamespace: 'default', + }); + + let entityRefs: Array = []; + 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, + }, + }; + } }