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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Added `resolveCatalogMembership` utility to query the catalog for additional authentication claims within sign-in resolvers.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user