Merge pull request #11029 from backstage/rugvip/auth-docs

auth/docs: Update user identity and sign-in resolver docs
This commit is contained in:
Patrik Oldsberg
2022-04-22 17:07:53 +02:00
committed by GitHub
+161 -137
View File
@@ -1,77 +1,123 @@
---
id: identity-resolver
title: Identity resolver
description: Identity resolvers of Backstage users after they sign-in
title: Sign-in Identities and Resolvers
description: An introduction to Backstage user identities and sign-in resolvers
---
This guide explains how the identity of a Backstage user is stored inside their
Backstage Identity Token and how you can customize the Sign-In resolvers to
include identity and group membership information of the user from other
external systems. This ultimately helps with determining the ownership of a
Backstage entity by a user. The ideas here were originally proposed in the RFC
[#4089](https://github.com/backstage/backstage/issues/4089).
By default, every Backstage auth provider is configured only for the use-case of
access delegation. This enables Backstage to request resources and actions from
external systems on behalf of the user, for example re-triggering a build in CI.
When a user signs in to Backstage, inside the `claims` field of their Backstage
Token (which are standard JWT tokens) a special `ent` claim is set. `ent`
contains a list of
[entity references](../features/software-catalog/references.md), each of which
denotes an identity or a membership that is relevant to the user. There is no
guarantee that these correspond to actual existing catalog entities.
If you want to use an auth provider to sign in users, you need to explicitly configure
it have sign-in enabled and also tell it how the external identities should
be mapped to user identities within Backstage.
Let's take an example sign-in resolver for the Google auth provider and explore
how the `ent` field inside `claims` can be set.
## Backstage User Identity
Inside your `packages/backend/src/plugins/auth.ts` file, you can provide custom
sign-in resolvers and set them for any of the Authentication providers inside
`providerFactories` of the `createRouter` imported from the
`@backstage/plugin-auth-backend` plugin.
A user identity within Backstage is built up from two pieces of information, a
user [entity reference](../features/software-catalog/references.md), and a
set of ownership entity references.
When a user signs in, a Backstage token is generated with these two pieces of information,
which is then used to identity the user within the Backstage ecosystem.
The user entity reference should uniquely identify the logged in user in Backstage.
It is encouraged that a matching user entity also exists within the Software Catalog,
but it is not required. If the user entity exists in the catalog it can be used to
store additional data about the user. There may even be some plugins that require
this for them to be able to function.
The ownership references are also entity references, and it is likewise
encouraged that these entities exist within the catalog, but it is not a requirement.
The ownership references are used to determine what the user owns, as a set
of references that the user claims ownership though. For example, a user
Jane (`user:default/jane`) might have the ownership references `user:default/jane`,
`group:default/team-a`, and `group:default/admins`. Given these ownership claims,
any entity that is marked as owned by either of `user:jane`, `team-a`, or `admins` would
be considered owned by Jane.
The ownership claims often contain the user entity reference itself, but it is not
required. It is also worth noting that the ownership claims can also be used to
resolve other relations similar to ownership, such as a claim for a `maintainer` or
`operator` status.
The Backstage token that encapsulates the user identity is a JWT. The user entity
reference is stored in the `sub` claim of the payload, while the ownership references
are stored in a custom `ent` claim. Both the user and ownership references should
always be full entity references, as opposed to shorthands like just `jane` or `user:jane`.
## Sign-in Resolvers
Signing in a user into Backstage requires a mapping of the user identity from the
third-party auth provider to a Backstage user identity. This mapping can vary quite
a lot between different organizations and auth providers, and because of that there's
no default way to resolve user identities. The auth provider that one wants to use
for sign-in must instead be configured with a sign-in resolver, which is a function
that is responsible for creating this user identity mapping.
The input to the sign-in resolver function is the result of a successful log in with
the given auth provider, as well as a context object that contains various helpers
for looking up users and issuing tokens. There are also a number of built-in sign-in
resolvers that can be used, which are covered a bit further down.
### Custom Resolver Example
Let's look at an example of a custom sign-in resolver for the Google auth provider.
This all typically happens within your `packages/backend/src/plugins/auth.ts` file,
which is responsible for setting up and configuring the auth backend plugin.
You provide the resolver as part of the options you pass when creating a new auth
provider factory. This means you need to replace the default Google provider with
one that you create. Be sure to also include the existing `defaultAuthProviderFactories`
if you want to keep all of the built-in auth providers installed.
Now let's look at the example, with the rest of the commentary being made with in
the code comments:
```ts
import { DEFAULT_NAMESPACE, stringifyEntityRef } from '@backstage/catalog-model';
import {
createRouter,
providers,
defaultAuthProviderFactories,
} from '@backstage/plugin-auth-backend';
import { Router } from 'express';
import { PluginEnvironment } from '../types';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
...
...env,
providerFactories: {
google: createGoogleProvider({
...defaultAuthProviderFactories,
google: providers.google.create({
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
// Call a custom validator function that checks that the email is
// valid and on our own company's domain, and throws an Error if it
// isn't.
// TODO: Implement this function
validateEmail(email);
resolver: async (info, ctx) => {
const {
profile: { email },
} = info;
// Profiles are not always guaranteed to to have an email address.
// You can also find more provider-specific information in `info.result`.
// It typically contains a `fullProfile` object as well as ID and/or access
// tokens that you can use for additional lookups.
if (!email) {
throw new Error('User profile contained no email');
}
// List of entity references that denote the identity and
// membership of the user
const ent: string[] = [];
// You can add your own custom validation logic here.
// Logins can be prevented by throwing an error like the one above.
myEmailValidator(email);
// Let's use the username in the email ID as the user's default
// unique identifier inside Backstage.
const [id] = email.split('@');
const userEntityRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
// This example resolver simply uses the local part of the email as the name.
const [name] = email.split('@');
// This helper function handles sign-in by looking up a user in the catalog.
// The lookup can be done either by reference, annotations, or custom filters.
//
// The helper also issues a token for the user, using the standard group
// membership logic to determine the ownership references of the user.
return ctx.signInWithCatalogUser({
entityRef: { name },
});
ent.push(userEntityRef);
// Let's call the internal LDAP provider to get a list of groups
// that the user belongs to, and add those to the list as well
const ldapGroups = await getLdapGroups(email);
ldapGroups.forEach(group => ent.push(stringifyEntityRef({
kind: 'Group',
namespace: DEFAULT_NAMESPACE,
name: group,
})));
// Issue the token containing the entity claims
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userEntityRef, ent },
});
return { id, token };
},
},
}),
@@ -80,97 +126,75 @@ export default async function createPlugin(
}
```
As you can see, the generated Backstage Token now contains all the claims about
the identity and membership of the user. Once the sign-in process is complete,
and we need to find out if a user owns an Entity in the Software Catalog, these
`ent` claims can be used to determine the ownership.
### Built-in Resolvers
According to the RFC, the definition of the ownership of an entity E, for a user
U, is as follows:
- Get all the `ownedBy` relations of E, and call them O
- Get all the claims of the user U and call them C
- If any C matches any O, return `true`
- Get all Group entities that U is a member of, using the regular
`memberOf`/`hasMember` relation mechanism, and call them G
- If any G matches any O, return `true`
- Otherwise, return `false`
## Default sign-in resolvers
Of course you don't have to customize the sign-in resolver if you don't need to.
The Auth backend plugin comes with a set of default sign-in resolvers which you
can use. For example - the Google provider has a default email-based sign-in
resolver, which will search the catalog for a single user entity that has a
matching `google.com/email` annotation.
It can be enabled like this
You don't always have to write your own custom resolver. The auth backend plugin provides
build-in resolvers for many of the common sign-in patterns. You access these via the `resolvers`
property of each of the auth provider integrations. For example, the Google provider has
a built in resolver that works just like the one we defined above:
```ts
// File: packages/backend/src/plugins/auth.ts
import { googleEmailSignInResolver, createGoogleProvider } from '@backstage/plugin-auth-backend';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: googleEmailSignInResolver
}
...
providers.google.create({
signIn: {
resolver: providers.google.resolvers.emailLocalPartMatchingUserEntityName(),
},
});
```
## 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:
There are also other options, like the this one that looks up a user
by matching the `google.com/email` annotation of user entities in the catalog:
```ts
import { DEFAULT_NAMESPACE, stringifyEntityRef } from '@backstage/catalog-model';
export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
return await createRouter({
...
providerFactories: {
google: createGoogleProvider({
signIn: {
resolver: async ({ profile: { email } }, ctx) => {
const [id] = email?.split('@') ?? '';
const userEntityRef = stringifyEntityRef({
kind: 'User',
namespace: DEFAULT_NAMESPACE,
name: id,
});
// 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: [id].concat(ent),
logger: ctx.logger,
});
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userEntityRef, ent: fullEnt },
});
return { id, token };
},
},
}),
...
providers.google.create({
signIn: {
resolver: providers.google.resolvers.emailMatchingUserEntityAnnotation(),
},
});
```
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.
## Custom Ownership Resolution
If you want to have more control over the membership resolution and token generation
that happens during sign-in you can replace `ctx.signInWithCatalogUser` with a set
of lower-level calls:
```ts
import { getDefaultOwnershipRefs } from '@backstage/plugin-auth-backend';
// This example only shows the resolver function itself.
async ({ profile: { email } }, ctx) => {
if (!email) {
throw new Error('User profile contained no email');
}
// This step calls the catalog to look up a user entity. You could for example
// replace it with a call to a different external system.
const { entity } = await ctx.findCatalogUser({
annotations: {
'acme.org/email': email,
},
});
// In this step we extract the ownership references from the user entity using
// the standard logic. It uses a reference to the entity itself, as well as the
// target of each `memberOf` relation where the target is of the kind `Group`.
//
// If you replace the catalog lookup with something does not return
// an entity you will need to replace this step as well.
//
// You might also replace it if you for example want to filter out certain groups.
const ownershipRefs = getDefaultOwnershipRefs(entity);
// The last step is to issue the token, where we might provide more options in the future.
const token = ctx.issueToken({
claims: {
sub: stringifyEntityRef(entity),
ent: ownershipRefs,
},
});
return { token };
};
```
## AuthHandler