Microsoft Entra 테넌트 데이터
Backstage 카탈로그는 Microsoft Graph API를 통해 Microsoft Entra ID의 테넌트에서 조직 데이터(사용자와 팀)를 직접 가져오도록 설정할 수 있어요.
출처: 문서
본문
Backstage 카탈로그는 Microsoft Graph API를 통해 Microsoft Entra ID의 테넌트에서 조직 데이터(사용자와 팀)를 직접 가져오도록 설정할 수 있어요.
설치
이 패키지는 기본으로 설치되지 않으므로 @backstage/plugin-catalog-backend-module-msgraph를 백엔드 패키지에 추가해야 해요.
Backstage 루트 디렉터리에서
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-msgraph
다음으로 app-config.yaml에 기본 구성을 추가하세요.
app-config.yaml
catalog: providers: microsoftGraphOrg: default: tenantId: ${AZURE_TENANT_ID} user: filter: userType eq 'member' group: filter: > securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified') schedule: frequency: PT1H timeout: PT50M
note
대규모 조직에서는 이 플러그인이 오래 걸릴 수 있으므로, 낮은 frequency/timeout을 설정하거나 첫 시도에 많은 사용자/그룹을 가져오지 않도록 주의하세요.
마지막으로 다음 줄을 추가해 백엔드를 업데이트하세요.
packages/backend/src/index.ts
backend.add(import('@backstage/plugin-catalog-backend'));backend.add(import('@backstage/plugin-catalog-backend-module-msgraph'));
대규모 테넌트를 위한 증분 수집
전체 데이터셋을 한 번에 메모리에 로드할 수 없는 아주 큰 Azure AD 테넌트의 경우, @backstage/plugin-catalog-backend-module-msgraph-incremental 패키지가 메모리 효율적인 대안을 제공해요. 사용자와 그룹을 한 페이지씩 처리하고 @odata.nextLink 커서를 유지해서, 팟 재시작 후 마지막으로 완료된 페이지부터 수집을 재개해요.
Backstage 루트 디렉터리에서
yarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-incremental-ingestionyarn --cwd packages/backend add @backstage/plugin-catalog-backend-module-msgraph-incremental
packages/backend/src/index.ts
backend.add(import('@backstage/plugin-catalog-backend'));backend.add( import('@backstage/plugin-catalog-backend-module-incremental-ingestion'),);backend.add( import('@backstage/plugin-catalog-backend-module-msgraph-incremental'),);
표준 모듈과 같은 catalog.providers.microsoftGraphOrg 구성을 사용해요. 증분 제공자는 다음 옵션을 지원하지 않아요: userGroupMember*와 groupIncludeSubGroups. 그것들이 필요하다면 MicrosoftGraphOrgEntityProvider를 사용하세요.
| | | MicrosoftGraphOrgEntityProvider | Incremental provider | Memory usage | Full dataset in RAM | One page at a time | Resume on restart | Starts from scratch | Resumes from cursor | userGroupMember* options | Supported | Not supported | groupIncludeSubGroups | Supported | Not supported | Suitable for large tenants | No | Yes
Microsoft Graph로 인증하기
로컬 개발
로컬 개발 환경에서는 Azure CLI나 Azure PowerShell을 설치하고 로그인해 두는 것이 좋아요. @azure/identity-vscode를 설치하면 Azure 확장이 있는 VSCode를 사용할 수도 있어요. 이들이 설정되면 자격 증명을 구성하거나 특별한 권한을 부여하지 않아도 플러그인이 Microsoft Graph API로 인증해요. 그렇게 할 수 없다면 앱 등록을 만들어야 해요.
앱 등록
다른 인증 방법이 모두 작동하지 않으면 Azure 포털에서 앱 등록을 만들 수 있어요. 기본적으로 graph 플러그인은 Microsoft Graph에 대해 다음 애플리케이션 권한(Delegated 아님)을 요구해요.
-
GroupMember.Read.All -
User.Read.All
조직이 이 권한에 대해 관리자 동의를 요구한다면 그 동의를 받아야 해요.
ClientId/ClientSecret으로 인증할 때는 AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET 환경 변수를 설정하거나 구성에서 값을 지정할 수 있어요.
microsoftGraphOrg: default: ##... clientId: 9ef1aac6-b454-4e69-9cf5-7199df049281 clientSecret: REDACTED
클라이언트 비밀이 아니라 인증서로 인증하려면 AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_CERTIFICATE_PATH 환경을 설정할 수 있어요.
관리 ID
Managed Identity를 지원하고 ID가 구성된 리소스(예: Azure App Services, Azure Container Apps)에 배포한다면 추가 구성 없이 Managed Identity가 자동으로 감지돼요. 앱에 관리 ID가 여러 개라면 Azure Identity에 어떤 ID를 사용할지 알려주기 위해 AZURE_CLIENT_ID 환경 변수를 설정해야 할 수 있어요.
관리 ID에 위의 App Registration에서 언급한 것과 같은 권한을 부여하려면 이 가이드를 따라가세요.
가져온 사용자와 그룹 필터링
기본적으로 이 플러그인은 디렉터리의 모든 활성 사용자와 모든 그룹을 가져와요. 비활성 사용자 계정(accountEnabled eq false)은 자동으로 제외돼요. 이는 필터와 검색 쿼리를 통해 더 커스터마이즈할 수 있어요. 커스텀 user.filter는 and를 사용해 기본 accountEnabled eq true 필터와 결합돼요.
그룹
검색 쿼리나 필터를 구성해 더 작은 그룹 집합을 얻을 수 있어요. filter와 search가 모두 제공되면 그룹은 수집되기 위해 둘 다 일치해야 해요.
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")'
search와/또는 filter 쿼리에 일치하는 그룹만 수집하고 싶지 않고, 일치하는 그룹의 멤버인 그룹도 수집하고 싶다면 includeSubGroups 구성을 사용할 수 있어요.
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")' includeSubGroups: true
이 그룹들 외에도 조직을 위해 추가 그룹이 하나 생성돼요. 가져온 모든 그룹은 이 그룹의 자식이 돼요.
기본적으로 제공자는 msgraph /group 엔드포인트를 사용해 그룹을 얻지만, path 구성을 설정해 다른 엔드포인트를 사용할 수도 있어요. /microsoft.graph.group을 포함하는 모든 엔드포인트는 올바른 유형의 그룹 객체를 반환해요. 자세한 내용은 usage를 참고하세요.
사용자
사용자를 가져오는 두 가지 모드가 있어요. filter에 일치하는 모든 사용자 객체를 가져올 수 있어요. accountEnabled eq true 기본 필터는 자동으로 적용되며, 제공한 커스텀 필터와 결합돼요.
microsoftGraphOrg: providerId: user: filter: userType eq 'member'
또는 특정 그룹의 멤버인 사용자를 가져올 수도 있어요. search와 filter 쿼리에 일치하는 각 그룹에 대해, 각 그룹 멤버가 가져와져요. 직접 그룹 멤버만 가져오며, transient 사용자는 가져오지 않아요.
microsoftGraphOrg: providerId: userGroupMember: filter: "displayName eq 'Backstage Users'" search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
기본적으로 제공자는 msgraph /user 엔드포인트를 사용해 사용자를 얻지만, path 구성을 설정해 다른 엔드포인트를 사용할 수도 있어요. /microsoft.graph.user를 포함하는 모든 엔드포인트는 올바른 유형의 사용자 객체를 반환해요. 자세한 내용은 usage를 참고하세요.
path 매개변수 사용하기
기본적으로 제공자는 msgraph /group과 /user 엔드포인트를 사용해 그룹과 사용자를 얻지만, path 구성을 설정해 다른 엔드포인트를 사용할 수도 있어요. /microsoft.graph.user를 포함하는 모든 엔드포인트는 올바른 유형의 사용자 객체를, /microsoft.graph.group을 포함하는 모든 엔드포인트는 올바른 유형의 그룹 객체를 반환해요.
예시
다음 조직 구조가 주어지면 path 매개변수를 사용해 그룹 someRootGroup의 멤버인 모든 수준의 사용자와 그룹을 얻을 수 있어요.
구성은 이렇게 생겼어요.
microsoftGraphOrg: providerId: group: path: /groups/{someRootGroup id}/transitiveMembers/microsoft.graph.group user: path: /groups/{someRootGroup id}/transitiveMembers/microsoft.graph.user
transitive members 엔드포인트를 사용하면 그룹 someRootGroup의 멤버인 모든 수준의 사용자와 그룹이 모두 반환돼요.
사용자 사진
기본적으로 사용자의 사진이 가져와져 각 사용자 엔티티에 추가돼요. 아주 큰 조직에서는 이것이 매우 오래 걸릴 수 있어 비현실적이며, loadPhotos를 false로 설정해 비활성화할 수 있어요.
microsoftGraphOrg: providerId: user: filter: ... loadPhotos: false
userGroupMember를 사용한다면 loadPhotos 구성은 여전히 users: 아래에서 search와 filters를 생략한 채 관리해야 해요.
microsoftGraphOrg: providerId: user: loadPhotos: false userGroupMember: filter: "displayName eq 'Backstage Users'" search: '"description:One" AND ("displayName:Video" OR "displayName:Drive")'
변환 커스터마이즈하기
커스텀 트랜스포머를 제공해 수집된 엔티티를 커스터마이즈할 수 있어요. 이것들로 내장 로직을 완전히 대체하거나, 기본 트랜스포머(defaultGroupTransformer, defaultUserTransformer, defaultOrganizationTransformer)를 사용해 조정할 수 있어요. undefined를 반환하면 엔티티를 Backstage에서 제외할 수도 있어요.
커스텀 트랜스포머를 사용할 때 반환되는 데이터를 커스터마이즈하고 싶을 수 있어요. 필요한 데이터를 얻으려면 Microsoft Graph 쿼리를 조정하는 여러 구성 옵션을 제공할 수 있어요.
microsoftGraphOrg: providerId: user: expand: manager group: expand: member select: ['id', 'displayName', 'description']
Provider Config Transformer 사용하기
동적 구성 확장을 사용하면 msgraph 카탈로그 플러그인이 재배포 없이 런타임에 설정을 조정할 수 있어요. 이 기능은 실시간 이벤트나 변화하는 조건에 따라 구성이 업데이트되어야 하는 시나리오에 유용해요. 예를 들어 동기화 일정, 필터, 검색 매개변수를 동적으로 조정해 성능과 응답성을 최적화할 수 있어요.
note
각 예약 수집에서 사용되지 않는 필드(예: id, schedule)를 조정하는 것은 효과가 없습니다.
warning
구성을 즉석에서 동적으로 변경하면 시스템 불안정이나 구성 오류 같은 의도하지 않은 결과가 발생할 수 있습니다. 트랜스포머가 예상대로 동작하는지 신중히 검토하세요!
예시 사용 사례:
-
필터 확장:
userGroupMember와groupFilter같은 필터를 동적으로 조정하세요. -
검색 매개변수 조정:
groupSearch와userSelect같은 검색 매개변수를 즉석에서 변경하세요.
커스텀 트랜스포머 사용하기
트랜스포머는 microsoftGraphOrgEntityProviderTransformExtensionPoint를 확장해 구성할 수 있어요. 예시는 다음과 같아요.
packages/backend/src/index.ts
import { createBackendModule } from '@backstage/backend-plugin-api';import { microsoftGraphOrgEntityProviderTransformExtensionPoint } from '@backstage/plugin-catalog-backend-module-msgraph/alpha';import { myUserTransformer, myGroupTransformer, myOrganizationTransformer, myProviderConfigTransformer,} from './transformers';backend.add( createBackendModule({ pluginId: 'catalog', moduleId: 'microsoft-graph-extensions', register(env) { env.registerInit({ deps: { microsoftGraphTransformers: microsoftGraphOrgEntityProviderTransformExtensionPoint, }, async init({ microsoftGraphTransformers }) { microsoftGraphTransformers.setUserTransformer(myUserTransformer); microsoftGraphTransformers.setGroupTransformer(myGroupTransformer); microsoftGraphTransformers.setOrganizationTransformer( myOrganizationTransformer, ); microsoftGraphTransformers.setProviderConfigTransformer( myProviderConfigTransformer, ); }, }); }, }),);
myUserTransformer, myGroupTransformer, myOrganizationTransformer, myProviderConfigTransformer 트랜스포머 함수는 아래 섹션의 예시에서 가져온 거예요.
트랜스포머 예시
다음은 각 종류의 트랜스포머에 대한 예시예요. 이것들을 위한 transformers.ts 파일을 packages/backend/src 폴더에 만드는 것을 권장해요.
먼저, 각 종류의 트랜스포머에 대해 기본 트랜스포머를 그대로 전달하는 함수가 있는 파일의 기본 구조를 설정해 봅시다.
packages/backend/src/extensions/transformers.ts
import * as MicrosoftGraph from '@microsoft/microsoft-graph-types';import { defaultGroupTransformer, defaultUserTransformer, defaultOrganizationTransformer, microsoftGraphOrgEntityProviderTransformExtensionPoint, MicrosoftGraphProviderConfig,} from '@backstage/plugin-catalog-backend-module-msgraph';import { GroupEntity, UserEntity } from '@backstage/catalog-model';import { createBackendModule } from '@backstage/backend-plugin-api';// The Group transformer transforms Groups that are ingested from MS Graphexport async function myGroupTransformer( group: MicrosoftGraph.Group, groupPhoto?: string,): Promise<GroupEntity | undefined> { const backstageGroup = await defaultGroupTransformer(group, groupPhoto); return backstageGroup;}// The User transformer transforms Users that are ingested from MS Graphexport async function myUserTransformer( graphUser: MicrosoftGraph.User, userPhoto?: string,): Promise<UserEntity | undefined> { const backstageUser = await defaultUserTransformer(graphUser, userPhoto); return backstageUser;}// The Organization transformer transforms the root MS Graph Organization into a Groupexport async function myOrganizationTransformer( graphOrganization: MicrosoftGraph.Organization,): Promise<GroupEntity | undefined> { const backstageOrg = await defaultOrganizationTransformer(graphOrganization); return backstageOrg;}// The Provider Config transformer enables modification of the plugin configexport async function myProviderConfigTransformer( provider: MicrosoftGraphProviderConfig,): Promise<MicrosoftGraphProviderConfig> { return provider;}// Wrapping these functions in a Module allows us to inject them into the Catalog plugin easilyexport default createBackendModule({ pluginId: 'catalog', moduleId: 'msgraph-org', register(reg) { reg.registerInit({ deps: { microsoftGraphTransformers: microsoftGraphOrgEntityProviderTransformExtensionPoint, }, async init({ microsoftGraphTransformers }) { // Set th... [truncated]
이제 각 제공자를 우리의 필요에 맞게 커스터마이즈해 봅시다.
이 Group Transformer 예시는 기본 로직을 완전히 제거하고 커스텀 로직으로 대체해요.
export async function myGroupTransformer( group: MicrosoftGraph.Group, groupPhoto?: string,): Promise<GroupEntity | undefined> { const backstageGroup = await defaultGroupTransformer(group, groupPhoto); return backstageGroup; // All of our groups are prefixed with the organisational unit: 'Engineering - Team A' // We want to drop the org unit from the group name and use it for the namespace instead const groupNameArr = group.displayName.split(' - '); const displayName = groupNameArr[1]; // Standardise name and namespace by replacing spaces with hyphens and converting to lowercase const namespace = groupNameArr[0].replace(' ', '-').toLowerCase(); const groupName = groupNameArr[1].replace(' ', '-').toLowerCase(); return { apiVersion: 'backstage.io/v1alpha1', kind: 'Group', metadata: { name: groupName, description: group.description, annotations: {}, }, spec: { type: 'team', displayName: displayName, email: group.mail, children: [], }, };}
이 User Transformer 예시는 내장 로직을 사용하면서도 사용자 이름을 수정하고 설명을 설정해요.
export async function myUserTransformer( graphUser: MicrosoftGraph.User, userPhoto?: string,): Promise<UserEntity | undefined> { const backstageUser = await defaultUserTransformer(graphUser, userPhoto); // Make sure the default transformer returned an entity if (backstageUser) { // Update the description to make it obvious where this entity came from backstageUser.metadata.description = 'Loaded from Microsoft Entra ID via MyCustomUserTransformer'; // The default transformer sets the username to the email address with invalid characters subbed out: 'user_domain.com' // Set the username to the local part of the email address in lowercase without the domain const newName = backstageUser.metadata.name.split('_')[0].toLowerCase(); backstageUser.metadata.name = newName; return backstageUser; } return undefined; return backstageUser;}
이 Organization Transformer 예시는 undefined를 반환해 조직 그룹을 완전히 제거해요.
export async function myOrganizationTransformer( graphOrganization: MicrosoftGraph.Organization,): Promise<GroupEntity | undefined> { const backstageOrg = await defaultOrganizationTransformer(graphOrganization); return backstageOrg; // The org transformer creates a group to be used as the base of the relationship tree for groups // We don't need this to be created, so return undefined instead of an entity return undefined;}
이 Config Transformer 예시는 그룹 필터를 확장해 'azure-group-a'도 포함시켜요.
export async function myProviderConfigTransformer( provider: MicrosoftGraphProviderConfig,): Promise<MicrosoftGraphProviderConfig> { // The filter in our config file relies on a property that has been intermittantly causing this important group to fail ingestion // Ensure the group is always discovered by the filter if (!provider.groupFilter?.includes('azure-group-a')) { provider.groupFilter = `${provider.groupFilter} or displayName eq 'azure-group-a'`; } return provider;}
이제 우리가 만든 새 모듈을 백엔드에 추가하기만 하면 돼요.
packages/backend/src/index.ts
// Your file will have more than this in itconst backend = createBackend();...backend.add(import('./extensions/transformers'));...backend.start();
문제 해결
데이터가 없음
먼저 로그에서 Reading msgraph users and groups 메시지를 확인하세요. 안 보이면 제공자를 등록했는지, 일정이 유효한지 확인하세요.
Read 0 msgraph users and 0 msgraph groups 로그 항목이 보이면 search와 filter 인수를 확인하세요.
시작 메시지(Reading msgraph users and groups)는 보이는데 종료 메시지(Read X msgraph users and Y msgraph groups)는 보이지 않는다면, 데이터 양이 많아 작업이 오래 걸릴 가능성이 커요. 기본 동작은 모든 사용자와 그룹을 가져오는 것으로, 보통 필요보다 많은 데이터예요. 더 작은 데이터 집합을 가져와 보세요(예: filter: displayName eq 'John Smith').
인증 / 토큰 오류
Troubleshooting Azure Identity Authentication Issues를 참고하세요.
Microsoft Graph에서 사용자 읽기 오류: Authorization_RequestDenied - 작업을 완료할 권한이 부족합니다
-
앱 등록이나 관리 ID에 필요한 모든 권한을 부여했는지 확인하세요
-
권한이
Delegated가 아니라Application권한인지 확인하세요 -
조직이 "관리자 동의"를 요구하도록 구성했다면 애플리케이션 권한에 대해 동의가 부여되었는지 확인하세요
-
그룹 쿼리가 Microsoft Teams 그룹을 반환한다면 추가 권한(예:
Team.ReadBasic.All,TeamMember.Read.All)을 부여해야 할 수 있어요 -
추가
select나expand필드를 추가했다면 그것들에 추가 권한이 필요할 수 있어요