diff --git a/.changeset/beige-colts-pump.md b/.changeset/beige-colts-pump.md new file mode 100644 index 0000000000..c85a260b31 --- /dev/null +++ b/.changeset/beige-colts-pump.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Added `resolveCatalogMembership` 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..7ec6ec7117 100644 --- a/docs/auth/identity-resolver.md +++ b/docs/auth/identity-resolver.md @@ -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 { + 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 diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.test.ts index 329db316ab..6ffcfd6f46 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,88 @@ describe('CatalogIdentityClient', () => { }, }); }); + + it('resolveCatalogMembership 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.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', + ]); + }); }); diff --git a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts index 07a7190503..02fe9818cb 100644 --- a/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts +++ b/plugins/auth-backend/src/lib/catalog/CatalogIdentityClient.ts @@ -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; }; +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 { + 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; + } }