From f181c8157d47896886458343a1380017195df5e9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 18 Mar 2022 14:16:15 +0100 Subject: [PATCH] auth-backend: add helper methods in AuthResolverContext + deprecations Signed-off-by: Patrik Oldsberg --- plugins/auth-backend/api-report.md | 42 ++++- plugins/auth-backend/src/index.ts | 2 + .../auth-backend/src/lib/catalog/helpers.ts | 3 + .../resolvers/CatalogAuthResolverContext.ts | 176 ++++++++++++++++++ .../auth-backend/src/lib/resolvers/index.ts | 20 ++ .../src/providers/google/provider.test.ts | 19 +- plugins/auth-backend/src/providers/index.ts | 1 + plugins/auth-backend/src/providers/types.ts | 66 ++++++- plugins/auth-backend/src/service/router.ts | 14 +- 9 files changed, 309 insertions(+), 34 deletions(-) create mode 100644 plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts create mode 100644 plugins/auth-backend/src/lib/resolvers/index.ts diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index fb58196b2f..19fa69a6aa 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -9,7 +9,9 @@ import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { BackstageSignInResult } from '@backstage/plugin-auth-node'; import { CatalogApi } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; +import { Entity } from '@backstage/catalog-model'; import express from 'express'; +import { GetEntitiesRequest } from '@backstage/catalog-client'; import { JsonValue } from '@backstage/types'; import { Logger } from 'winston'; import { PluginDatabaseManager } from '@backstage/backend-common'; @@ -110,11 +112,40 @@ export interface AuthProviderRouteHandlers { start(req: express.Request, res: express.Response): Promise; } +// Warning: (ae-missing-release-tag) "AuthResolverCatalogUserQuery" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: Exclude; + }; + // @public export type AuthResolverContext = { + logger: Logger; tokenIssuer: TokenIssuer; catalogIdentityClient: CatalogIdentityClient; - logger: Logger; + issueToken(params: TokenParams): Promise<{ + token: string; + }>; + findCatalogUser(query: AuthResolverCatalogUserQuery): Promise<{ + entity: Entity; + }>; + signInWithCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise; }; // Warning: (ae-missing-release-tag) "AuthResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -360,10 +391,12 @@ export type GcpIapTokenInfo = { [key: string]: JsonValue; }; -// Warning: (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts +// @public +export function getDefaultOwnershipEntityRefs(entity: Entity): string[]; + // Warning: (ae-missing-release-tag) "getEntityClaims" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // -// @public (undocumented) +// @public @deprecated (undocumented) export function getEntityClaims(entity: UserEntity): TokenParams['claims']; // Warning: (ae-missing-release-tag) "GithubOAuthResult" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -760,5 +793,6 @@ export type WebMessageResponse = // src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts // src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts // src/providers/github/provider.d.ts:97:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts -// src/providers/types.d.ts:131:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts +// src/providers/types.d.ts:50:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts +// src/providers/types.d.ts:180:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts ``` diff --git a/plugins/auth-backend/src/index.ts b/plugins/auth-backend/src/index.ts index d0cade087e..be622e59e9 100644 --- a/plugins/auth-backend/src/index.ts +++ b/plugins/auth-backend/src/index.ts @@ -32,3 +32,5 @@ export * from './lib/flow'; export * from './lib/oauth'; export * from './lib/catalog'; + +export { getDefaultOwnershipEntityRefs } from './lib/resolvers'; diff --git a/plugins/auth-backend/src/lib/catalog/helpers.ts b/plugins/auth-backend/src/lib/catalog/helpers.ts index db9b38e2a6..3ff1b5da05 100644 --- a/plugins/auth-backend/src/lib/catalog/helpers.ts +++ b/plugins/auth-backend/src/lib/catalog/helpers.ts @@ -21,6 +21,9 @@ import { } from '@backstage/catalog-model'; import { TokenParams } from '../../identity'; +/** + * @deprecated use {@link getDefaultOwnershipEntityRefs} instead + */ export function getEntityClaims(entity: UserEntity): TokenParams['claims'] { const userRef = stringifyEntityRef(entity); diff --git a/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts new file mode 100644 index 0000000000..1e2ec1685e --- /dev/null +++ b/plugins/auth-backend/src/lib/resolvers/CatalogAuthResolverContext.ts @@ -0,0 +1,176 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TokenManager } from '@backstage/backend-common'; +import { CatalogApi } from '@backstage/catalog-client'; +import { + DEFAULT_NAMESPACE, + Entity, + parseEntityRef, + RELATION_MEMBER_OF, + stringifyEntityRef, +} from '@backstage/catalog-model'; +import { ConflictError, InputError, NotFoundError } from '@backstage/errors'; +import { Logger } from 'winston'; +import { TokenIssuer } from '../..'; +import { TokenParams } from '../../identity'; +import { AuthResolverContext } from '../../providers'; +import { AuthResolverCatalogUserQuery } from '../../providers/types'; +import { CatalogIdentityClient } from '../catalog'; + +/** + * Uses the default ownership resolution logic to return an array + * of entity refs that the provided entity claims ownership through. + * + * A reference to the entity itself will also be included in the returned array. + * + * @public + */ +export function getDefaultOwnershipEntityRefs(entity: Entity) { + const membershipRefs = + entity.relations + ?.filter(r => r.type === RELATION_MEMBER_OF) + .map(r => r.targetRef) ?? []; + + return Array.from(new Set([stringifyEntityRef(entity), ...membershipRefs])); +} + +/** + * @internal + */ +export class CatalogAuthResolverContext implements AuthResolverContext { + static create(options: { + logger: Logger; + catalogApi: CatalogApi; + tokenIssuer: TokenIssuer; + tokenManager: TokenManager; + }): CatalogAuthResolverContext { + const catalogIdentityClient = new CatalogIdentityClient({ + catalogApi: options.catalogApi, + tokenManager: options.tokenManager, + }); + return new CatalogAuthResolverContext( + options.logger, + options.tokenIssuer, + catalogIdentityClient, + options.catalogApi, + options.tokenManager, + ); + } + + private constructor( + public readonly logger: Logger, + public readonly tokenIssuer: TokenIssuer, + public readonly catalogIdentityClient: CatalogIdentityClient, + private readonly catalogApi: CatalogApi, + private readonly tokenManager: TokenManager, + ) {} + + async issueToken(params: TokenParams) { + const token = await this.tokenIssuer.issueToken(params); + return { token }; + } + + async findCatalogUser(query: AuthResolverCatalogUserQuery) { + let result: Entity[] | Entity | undefined = undefined; + const { token } = await this.tokenManager.getToken(); + + if ('entityRef' in query) { + const entityRef = parseEntityRef(query.entityRef, { + defaultKind: 'user', + defaultNamespace: DEFAULT_NAMESPACE, + }); + result = await this.catalogApi.getEntityByRef(entityRef, { token }); + } else if ('annotations' in query) { + const filter: Record = { + kind: 'user', + }; + for (const [key, value] of Object.entries(query.annotations)) { + filter[`metadata.annotations.${key}`] = value; + } + const res = await this.catalogApi.getEntities({ filter }, { token }); + result = res.items; + } else if ('filter' in query) { + const res = await this.catalogApi.getEntities( + { filter: query.filter }, + { token }, + ); + result = res.items; + } else { + throw new InputError('Invalid user lookup query'); + } + + if (Array.isArray(result)) { + if (result.length > 1) { + throw new ConflictError('User lookup resulted in multiple matches'); + } + result = result[0]; + } + if (!result) { + throw new NotFoundError('User not found'); + } + + return { entity: result }; + } + + async signInWithCatalogUser(query: AuthResolverCatalogUserQuery) { + const { entity } = await this.findCatalogUser(query); + const ownershipRefs = getDefaultOwnershipEntityRefs(entity); + + const token = await this.tokenIssuer.issueToken({ + claims: { + sub: stringifyEntityRef(entity), + ent: ownershipRefs, + }, + }); + return { token }; + } + /* + async expandCatalogOwnership(query: { entityRefs: string[] }) { + const { entityRefs } = query; + + const compoundRefs = entityRefs.map(ref => + parseEntityRef(ref.toLocaleLowerCase('en-US'), { + defaultKind: 'user', + defaultNamespace: DEFAULT_NAMESPACE, + }), + ); + const stringRefs = compoundRefs.map(e => stringifyEntityRef(e)); + + const { token } = await this.tokenManager.getToken(); + const { items: entities } = await this.catalogApi.getEntities( + { + filter: compoundRefs.map(ref => ({ + kind: ref.kind, + 'metadata.namespace': ref.namespace, + 'metadata.name': ref.name, + })), + }, + { token }, + ); + + if (compoundRefs.length !== entities.length) { + const found = entities.map(e => stringifyEntityRef(e)); + const missing = stringRefs.filter(ref => !found.includes(ref)); + throw new NotFoundError(`Entities not found for refs ${missing.join()}`); + } + + const memberOf = entities.flatMap(e => getDefaultOwnershipEntityRefs(e)); + + return Array.from(new Set(memberOf)); + } +*/ +} diff --git a/plugins/auth-backend/src/lib/resolvers/index.ts b/plugins/auth-backend/src/lib/resolvers/index.ts new file mode 100644 index 0000000000..c1ca59cb25 --- /dev/null +++ b/plugins/auth-backend/src/lib/resolvers/index.ts @@ -0,0 +1,20 @@ +/* + * Copyright 2022 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { + CatalogAuthResolverContext, + getDefaultOwnershipEntityRefs, +} from './CatalogAuthResolverContext'; diff --git a/plugins/auth-backend/src/providers/google/provider.test.ts b/plugins/auth-backend/src/providers/google/provider.test.ts index c31833d561..2e904c62ad 100644 --- a/plugins/auth-backend/src/providers/google/provider.test.ts +++ b/plugins/auth-backend/src/providers/google/provider.test.ts @@ -17,9 +17,7 @@ import { GoogleAuthProvider } from './provider'; import * as helpers from '../../lib/passport/PassportStrategyHelper'; import { OAuthResult } from '../../lib/oauth'; -import { getVoidLogger } from '@backstage/backend-common'; -import { TokenIssuer } from '../../identity/types'; -import { CatalogIdentityClient } from '../../lib/catalog'; +import { AuthResolverContext } from '../types'; const mockFrameHandler = jest.spyOn( helpers, @@ -30,21 +28,8 @@ const mockFrameHandler = jest.spyOn( describe('createGoogleProvider', () => { it('should auth', async () => { - const tokenIssuer = { - issueToken: jest.fn(), - listPublicKeys: jest.fn(), - }; - const catalogIdentityClient = { - findUser: jest.fn(), - }; - const provider = new GoogleAuthProvider({ - resolverContext: { - logger: getVoidLogger(), - catalogIdentityClient: - catalogIdentityClient as unknown as CatalogIdentityClient, - tokenIssuer: tokenIssuer as unknown as TokenIssuer, - }, + resolverContext: {} as AuthResolverContext, authHandler: async ({ fullProfile }) => ({ profile: { email: fullProfile.emails![0]!.value, diff --git a/plugins/auth-backend/src/providers/index.ts b/plugins/auth-backend/src/providers/index.ts index 9650632622..409ac4c4b4 100644 --- a/plugins/auth-backend/src/providers/index.ts +++ b/plugins/auth-backend/src/providers/index.ts @@ -41,6 +41,7 @@ export type { AuthProviderFactoryOptions, AuthProviderFactory, AuthHandler, + AuthResolverCatalogUserQuery, AuthResolverContext, AuthHandlerResult, SignInResolver, diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index ce77a650e3..d3f6a72aaf 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -18,7 +18,7 @@ import { PluginEndpointDiscovery, TokenManager, } from '@backstage/backend-common'; -import { CatalogApi } from '@backstage/catalog-client'; +import { CatalogApi, GetEntitiesRequest } from '@backstage/catalog-client'; import { Config } from '@backstage/config'; import { BackstageIdentityResponse, @@ -26,9 +26,40 @@ import { } from '@backstage/plugin-auth-node'; import express from 'express'; import { Logger } from 'winston'; -import { TokenIssuer } from '../identity/types'; +import { TokenIssuer, TokenParams } from '../identity/types'; import { OAuthStartRequest } from '../lib/oauth/types'; import { CatalogIdentityClient } from '../lib/catalog'; +import { Entity } from '@backstage/catalog-model'; + +/** + * A query for a single user in the catalog. + * + * If `entityRef` is used, the default kind is `'User'`. + * + * If `annotations` are used, all annotations must be present and + * match the provided value exactly. Only entities of kind `'User'` will be considered. + * + * If `filter` are used they are passed on as they are to the `CatalogApi`. + * + * Regardless of the query method, the query must match exactly one entity + * in the catalog, or an error will be thrown. + */ +export type AuthResolverCatalogUserQuery = + | { + entityRef: + | string + | { + kind?: string; + namespace?: string; + name: string; + }; + } + | { + annotations: Record; + } + | { + filter: Exclude; + }; /** * The context that is used for auth processing. @@ -36,9 +67,36 @@ import { CatalogIdentityClient } from '../lib/catalog'; * @public */ export type AuthResolverContext = { - tokenIssuer: TokenIssuer; - catalogIdentityClient: CatalogIdentityClient; + /** @deprecated Will be removed from the context, access it via a closure instead if needed */ logger: Logger; + /** @deprecated Use the `issueToken` method instead */ + tokenIssuer: TokenIssuer; + /** @deprecated Use the `findCatalogUser` and `signInWithCatalogUser` methods instead, and the `getDefaultOwnershipEntityRefs` helper */ + catalogIdentityClient: CatalogIdentityClient; + + /** + * Issues a Backstage token using the provided parameters. + */ + issueToken(params: TokenParams): Promise<{ token: string }>; + + /** + * Finds a single user in the catalog using the provided query. + * + * See {@link AuthResolverCatalogUserQuery} for details. + */ + findCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise<{ entity: Entity }>; + + /** + * Finds a single user in the catalog using the provided query, and then + * issues an identity for that user using default ownership resolution. + * + * See {@link AuthResolverCatalogUserQuery} for details. + */ + signInWithCatalogUser( + query: AuthResolverCatalogUserQuery, + ): Promise; }; /** diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 42afe56bc0..80b8a24022 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -34,7 +34,7 @@ import { createOidcRouter, TokenFactory, KeyStores } from '../identity'; import session from 'express-session'; import passport from 'passport'; import { Minimatch } from 'minimatch'; -import { CatalogIdentityClient } from '../lib/catalog'; +import { CatalogAuthResolverContext } from '../lib/resolvers'; type ProviderFactories = { [s: string]: AuthProviderFactory }; @@ -104,11 +104,6 @@ export async function createRouter( const isOriginAllowed = createOriginFilter(config); - const catalogIdentityClient = new CatalogIdentityClient({ - catalogApi, - tokenManager, - }); - for (const [providerId, providerFactory] of Object.entries( allProviderFactories, )) { @@ -128,11 +123,12 @@ export async function createRouter( tokenIssuer, discovery, catalogApi, - resolverContext: { + resolverContext: CatalogAuthResolverContext.create({ logger, + catalogApi, tokenIssuer, - catalogIdentityClient, - }, + tokenManager, + }), }); const r = Router();