From b8ebecd100ac0f4b3fa00cccb91d45d02add729f Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Wed, 6 Jul 2022 23:20:28 +0100 Subject: [PATCH 1/5] Use Azure Identity to Authenticate with Microsoft Graph API Replaced MSAL with Azure Identity to support additional authentication methods with Microsoft Graph - in particular Managed Identity. If `clientId` and clientSecret` are configured, the plugin will continue to behave as before using those credentials. If those values are ommitted, then `DefaultAzureCredential` is choose the best method of authentication. This is particularly helpful locally as if the Azure CLI or Azure Powershell are installed, Azure Identity will use those to authenticate, without the developer needing to configure anything. In those cases, the following config will be sufficient for a working graph plugin. ```yaml catalog: provider: microsoftGraphOrg: default: tenantId: ${AZURE_TENANT_ID} user: filter: accountEnabled eq true and userType eq 'member' group: filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') ``` Signed-off-by: Alex Crome --- .changeset/polite-keys-confess.md | 12 +++ app-config.yaml | 9 ++ .../catalog-backend-module-msgraph/README.md | 14 ++- .../package.json | 6 +- .../src/microsoftGraph/client.test.ts | 30 +++--- .../src/microsoftGraph/client.ts | 43 +++++---- .../src/microsoftGraph/config.test.ts | 93 ++++++++++++------- .../src/microsoftGraph/config.ts | 49 +++++++--- yarn.lock | 2 +- 9 files changed, 169 insertions(+), 89 deletions(-) create mode 100644 .changeset/polite-keys-confess.md diff --git a/.changeset/polite-keys-confess.md b/.changeset/polite-keys-confess.md new file mode 100644 index 0000000000..c83bd20b3a --- /dev/null +++ b/.changeset/polite-keys-confess.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-catalog-backend-module-msgraph': minor +--- + +Microsoft Graph plugin can supports many more options for authenticating with the Microsoft Graph API. +Previously only ClientId/ClientSecret was supported, but now all the authentication options of `DefaultAzureCredential` from `@azure/identity` are supported. +Including Managed Identity, Client Certificate, Azure CLI and VS Code. + +If `clientId` and `clientSecret` are specified in configuration, the plugin behaves the same way as before. +If these fields are omitted, the plugin uses `DefaultAzureCredential` to determine + +This is particularly useful for local development environments - the default configuration will try to use existing credentials from Visual Studio Code, Azure CLI and Azure PowerShell, without the user needing to configure any credentials in app-config.yaml diff --git a/app-config.yaml b/app-config.yaml index 9a3206026e..67a55582c8 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -216,6 +216,15 @@ catalog: - Domain - Location + providers: + microsoftGraphOrg: + default: + tenantId: ${AZURE_TENANT_ID} + user: + filter: accountEnabled eq true and userType eq 'member' + group: + filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') + processors: ldapOrg: ### Example for how to add your enterprise LDAP server diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index 6132d5abc6..d9f004af03 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -6,13 +6,17 @@ This provider is useful if you want to import users and groups from Azure Active ## Getting Started -1. Create or use an existing App registration in the [Microsoft Azure Portal](https://portal.azure.com/). +1. Choose your authentication method + + - If you have a + +1. Create or use an existing App registration or Managed Identity in the [Microsoft Azure Portal](https://portal.azure.com/). The App registration requires at least the API permissions `Group.Read.All`, `GroupMember.Read.All`, `User.Read` and `User.Read.All` for Microsoft Graph (if you still run into errors about insufficient privileges, add `Team.ReadBasic.All` and `TeamMember.Read.All` too). -2. Configure the entity provider: +1. Configure the entity provider: ```yaml # app-config.yaml @@ -159,3 +163,9 @@ export async function myGroupTransformer( }), ); ``` + +## Troubleshooting + +### Authentication Errors + +If you're having problems authenticating, take a look at (Troubleshooting Azure Identity Authentication Issues)[https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/TROUBLESHOOTING.md] diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index 921d198b8b..7609b4c083 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -33,7 +33,7 @@ "start": "backstage-cli package start" }, "dependencies": { - "@azure/msal-node": "^1.1.0", + "@azure/identity": "^2.0.4", "@backstage/backend-tasks": "^0.3.3-next.2", "@backstage/catalog-model": "^1.1.0-next.2", "@backstage/config": "^1.0.1", @@ -43,9 +43,9 @@ "lodash": "^4.17.21", "node-fetch": "^2.6.7", "p-limit": "^3.0.2", + "qs": "^6.9.4", "uuid": "^8.0.0", - "winston": "^3.2.1", - "qs": "^6.9.4" + "winston": "^3.2.1" }, "devDependencies": { "@backstage/backend-common": "^0.14.1-next.2", diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts index 5ff4fa4e0e..94502b9e7d 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.test.ts @@ -14,30 +14,26 @@ * limitations under the License. */ -import * as msal from '@azure/msal-node'; +import { TokenCredential } from '@azure/identity'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { rest } from 'msw'; import { setupServer } from 'msw/node'; import { MicrosoftGraphClient } from './client'; describe('MicrosoftGraphClient', () => { - const confidentialClientApplication: jest.Mocked = - { - acquireTokenByClientCredential: jest.fn(), - } as any; + const tokenCredential: jest.Mocked = { + getToken: jest.fn(), + } as any; let client: MicrosoftGraphClient; const worker = setupServer(); setupRequestMockHandlers(worker); beforeEach(() => { - confidentialClientApplication.acquireTokenByClientCredential.mockResolvedValue( - { token: 'ACCESS_TOKEN' } as any, - ); - client = new MicrosoftGraphClient( - 'https://example.com', - confidentialClientApplication, - ); + tokenCredential.getToken.mockResolvedValue({ + token: 'ACCESS_TOKEN', + } as any); + client = new MicrosoftGraphClient('https://example.com', tokenCredential); }); afterEach(() => { @@ -55,12 +51,10 @@ describe('MicrosoftGraphClient', () => { expect(response.status).toBe(200); expect(await response.json()).toEqual({ value: 'example' }); - expect( - confidentialClientApplication.acquireTokenByClientCredential, - ).toBeCalledTimes(1); - expect( - confidentialClientApplication.acquireTokenByClientCredential, - ).toBeCalledWith({ scopes: ['https://graph.microsoft.com/.default'] }); + expect(tokenCredential.getToken).toBeCalledTimes(1); + expect(tokenCredential.getToken).toBeCalledWith( + 'https://graph.microsoft.com/.default', + ); }); it('should perform simple api request', async () => { diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts index 20e791c5d5..26da0cb64b 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/client.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import * as msal from '@azure/msal-node'; +import { + TokenCredential, + DefaultAzureCredential, + ClientSecretCredential, +} from '@azure/identity'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; import fetch, { Response } from 'node-fetch'; import qs from 'qs'; @@ -77,25 +81,32 @@ export class MicrosoftGraphClient { * @param config - Configuration for Interacting with Graph API */ static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient { - const clientConfig: msal.Configuration = { - auth: { - clientId: config.clientId, - clientSecret: config.clientSecret, - authority: `${config.authority}/${config.tenantId}`, - }, + const options = { + authorityHost: config.authority, + tenantId: config.tenantId, }; - const pca = new msal.ConfidentialClientApplication(clientConfig); - return new MicrosoftGraphClient(config.target, pca); + + const credential = + config.clientId && config.clientSecret + ? new ClientSecretCredential( + config.tenantId, + config.clientId, + config.clientSecret, + options, + ) + : new DefaultAzureCredential(options); + + return new MicrosoftGraphClient(config.target, credential); } /** * @param baseUrl - baseUrl of Graph API {@link MicrosoftGraphProviderConfig.target} - * @param pca - instance of `msal.ConfidentialClientApplication` that is used to acquire token for Graph API calls + * @param tokenCredential - instance of `TokenCredential` that is used to acquire token for Graph API calls * */ constructor( private readonly baseUrl: string, - private readonly pca: msal.ConfidentialClientApplication, + private readonly tokenCredential: TokenCredential, ) {} /** @@ -202,18 +213,18 @@ export class MicrosoftGraphClient { headers?: Record, ): Promise { // Make sure that we always have a valid access token (might be cached) - const token = await this.pca.acquireTokenByClientCredential({ - scopes: ['https://graph.microsoft.com/.default'], - }); + const token = await this.tokenCredential.getToken( + 'https://graph.microsoft.com/.default', + ); if (!token) { - throw new Error('Error while requesting token for Microsoft Graph'); + throw new Error('Failed to obtain token from Azure Identity'); } return await fetch(url, { headers: { ...headers, - Authorization: `Bearer ${token.accessToken}`, + Authorization: `Bearer ${token.token}`, }, }); } diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts index 8239090249..8e2866df65 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.test.ts @@ -25,8 +25,6 @@ describe('readMicrosoftGraphConfig', () => { id: 'target', target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', }, ], }; @@ -36,11 +34,6 @@ describe('readMicrosoftGraphConfig', () => { id: 'target', target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.microsoftonline.com', - userFilter: undefined, - groupFilter: undefined, }, ]; expect(actual).toEqual(expected); @@ -72,7 +65,7 @@ describe('readMicrosoftGraphConfig', () => { tenantId: 'tenantId', clientId: 'clientId', clientSecret: 'clientSecret', - authority: 'https://login.example.com', + authority: 'https://login.example.com/', userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', @@ -87,12 +80,7 @@ describe('readMicrosoftGraphConfig', () => { const config = { providers: [ { - id: 'target', - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', userFilter: 'accountEnabled eq true', userGroupMemberFilter: 'any', }, @@ -105,12 +93,7 @@ describe('readMicrosoftGraphConfig', () => { const config = { providers: [ { - id: 'target', - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', userFilter: 'accountEnabled eq true', userGroupMemberSearch: 'any', }, @@ -118,6 +101,30 @@ describe('readMicrosoftGraphConfig', () => { }; expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); }); + + it('should fail if clientId is set without clientSecret', () => { + const config = { + providers: [ + { + tenantId: 'tenantId', + clientId: 'clientId', + }, + ], + }; + expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); + }); + + it('should fail if clientSecret is set without clientId', () => { + const config = { + providers: [ + { + tenantId: 'tenantId', + clientSecret: 'clientId', + }, + ], + }; + expect(() => readMicrosoftGraphConfig(new ConfigReader(config))).toThrow(); + }); }); describe('readProviderConfigs', () => { @@ -127,10 +134,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', }, }, }, @@ -140,11 +144,8 @@ describe('readProviderConfigs', () => { const expected = [ { id: 'customProviderId', - target: 'target', + target: 'https://graph.microsoft.com/v1.0', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.microsoftonline.com', }, ]; expect(actual).toEqual(expected); @@ -183,7 +184,7 @@ describe('readProviderConfigs', () => { tenantId: 'tenantId', clientId: 'clientId', clientSecret: 'clientSecret', - authority: 'https://login.example.com', + authority: 'https://login.example.com/', userExpand: 'manager', userFilter: 'accountEnabled eq true', groupExpand: 'member', @@ -200,11 +201,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', user: { filter: 'accountEnabled eq true', }, @@ -225,11 +222,7 @@ describe('readProviderConfigs', () => { providers: { microsoftGraphOrg: { customProviderId: { - target: 'target', tenantId: 'tenantId', - clientId: 'clientId', - clientSecret: 'clientSecret', - authority: 'https://login.example.com/', user: { filter: 'accountEnabled eq true', }, @@ -243,4 +236,36 @@ describe('readProviderConfigs', () => { }; expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); }); + + it('should fail if clientId is set without clientSecret', () => { + const config = { + catalog: { + providers: { + microsoftGraphOrg: { + customProviderId: { + tenantId: 'tenantId', + clientId: 'id', + }, + }, + }, + }, + }; + expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); + }); + + it('should fail if clientSecret is set without clientId', () => { + const config = { + catalog: { + providers: { + microsoftGraphOrg: { + customProviderId: { + tenantId: 'tenantId', + clientSecret: 'clientSecret', + }, + }, + }, + }, + }; + expect(() => readProviderConfigs(new ConfigReader(config))).toThrow(); + }); }); diff --git a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts index 30e3a9a4d2..f7675acf42 100644 --- a/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts +++ b/plugins/catalog-backend-module-msgraph/src/microsoftGraph/config.ts @@ -17,7 +17,6 @@ import { Config } from '@backstage/config'; import { trimEnd } from 'lodash'; -const DEFAULT_AUTHORITY = 'https://login.microsoftonline.com'; const DEFAULT_PROVIDER_ID = 'default'; const DEFAULT_TARGET = 'https://graph.microsoft.com/v1.0'; @@ -49,12 +48,14 @@ export type MicrosoftGraphProviderConfig = { tenantId: string; /** * The OAuth client ID to use for authenticating requests. + * If specified, ClientSecret must also be specified */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. + * If specified, ClientId must also be specified */ - clientSecret: string; + clientSecret?: string; /** * The filter to apply to extract users. * @@ -131,14 +132,15 @@ export function readMicrosoftGraphConfig( const providerConfigs = config.getOptionalConfigArray('providers') ?? []; for (const providerConfig of providerConfigs) { - const target = trimEnd(providerConfig.getString('target'), '/'); + const target = trimEnd( + providerConfig.getOptionalString('target') ?? DEFAULT_TARGET, + '/', + ); + const authority = providerConfig.getOptionalString('authority'); - const authority = providerConfig.getOptionalString('authority') - ? trimEnd(providerConfig.getOptionalString('authority'), '/') - : DEFAULT_AUTHORITY; const tenantId = providerConfig.getString('tenantId'); - const clientId = providerConfig.getString('clientId'); - const clientSecret = providerConfig.getString('clientSecret'); + const clientId = providerConfig.getOptionalString('clientId'); + const clientSecret = providerConfig.getOptionalString('clientSecret'); const userExpand = providerConfig.getOptionalString('userExpand'); const userFilter = providerConfig.getOptionalString('userFilter'); @@ -173,6 +175,18 @@ export function readMicrosoftGraphConfig( throw new Error(`queryMode must be one of: basic, advanced`); } + if (clientId && !clientSecret) { + throw new Error( + `clientSecret must be provided when clientId is defined.`, + ); + } + + if (clientSecret && !clientId) { + throw new Error( + `clientId must be provided when clientSecret is defined.`, + ); + } + providers.push({ id: target, target, @@ -225,14 +239,11 @@ export function readProviderConfig( config.getOptionalString('target') ?? DEFAULT_TARGET, '/', ); - const authority = trimEnd( - config.getOptionalString('authority') ?? DEFAULT_AUTHORITY, - '/', - ); + const authority = config.getOptionalString('authority'); - const clientId = config.getString('clientId'); - const clientSecret = config.getString('clientSecret'); const tenantId = config.getString('tenantId'); + const clientId = config.getOptionalString('clientId'); + const clientSecret = config.getOptionalString('clientSecret'); const userExpand = config.getOptionalString('user.expand'); const userFilter = config.getOptionalString('user.filter'); @@ -260,6 +271,14 @@ export function readProviderConfig( ); } + if (clientId && !clientSecret) { + throw new Error(`clientSecret must be provided when clientId is defined.`); + } + + if (clientSecret && !clientId) { + throw new Error(`clientId must be provided when clientSecret is defined.`); + } + return { id, target, diff --git a/yarn.lock b/yarn.lock index 4393aedfb8..88993d0568 100644 --- a/yarn.lock +++ b/yarn.lock @@ -349,7 +349,7 @@ resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-7.1.0.tgz#b77dbf9ae581f1ed254f81d56422e3cdd6664b32" integrity sha512-WyfqE5mY/rggjqvq0Q5DxLnA33KSb0vfsUjxa95rycFknI03L5GPYI4HTU9D+g0PL5TtsQGnV3xzAGq9BFCVJQ== -"@azure/msal-node@^1.1.0", "@azure/msal-node@^1.3.0": +"@azure/msal-node@^1.3.0": version "1.11.0" resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.11.0.tgz#d8bd3f15c1f05bf806ba6f9479c48c2eddd6a98d" integrity sha512-KW/XEexfCrPzdYbjY7NVmhq9okZT3Jvck55CGXpz9W5asxeq3EtrP45p+ZXtQVEfko0YJdolpCNqWUyXvanWZg== From 346d5aa74638dbda1791173285c255ab462a5124 Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Thu, 7 Jul 2022 18:55:10 +0100 Subject: [PATCH 2/5] Added API Report Signed-off-by: Alex Crome --- plugins/catalog-backend-module-msgraph/api-report.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/catalog-backend-module-msgraph/api-report.md b/plugins/catalog-backend-module-msgraph/api-report.md index a4083b1d9e..8f773e1b90 100644 --- a/plugins/catalog-backend-module-msgraph/api-report.md +++ b/plugins/catalog-backend-module-msgraph/api-report.md @@ -12,9 +12,9 @@ import { GroupEntity } from '@backstage/catalog-model'; import { LocationSpec } from '@backstage/plugin-catalog-backend'; import { Logger } from 'winston'; import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; -import * as msal from '@azure/msal-node'; import { Response as Response_2 } from 'node-fetch'; import { TaskRunner } from '@backstage/backend-tasks'; +import { TokenCredential } from '@azure/identity'; import { UserEntity } from '@backstage/catalog-model'; // @public @@ -65,7 +65,7 @@ export const MICROSOFT_GRAPH_USER_ID_ANNOTATION = 'graph.microsoft.com/user-id'; // @public export class MicrosoftGraphClient { - constructor(baseUrl: string, pca: msal.ConfidentialClientApplication); + constructor(baseUrl: string, tokenCredential: TokenCredential); static create(config: MicrosoftGraphProviderConfig): MicrosoftGraphClient; getGroupMembers(groupId: string): AsyncIterable; // (undocumented) @@ -190,8 +190,8 @@ export type MicrosoftGraphProviderConfig = { target: string; authority?: string; tenantId: string; - clientId: string; - clientSecret: string; + clientId?: string; + clientSecret?: string; userFilter?: string; userExpand?: string; userGroupMemberFilter?: string; From 5107aa6048d7cd0e1c9ab8936e2164a5b2820e9a Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Thu, 7 Jul 2022 21:58:00 +0100 Subject: [PATCH 3/5] Updated docs Signed-off-by: Alex Crome --- docs/integrations/azure/org.md | 233 ++++++++++++++++++++++++++++++++- 1 file changed, 230 insertions(+), 3 deletions(-) diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md index 8e17bb2cc1..b5b8ac6f8d 100644 --- a/docs/integrations/azure/org.md +++ b/docs/integrations/azure/org.md @@ -3,12 +3,239 @@ id: org title: Microsoft Azure Active Directory Organizational Data sidebar_label: Org Data # prettier-ignore -description: Importing users and groups from a Microsoft Azure Active Directory into Backstage +description: Importing users and groups from Microsoft Azure Active Directory into Backstage --- The Backstage catalog can be set up to ingest organizational data - users and teams - directly from a tenant in Microsoft Azure Active Directory via the Microsoft Graph API. -More details on this are available in the -[README of the `@backstage/plugin-catalog-backend-module-msgraph` package](https://github.com/backstage/backstage/blob/master/plugins/catalog-backend-module-msgraph/README.md). +## Installation + +The package is not installed by default, therefore you have to add `@backstage/plugin-catalog-backend-module-msgraph` to your backend package. + +```bash +# From your Backstage root directory +yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-msgraph +``` + +Next add the basic configuration to `app-config.yaml` + +```yaml +catalog: + providers: + microsoftGraphOrg: + default: + tenantId: ${AZURE_TENANT_ID} + user: + filter: accountEnabled eq true and userType eq 'member' + group: + filter: > + securityEnabled eq false + and mailEnabled eq true + and groupTypes/any(c:c+eq+'Unified') +``` + +Finally, register the plugin in `catalog.ts`. +For large organizations, this plugin can take a long time, so be careful setting low frequency / timeouts. + +```diff + // packages/backend/src/plugins/catalog.ts ++import { MicrosoftGraphOrgEntityProvider } from '@backstage/plugin-catalog-backend-module-msgraph'; + + export default async function createPlugin( + env: PluginEnvironment, + ): Promise { + const builder = await CatalogBuilder.create(env); + ++ builder.addEntityProvider( ++ MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { ++ logger: env.logger, ++ schedule: env.scheduler.createScheduledTaskRunner({ ++ frequency: { hours: 1 }, ++ timeout: { minutes: 50 }, ++ initialDelay: { seconds: 15} ++ }), ++ }), ++ ); +``` + +## Authenticating with Microsoft Graph + +### Local Development + +For a local dev environment, it's recommended you have the Azure CLI or Azure PowerShell installed, and are logged in to those. +Alternatively you can use VSCode with the Azure extension if you install `@azure/identity-vscode`. +When these are set up, the plugin will authenticate with the Microsoft Graph API without you needing to configure any credentials, or granting any special permissions. +If you can't do this, you'll have to create an App Registration. + +## App Registration + +If none of the other authentication methods work, you can create an app registration in the azure portal. +By default the graph plugin requires the following Application permissions (not Delegated) for Microsoft Graph: + +- `GroupMember.Read.All` +- `User.Read.All` + +If your organization required Admin Consent for these permissions, that will need to be granted. + +When authenticating with a ClientId/ClientSecret, you can either set the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_SECRET` environment variables, or specify the values in configuration + +```yaml +microsoftGraphOrg: + default: + ##... + clientId: 9ef1aac6-b454-4e69-9cf5-7199df049281 + clientSecret: REDACTED +``` + +To authenticate with a certificate rather than a client secret, you can set the `AZURE_TENANT_ID`, `AZURE_CLIENT_ID` and `AZURE_CLIENT_CERTIFICATE_PATH` environments + +### Managed Identity + +If deploying to resources that supports Managed Identity, and has identities configured (e.g. Azure App Services, Azure Container Apps), Managed Identity should be picked up without any additional configuration. +If your app has multiple managed identities, you may need to set the `AZURE_CLIENT_ID` environment variable to tell Azure Identity which identity to use. + +You will need to grant the same permissions to your identity as described for App Registrations above. + +## Filtering imported Users and Groups + +By default, the plugin will import all users and groups from your directory. +This can be customized through filters and search queries. + +### Groups + +A smaller set of groups can be obtained by configuring a search query or a filter. +If both `filter` and `search` are provided, then groups must match both to be ingested. + +```yaml +microsoftGraphOrg: + providerId: + group: + filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') + search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' +``` + +In addition to these groups, one additional group will be created for your organization. +All imported groups will be a child of this group. + +### Users + +There are two modes for importing users - You can import all user objects matching a `filter`. + +```yaml +microsoftGraphOrg: + providerId: + user: + filter: accountEnabled eq true and userType eq 'member' +``` + +Alternatively you can import users that are members of specific groups. +For each group matching the `search` and `filter` query, each group member will be imported. +Only direct group members will be imported, not transient users. + +```yaml +microsoftGraphOrg: + providerId: + userGroupMember: + filter: "displayName eq 'Backstage Users'" + search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")' +``` + +## Customizing Transformation + +Ingested entities can be customized by providing custom transformers. +These can be used to completely replace the built in logic, or used to tweak it by using the default transformers (`defaultGroupTransformer`, `defaultUserTransformer` and `defaultOrganizationTransformer` +Entities can also be excluded from backstage by returning `undefined`. + +These Transformers are be registered when configuring `MicrosoftGraphOrgEntityProvider` + +```diff + builder.addEntityProvider( + MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { + // ... ++ groupTransformer: myGroupTransformer, ++ userTransformer: myUserTransformer, ++ organizationTransformer: myOrganizationTransformer, + }), + ); +``` + +When using custom transformers, you may want to customize the data returned. +Several configuration options can be provided to tweak the Microsoft Graph query to get the data you need + +```yaml +microsoftGraphOrg: + providerId: + user: + expand: manager + group: + expand: member + select: ['id', 'displayName', 'description'] +``` + +The following provides an example of each kind of transformer + +```ts +import * as MicrosoftGraph from '@microsoft/microsoft-graph-types'; +import { + defaultGroupTransformer, + defaultUserTransformer, + defaultOrganizationTransformer, +} from '@backstage/plugin-catalog-backend-module-msgraph'; +import { GroupEntity, UserEntity } from '@backstage/catalog-model'; + +// This group transformer completely replaces the built in logic with custom logic. +export async function myGroupTransformer( + group: MicrosoftGraph.Group, + groupPhoto?: string, +): Promise { + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Group', + metadata: { + name: group.id!, + annotations: {}, + }, + spec: { + type: 'aad', + children: [], + }, + }; +} + +// This user transformer makes use of the built in logic, but also sets the description field +export async function myUserTransformer( + graphUser: MicrosoftGraph.User, + userPhoto?: string, +): Promise { + const backstageUser = await defaultUserTransformer(graphUser, userPhoto); + + if (backstageUser) { + backstageUser.metadata.description = 'Loaded from Azure Active Directory'; + } + + return backstageUser; +} + +// Example organization transformer that removes the organization group completely +export async function myOrganizationTransformer( + graphOrganization: MicrosoftGraph.Organization, +): Promise { + return undefined; +} +``` + +## Troubleshooting + +### Authentication / Token Errors + +See (Troubleshooting Azure Identity Authentication Issues)[https://aka.ms/azsdk/js/identity/troubleshoot] + +### Error while reading users from Microsoft Graph: Authorization_RequestDenied - Insufficient privileges to complete the operation. + +- Make sure you've granted all the permissions required +- Make sure the permission is an `Application` permission rather than `Delegated` +- If your organization has configured "Admin consent" to be required, make sure this has been granted for your application permissions +- If your group queries are returning Microsoft Teams groups, you may need to grant addition permissions (e.g. `Team.ReadBasic.All`, `TeamMember.Read.All`) +- If you've added additional `select` fields, those may need additional permissions granted From e51c9614532776d1951b76e8cc85bb2c8db4011c Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Fri, 8 Jul 2022 16:20:40 +0100 Subject: [PATCH 4/5] PR feedback Signed-off-by: Alex Crome --- app-config.yaml | 9 ------ .../catalog-backend-module-msgraph/README.md | 32 +++++++++---------- .../config.d.ts | 8 ++--- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/app-config.yaml b/app-config.yaml index 67a55582c8..9a3206026e 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -216,15 +216,6 @@ catalog: - Domain - Location - providers: - microsoftGraphOrg: - default: - tenantId: ${AZURE_TENANT_ID} - user: - filter: accountEnabled eq true and userType eq 'member' - group: - filter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') - processors: ldapOrg: ### Example for how to add your enterprise LDAP server diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index d9f004af03..a052a80c58 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -6,15 +6,16 @@ This provider is useful if you want to import users and groups from Azure Active ## Getting Started -1. Choose your authentication method +1. Choose your authentication method - all methods supported by [DefaultAzureCredential](https://docs.microsoft.com/en-us/javascript/api/overview/azure/identity-readme?view=azure-node-latest#defaultazurecredential) - - If you have a + - For local dev, use Azure CLI, Azure PowerShell or Visual Studio Code for authentication + - If your infrastructure supports Managed Identity, use that + - Otherwise use an App Registration -1. Create or use an existing App registration or Managed Identity in the [Microsoft Azure Portal](https://portal.azure.com/). - The App registration requires at least the API permissions `Group.Read.All`, - `GroupMember.Read.All`, `User.Read` and `User.Read.All` for Microsoft Graph - (if you still run into errors about insufficient privileges, add - `Team.ReadBasic.All` and `TeamMember.Read.All` too). +1. If using Managed Identity or App Registration for authentication, grant the following application permissions (not delegated) + + - `GroupMember.Read.All` + - `User.Read.All` 1. Configure the entity provider: @@ -28,11 +29,13 @@ catalog: authority: https://login.microsoftonline.com # If you don't know you tenantId, you can use Microsoft Graph Explorer # to query it - tenantId: ${MICROSOFT_GRAPH_TENANT_ID} + tenantId: ${AZURE_TENANT_ID} + # Optional ClientId and ClientSecret if you don't want to use `DefaultAzureCredential` + # for authentication # Client Id and Secret can be created under Certificates & secrets in # the App registration in the Microsoft Azure Portal. - clientId: ${MICROSOFT_GRAPH_CLIENT_ID} - clientSecret: ${MICROSOFT_GRAPH_CLIENT_SECRET_TOKEN} + clientId: ${AZURE_CLIENT_ID} + clientSecret: ${AZURE_CLIENT_SECRET} # Optional mode for querying which defaults to "basic". # By default, the Microsoft Graph API only provides the basic feature set # for querying. Certain features are limited to advanced querying capabilities. @@ -112,8 +115,9 @@ yarn add --cwd packages/backend @backstage/plugin-catalog-backend-module-msgraph + MicrosoftGraphOrgEntityProvider.fromConfig(env.config, { + logger: env.logger, + schedule: env.scheduler.createScheduledTaskRunner({ -+ frequency: { minutes: 5 }, -+ timeout: { minutes: 3 }, ++ frequency: { hours: 1 }, ++ timeout: { minutes: 50 }, ++ initialDelay: { seconds: 15} + }), + }), + ); @@ -165,7 +169,3 @@ export async function myGroupTransformer( ``` ## Troubleshooting - -### Authentication Errors - -If you're having problems authenticating, take a look at (Troubleshooting Azure Identity Authentication Issues)[https://github.com/Azure/azure-sdk-for-js/blob/main/sdk/identity/identity/TROUBLESHOOTING.md] diff --git a/plugins/catalog-backend-module-msgraph/config.d.ts b/plugins/catalog-backend-module-msgraph/config.d.ts index 564ccbb10a..8748df5974 100644 --- a/plugins/catalog-backend-module-msgraph/config.d.ts +++ b/plugins/catalog-backend-module-msgraph/config.d.ts @@ -50,13 +50,13 @@ export interface Config { /** * The OAuth client ID to use for authenticating requests. */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. * * @visibility secret */ - clientSecret: string; + clientSecret?: string; // TODO: Consider not making these config options and pass them in the // constructor instead. They are probably not environment specific, so @@ -130,13 +130,13 @@ export interface Config { /** * The OAuth client ID to use for authenticating requests. */ - clientId: string; + clientId?: string; /** * The OAuth client secret to use for authenticating requests. * * @visibility secret */ - clientSecret: string; + clientSecret?: string; user?: { /** From e4ef21c84f113ea882765b95a604e8a4b66a8d35 Mon Sep 17 00:00:00 2001 From: Alex Crome Date: Wed, 13 Jul 2022 08:02:47 +0100 Subject: [PATCH 5/5] Misc small fixes * Updated @azure/identity to improve error message when no credentials are avaialble. * Misc small text fixes Signed-off-by: Alex Crome --- .changeset/polite-keys-confess.md | 3 +- docs/integrations/azure/org.md | 8 ++-- .../catalog-backend-module-msgraph/README.md | 2 - .../package.json | 2 +- yarn.lock | 47 ++++++++++++++++++- 5 files changed, 51 insertions(+), 11 deletions(-) diff --git a/.changeset/polite-keys-confess.md b/.changeset/polite-keys-confess.md index c83bd20b3a..007ad98641 100644 --- a/.changeset/polite-keys-confess.md +++ b/.changeset/polite-keys-confess.md @@ -7,6 +7,5 @@ Previously only ClientId/ClientSecret was supported, but now all the authenticat Including Managed Identity, Client Certificate, Azure CLI and VS Code. If `clientId` and `clientSecret` are specified in configuration, the plugin behaves the same way as before. -If these fields are omitted, the plugin uses `DefaultAzureCredential` to determine - +If these fields are omitted, the plugin uses `DefaultAzureCredential` to automatically determine the best authentication method. This is particularly useful for local development environments - the default configuration will try to use existing credentials from Visual Studio Code, Azure CLI and Azure PowerShell, without the user needing to configure any credentials in app-config.yaml diff --git a/docs/integrations/azure/org.md b/docs/integrations/azure/org.md index b5b8ac6f8d..37ef0634fc 100644 --- a/docs/integrations/azure/org.md +++ b/docs/integrations/azure/org.md @@ -230,12 +230,12 @@ export async function myOrganizationTransformer( ### Authentication / Token Errors -See (Troubleshooting Azure Identity Authentication Issues)[https://aka.ms/azsdk/js/identity/troubleshoot] +See [Troubleshooting Azure Identity Authentication Issues](https://aka.ms/azsdk/js/identity/troubleshoot) ### Error while reading users from Microsoft Graph: Authorization_RequestDenied - Insufficient privileges to complete the operation. -- Make sure you've granted all the permissions required -- Make sure the permission is an `Application` permission rather than `Delegated` +- Make sure you've granted all the required permissions to your application registration or managed identity +- Make sure the permissions are `Application` permissions rather than `Delegated` - If your organization has configured "Admin consent" to be required, make sure this has been granted for your application permissions - If your group queries are returning Microsoft Teams groups, you may need to grant addition permissions (e.g. `Team.ReadBasic.All`, `TeamMember.Read.All`) -- If you've added additional `select` fields, those may need additional permissions granted +- If you've added additional `select` or `expand` fields, those may need additional permissions granted diff --git a/plugins/catalog-backend-module-msgraph/README.md b/plugins/catalog-backend-module-msgraph/README.md index a052a80c58..23166a755d 100644 --- a/plugins/catalog-backend-module-msgraph/README.md +++ b/plugins/catalog-backend-module-msgraph/README.md @@ -167,5 +167,3 @@ export async function myGroupTransformer( }), ); ``` - -## Troubleshooting diff --git a/plugins/catalog-backend-module-msgraph/package.json b/plugins/catalog-backend-module-msgraph/package.json index 7609b4c083..0e4c953890 100644 --- a/plugins/catalog-backend-module-msgraph/package.json +++ b/plugins/catalog-backend-module-msgraph/package.json @@ -33,7 +33,7 @@ "start": "backstage-cli package start" }, "dependencies": { - "@azure/identity": "^2.0.4", + "@azure/identity": "^2.1.0", "@backstage/backend-tasks": "^0.3.3-next.2", "@backstage/catalog-model": "^1.1.0-next.2", "@backstage/config": "^1.0.1", diff --git a/yarn.lock b/yarn.lock index 88993d0568..354d6616c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -287,6 +287,20 @@ "@opentelemetry/api" "^1.0.1" tslib "^2.2.0" +"@azure/core-tracing@^1.0.0": + version "1.0.1" + resolved "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.0.1.tgz#352a38cbea438c4a83c86b314f48017d70ba9503" + integrity sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw== + dependencies: + tslib "^2.2.0" + +"@azure/core-util@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@azure/core-util/-/core-util-1.0.0.tgz#07c7175670e0abe725ad88f9c3d65d074107a3af" + integrity sha512-yWshY9cdPthlebnb3Zuz/j0Lv4kjU6u7PR5sW7A9FF7EX+0irMRJAtyTq5TPiDHJfjH8gTSlnIYFj9m7Ed76IQ== + dependencies: + tslib "^2.2.0" + "@azure/core-util@^1.0.0-beta.1": version "1.0.0-beta.1" resolved "https://registry.npmjs.org/@azure/core-util/-/core-util-1.0.0-beta.1.tgz#2efd2c74b4b0a38180369f50fe274a3c4cd36e98" @@ -316,6 +330,28 @@ tslib "^2.2.0" uuid "^8.3.0" +"@azure/identity@^2.1.0": + version "2.1.0" + resolved "https://registry.npmjs.org/@azure/identity/-/identity-2.1.0.tgz#89f0bfc1d1264dfd3d0cb19837c33a9c6706d548" + integrity sha512-BPDz1sK7Ul9t0l9YKLEa8PHqWU4iCfhGJ+ELJl6c8CP3TpJt2urNCbm0ZHsthmxRsYoMPbz2Dvzj30zXZVmAFw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.3.0" + "@azure/core-client" "^1.4.0" + "@azure/core-rest-pipeline" "^1.1.0" + "@azure/core-tracing" "^1.0.0" + "@azure/core-util" "^1.0.0" + "@azure/logger" "^1.0.0" + "@azure/msal-browser" "^2.26.0" + "@azure/msal-common" "^7.0.0" + "@azure/msal-node" "^1.10.0" + events "^3.0.0" + jws "^4.0.0" + open "^8.0.0" + stoppable "^1.1.0" + tslib "^2.2.0" + uuid "^8.3.0" + "@azure/logger@^1.0.0": version "1.0.1" resolved "https://registry.npmjs.org/@azure/logger/-/logger-1.0.1.tgz#19b333203d1b2931353d8879e814b64a7274837a" @@ -330,6 +366,13 @@ dependencies: "@azure/msal-common" "^5.2.0" +"@azure/msal-browser@^2.26.0": + version "2.27.0" + resolved "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-2.27.0.tgz#3db38db6bc2bae44485025ba9bb99c43ed7f4302" + integrity sha512-PyATq2WvK+x32waRqqikym8wvn939iO9UhpFqhLwitNrfLa3PHUgJuuI9oLSQOS3/UzjYb8aqN+XzchU3n/ZuQ== + dependencies: + "@azure/msal-common" "^7.1.0" + "@azure/msal-common@^4.5.1": version "4.5.1" resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-4.5.1.tgz#f35af8b634ae24aebd0906deb237c0db1afa5826" @@ -344,12 +387,12 @@ dependencies: debug "^4.1.1" -"@azure/msal-common@^7.1.0": +"@azure/msal-common@^7.0.0", "@azure/msal-common@^7.1.0": version "7.1.0" resolved "https://registry.npmjs.org/@azure/msal-common/-/msal-common-7.1.0.tgz#b77dbf9ae581f1ed254f81d56422e3cdd6664b32" integrity sha512-WyfqE5mY/rggjqvq0Q5DxLnA33KSb0vfsUjxa95rycFknI03L5GPYI4HTU9D+g0PL5TtsQGnV3xzAGq9BFCVJQ== -"@azure/msal-node@^1.3.0": +"@azure/msal-node@^1.10.0", "@azure/msal-node@^1.3.0": version "1.11.0" resolved "https://registry.npmjs.org/@azure/msal-node/-/msal-node-1.11.0.tgz#d8bd3f15c1f05bf806ba6f9479c48c2eddd6a98d" integrity sha512-KW/XEexfCrPzdYbjY7NVmhq9okZT3Jvck55CGXpz9W5asxeq3EtrP45p+ZXtQVEfko0YJdolpCNqWUyXvanWZg==