diff --git a/docs-taxonomy.yml b/docs-taxonomy.yml index 0f3ce8e7..bbcb23f9 100644 --- a/docs-taxonomy.yml +++ b/docs-taxonomy.yml @@ -34,6 +34,7 @@ services: azure-automation: Azure Automation azure-container-apps: Azure Container Apps azure-cosmos-db: Azure Cosmos DB + azure-cost-management-and-billing: Cost Management + Billing azure-database-for-mysql: Azure Database for MySQL azure-functions: Azure Functions azure-hdinsight: Azure HDInsight @@ -48,6 +49,7 @@ services: technologies: azure-cli: Azure CLI + azure-rbac: Azure RBAC bicep: Bicep cosmos-db: Azure Cosmos DB docker: Docker diff --git a/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/images/ea-hierarchies.png b/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/images/ea-hierarchies.png new file mode 100644 index 00000000..29568949 Binary files /dev/null and b/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/images/ea-hierarchies.png differ diff --git a/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/index.md b/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/index.md new file mode 100644 index 00000000..e9f7a8f8 --- /dev/null +++ b/docs/services/azure-cost-management-and-billing/ea-subscription-reservation-permissions/index.md @@ -0,0 +1,171 @@ +--- +title: EA·Azure 구독·RI의 관계와 역할 부여 +description: EA와 구독은 어떻게 연결되며, RI를 구매·관리하는 역할은 누구에게 받아야 하는지 설명합니다. +document_type: guide +services: [azure-cost-management-and-billing, microsoft-entra-id] +technologies: [azure-rbac] +tags: [operate, secure, identity] +status: current +verification_status: needs-review +sources_checked_at: 2026-09-15 +official_sources: + - title: Manage Azure Enterprise Agreement roles + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/understand-ea-roles + - title: Understand and work with scopes + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/understand-work-scopes + - title: EA Billing administration on the Azure portal + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/direct-ea-administration + - title: Create an Enterprise Agreement subscription + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/create-enterprise-subscription + - title: Buy a reservation + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/prepare-buy-reservation + - title: Save costs with Azure Reserved VM Instances + url: https://learn.microsoft.com/en-us/azure/virtual-machines/prepay-reserved-vm-instances + - title: Permissions to view and manage Azure reservations + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/view-reservations + - title: Manage Reservations for Azure resources + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/manage-reserved-vm-instance + - title: Assign Azure roles using the Azure portal + url: https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal + - title: Azure roles, Microsoft Entra roles, and classic subscription administrator roles + url: https://learn.microsoft.com/en-us/azure/role-based-access-control/rbac-and-directory-admin-roles + - title: Grant RBAC access to Azure reservations by using PowerShell + url: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/manage-reservations-rbac-powershell + - title: Azure built-in roles for Management and governance + url: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/management-and-governance +last_verified: null +review_cycle_days: 30 +applies_to: [Azure Enterprise Agreement, Azure portal, Azure Reserved VM Instances] +--- + +# EA·Azure 구독·RI의 관계와 역할 부여 + +**EA는 기업의 청구 계약, Azure 구독은 리소스를 사용하는 단위, RI는 예약 할인 상품입니다.** +따라서 **구독을 만드는 역할**, **RI를 구매하는 역할**, **구매한 예약을 관리하는 역할**을 구분하면 됩니다. +이 문서는 EA 고객이 Azure portal을 사용하는 일반적인 권한 부여 경로를 설명합니다. + +## 1. EA와 Azure 구독은 어떻게 연결되는가 + +아래는 Microsoft Learn에 실린 **EA 계층의 원본 그림**입니다. + +![Microsoft Learn의 EA 계층 원본. Enterprise 아래 Department, Account, Subscription을 기능·사업부·지역별로 구성한 예시입니다.](images/ea-hierarchies.png) + +© Microsoft. [공식 문서][ea-roles]의 [원본 PNG][ea-image]를 수정 없이 사용했습니다. +라이선스: [CC BY 4.0][docs-license]. 그림의 인명과 프로젝트명은 원문의 예시입니다. + +- **Enterprise / Enrollment**: EA 청구 계정입니다. Portal에서는 **Billing account**로 표시합니다. +- **Department**: 부서별로 계정을 묶는 선택 사항입니다. +- **Account / Enrollment account**: 계정마다 **Account Owner 한 명**이 있으며, 그 아래에 구독을 만듭니다. 하나의 EA Enrollment에는 여러 계정을 둘 수 있지만, 한 계정에 여러 Account Owner를 지정하지는 못합니다. +- **Subscription**: VM·Storage 같은 Azure 리소스를 사용하는 구독입니다. + +즉, **EA 청구 계정 → 부서(선택) → 계정 → 구독**의 관계입니다. +([EA 역할과 계층][ea-roles], [EA 청구 범위][billing-scopes]) + +### 구독을 만들고 관리하는 역할은 누가 주는가 + +| 역할 | 하는 일 | 누가 부여하는가 | +|---|---|---| +| **Enterprise Administrator — EA 관리자** | EA 계정·관리자를 관리하고, 활성 계정 아래에 구독 생성 | 기존 EA 관리자. 최초 관리자는 EA 개설 시 설정 | +| **Department Administrator — 부서 관리자** | 자기 부서의 계정과 Account Owner 관리. 이 역할만으로 구독을 생성하지는 못함 | EA 관리자 또는 해당 부서의 기존 부서 관리자. 읽기 전용 역할은 부여 불가 | +| **Account Owner — 계정 소유자** | 자기 계정 아래에 구독 생성 | EA 관리자 또는 해당 부서의 부서 관리자 | +| **Owner — 구독 소유자** | 해당 구독의 리소스 운영과 역할 부여 | 구독 생성 시 지정. 이후에는 기존 구독 Owner 또는 해당 구독의 권한 관리자 | + +EA 관리자는 **Cost Management + Billing**에서 EA 역할을 부여합니다. +구독의 Owner 등 Azure 역할(Azure RBAC)은 해당 **Subscription → Access control (IAM)**에서 부여합니다. +**EA의 Account Owner와 구독의 Owner는 같은 이름의 역할이 아닙니다.** +([EA 역할 부여][ea-administration], [구독 생성과 Owner 지정][create-subscription], [Azure 역할 부여][assign-role]) + +## 2. RI와 Reservations는 무엇이며, 어떤 역할을 받아야 하는가 + +여기서 **RI는 Azure Reserved VM Instances**, 즉 VM 사용료에 적용하는 예약 할인 상품입니다. +Azure portal의 **Reservations**는 이러한 Azure 예약을 구매·조회·관리하는 메뉴입니다. +**Reservations 자체가 역할 이름인 것은 아닙니다.** +([RI 설명][ri-overview], [Reservations 메뉴와 권한][reservation-access]) + +구매할 때 청구에 사용할 구독을 선택하지만, 구매 결과로 **Reservation order(예약 주문)**와 +그 아래 **Reservation(예약)**이 만들어집니다. 예약은 주문의 권한을 상속하며, +구매 후 구독 권한을 계속 상속하지는 않습니다. +따라서 **구독 Owner를 받았다고 기존 RI의 관리 권한까지 자동으로 생기는 것은 아닙니다.** +([예약 주문과 예약][manage-reservation], [구독과 분리된 예약 권한][reservation-access]) + +### 필요한 역할과 부여자 + +| 하려는 일 | 받아야 하는 역할과 대상 | 누구에게 요청하는가 | +|---|---|---| +| **RI 구매** | 구매에 사용할 적격 구독의 **Reservation Purchaser**. 해당 구독의 **Owner**도 구매 가능 | 해당 **구독 Owner** 또는 구독의 권한 관리자 | +| **구매한 RI 조회** | 해당 **예약 주문의 Reader** | 해당 **예약 주문의 Owner** 또는 예약의 권한 관리자 | +| **구매한 RI의 일반 설정 변경** | 해당 **예약 주문의 Contributor** | 해당 **예약 주문의 Owner** 또는 예약의 권한 관리자 | +| **RI 관리와 다른 사람에게 역할 부여** | 해당 **예약 주문의 Owner** | 기존 **예약 주문 Owner** 또는 예약의 권한 관리자 | + +표의 **권한 관리자**는 해당 대상에 역할 할당 권한을 가진 +`User Access Administrator`·`Role Based Access Control Administrator` 등을 뜻합니다. +`Contributor`는 일반 설정을 변경하는 역할이고, 다른 사람에게 역할을 주는 역할은 아닙니다. +([예약의 조회·관리·위임][reservation-access], [Azure 역할의 차이][azure-roles], [역할 할당 조건][assign-role]) + +**Reservation Purchaser는 구매자가 받는 역할이지, 다른 사람에게 구독 역할을 부여하는 역할이 아닙니다.** +이 역할의 역할 할당 관련 권한은 `Microsoft.Authorization/roleAssignments/read`이며, +역할 부여에 필요한 `Microsoft.Authorization/roleAssignments/write`는 포함하지 않습니다. +([Reservation Purchaser 역할 정의][reservation-purchaser-role], [역할 할당 조건][assign-role]) + +**예약 주문의 Owner를 모르겠다면 EA 관리자에게 요청하면 됩니다.** +쓰기 가능한 EA 관리자는 해당 EA의 예약을 관리할 수 있고, +**Cost Management + Billing → EA 청구 계정 → Products + services → Reservations + Hybrid Benefit**에서 +예약을 선택한 뒤 **Grant access**로 소유권을 확보하고 IAM 역할을 부여하는 경로를 사용할 수 있습니다. +읽기 전용 EA 관리자 역할만으로는 이 작업을 할 수 없습니다. +([EA 관리자의 예약 접근·소유권 확보][reservation-access]) + +구매·변경 시에는 다음 조건도 구분합니다. + +- **일반 구매자의 구독 역할**: 적격 구독의 built-in **Owner** 또는 **Reservation Purchaser**가 필요합니다. + 같은 권한을 흉내 낸 custom role은 구매에 사용할 수 없습니다. ([구매 조건][buy-reservation]) +- **EA 구매 정책**: EA 관리자는 **Reserved Instances** 정책을 비활성화하여 구매자를 EA 관리자로 제한할 수 있습니다. + 따라서 일반 구매자는 구독 역할뿐 아니라 이 정책의 허용 여부도 확인해야 합니다. ([구매 조건][buy-reservation]) +- **중앙 구매팀의 EA 관리자**: 구매 가이드는 EA 관리자에게도 적어도 하나의 EA 구독에 + **Owner** 또는 **Reservation Purchaser** 권한이 필요하다고 명시합니다. + 정책을 비활성화하면 이 구독 역할 요건까지 면제된다고 설명하지 않습니다. ([구매 조건][buy-reservation]) +- **Shared에서 특정 구독으로 할인 범위를 바꿀 때는 대상 구독의 Owner도 필요**합니다. + 위 표의 일반 설정 변경을 교환·환불·갱신의 모든 조건으로 확대하지 않습니다. ([예약 변경 조건][manage-reservation]) + +!!! note "EA 관리자 구매 조건의 확인 범위" + + [EA 역할표의 각주 6][ea-roles]은 EA 관리자의 청구 계정 범위 구매와 구매 정책 flag의 예외를 설명합니다. + 반면 [예약 구매 가이드][buy-reservation]는 중앙 구매팀의 EA 관리자에게 구독 역할 요건을 명시합니다. + **구매 정책 예외와 구독 RBAC 요건 면제를 같은 의미로 해석하지 않습니다.** + 위 안내는 구매 가이드의 준비 요건을 따르며, EA 역할만으로 구매하는 모든 경로의 조건을 확정하지 않습니다. + 구독 역할 없이 구매하려는 경우에는 적용할 경로의 요건을 Microsoft에 확인해야 합니다. + 이 조건 차이가 남아 있으므로 문서의 전체 검증 상태는 `needs-review`로 유지합니다. + +## 3. 테넌트 전체의 Reservations 담당자를 따로 두려면 + +한 예약 주문이 아니라 **해당 Microsoft Entra 테넌트의 모든 예약**을 담당하게 하려면 +테넌트의 예약 범위에 다음 역할을 부여합니다. + +- **Reservations Administrator**: 예약 관리와 예약 역할 위임 +- **Reservations Contributor**: 예약 관리. 다른 사람에게 예약 역할을 위임할 수 없음 +- **Reservations Reader**: 예약 조회 + +이 역할은 특정 예약에 한정할 수도 있지만, 여기서는 **테넌트의 모든 예약**을 대상으로 부여합니다. +예약 주문 범위의 일반 `Contributor`와 테넌트 예약 관리용 `Reservations Contributor`를 구분합니다. + +공식 Portal 절차에서는 **Global Administrator가 User Access Administrator로 접근 권한을 승격한 뒤** +**Reservations → Role assignment**에서 이 역할을 부여합니다. +EA 관리자는 **EA 계약 범위**, 이 예약 역할은 **테넌트 범위**를 대상으로 한다는 차이가 있습니다. +([테넌트 예약 역할][reservation-access], [Portal과 PowerShell의 테넌트 역할 부여 절차][tenant-reservation-roles], [EA 청구 범위][billing-scopes]) + +**정리하면, RI 구매를 위한 Reservation Purchaser(또는 Owner) 역할은 해당 구독 Owner 또는 구독의 권한 관리자에게 요청합니다. 구매한 RI의 관리 권한은 예약 주문 Owner 또는 EA 관리자에게 요청합니다.** +테넌트 전체 예약 관리자를 지정하는 경우에만 위의 테넌트 권한 부여 경로를 따로 확인하면 됩니다. + +[ea-roles]: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/understand-ea-roles +[billing-scopes]: https://learn.microsoft.com/en-us/azure/cost-management-billing/costs/understand-work-scopes +[ea-administration]: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/direct-ea-administration +[create-subscription]: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/create-enterprise-subscription +[buy-reservation]: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/prepare-buy-reservation +[ri-overview]: https://learn.microsoft.com/en-us/azure/virtual-machines/prepay-reserved-vm-instances +[reservation-access]: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/view-reservations +[manage-reservation]: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/manage-reserved-vm-instance +[assign-role]: https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-portal +[azure-roles]: https://learn.microsoft.com/en-us/azure/role-based-access-control/rbac-and-directory-admin-roles +[tenant-reservation-roles]: https://learn.microsoft.com/en-us/azure/cost-management-billing/reservations/manage-reservations-rbac-powershell +[reservation-purchaser-role]: https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/management-and-governance#reservation-purchaser +[ea-image]: https://learn.microsoft.com/en-us/azure/cost-management-billing/manage/media/understand-ea-roles/ea-hierarchies.png +[docs-license]: https://github.com/MicrosoftDocs/azure-docs/blob/main/LICENSE diff --git a/tests/docs/test_faceted_discovery.py b/tests/docs/test_faceted_discovery.py index df1565e1..937050a6 100644 --- a/tests/docs/test_faceted_discovery.py +++ b/tests/docs/test_faceted_discovery.py @@ -160,11 +160,11 @@ def test_explore_has_exact_member_data_and_one_card_per_topic(taxonomy, catalog) assert PurePosixPath("explore/index.md") in pages page = pages[PurePosixPath("explore/index.md")] assert yaml.safe_load(page.split("---", 2)[1])["title"] == "글 찾기" - assert "41개 주제 · 66개 문서" in page + assert f"{len(catalog.topics)}개 주제 · {len(catalog.documents)}개 문서" in page parser = Elements(page) - assert len(parser.select("data-explore-topic")) == len(catalog.topics) == 41 + assert len(parser.select("data-explore-topic")) == len(catalog.topics) members = parser.select("data-explore-member") - assert len(members) == len(catalog.documents) == 66 + assert len(members) == len(catalog.documents) by_path = {row["data-explore-member"]: row for row in members} for doc in catalog.documents: row = by_path[doc.relative_path.as_posix()] @@ -230,7 +230,7 @@ def test_home_navigation_and_article_chips_use_explore(taxonomy, catalog): nav = yaml.safe_load((ROOT / "docs/.nav.yml").read_text())["nav"] assert [next(iter(item)) for item in nav if "glob" not in item] == ["홈", "서비스별 보기", "글 찾기", "기여하기"] home = build_home_page((ROOT / "docs/index.md").read_text(), catalog.documents, taxonomy, catalog=catalog) - assert "41개 주제 · 66개 문서" in home + assert f"{len(catalog.topics)}개 주제 · {len(catalog.documents)}개 문서" in home assert "전체 글" not in home and "태그별 보기" not in home assert "(explore/index.md)" in home doc = catalog.documents[0] @@ -253,7 +253,7 @@ def test_explore_assets_are_configured_and_accessible(): def test_service_discovery_reports_topics_and_documents(taxonomy, catalog): pages = build_index_pages(catalog.documents, taxonomy, catalog=catalog) - assert "41개 주제 · 66개 문서" in pages[PurePosixPath("services/index.md")] + assert f"{len(catalog.topics)}개 주제 · {len(catalog.documents)}개 문서" in pages[PurePosixPath("services/index.md")] documents = [doc for doc in catalog.documents if "azure-monitor" in doc.metadata["services"]] topics = {catalog.by_document[doc.relative_path].entry.relative_path for doc in documents} assert f"{len(topics)}개 주제 · {len(documents)}개 문서" in pages[PurePosixPath("services/azure-monitor/index.md")] diff --git a/tests/docs/test_topics.py b/tests/docs/test_topics.py index 45e71e1f..1b5ca776 100644 --- a/tests/docs/test_topics.py +++ b/tests/docs/test_topics.py @@ -1065,8 +1065,16 @@ def test_repository_uses_only_canonical_topic_packages() -> None: ROOT / "docs", load_taxonomy(ROOT / "docs-taxonomy.yml") ) - assert len(catalog.documents) == 66 - assert len(catalog.topics) == 41 + source_paths = { + PurePosixPath(path.relative_to(ROOT / "docs").as_posix()) + for path in (ROOT / "docs" / "services").rglob("index.md") + if "samples" not in path.relative_to(ROOT / "docs" / "services").parts + } + assert len(catalog.documents) == len(source_paths) + assert {document.relative_path for document in catalog.documents} == source_paths + assert set(catalog.topics) == { + (path.parts[1], path.parts[2]) for path in source_paths + } assert not any( (ROOT / "docs" / name).exists() for name in ("cases", "guides", "labs", "research")