Privileged Access Manager(PAM)をTerraformで管理する

記事タイトルとURLをコピーする

G-gen の武井です。当記事では Privileged Access Manager を Terraform で管理する方法について紹介します。

はじめに

概要

当記事では Google Cloud における一時的な IAM 権限付与を実現する仕組みである Privileged Access Manager (以降、PAM) を、Terraform と GitHub Actions による CI/CD で管理する方法を紹介します。

当記事で実現するのは、PAM の仕組みのデプロイです。API の有効化、サービスエージェントへの権限付与、利用資格 (entitlements) の作成などを Terraform で行うことで、デプロイ以降は PAM を使った承認フローにより、組織の IAM 権限を管理することができるようになります。

Privileged Access Manager (PAM)

PAM の詳細は以下の記事で解説しています。

blog.g-gen.co.jp

PAM での権限管理の仕組みを端的にまとめると、利用資格 (entitlements) という設定情報にもとづき、一時的な権限の付与を行うものです。

利用資格(entitlements)は PAM のオブジェクトであり、承認フローと言い換えることもできます。利用資格には「付与する IAM ロール」「権限を付与する最大時間」「誰が権限をリクエストできるか」「誰がリクエストを承認できるか」「誰が通知を受け取るか」などを定義できます。

承認フローは以下のようになります。

  1. 申請者は必要な権限、期間、その理由を利用資格に明記し、承認者に提出する
  2. 承認者は、その妥当性を確認し申請を承認もしくは否認する
  3. 承認された場合、申請者に対し一定期間権限が付与される
  4. 所定の期間が経過すると、申請者に付与されていた権限は自動的にはく奪される

PAM に必要な権限

PAM に必要な権限 (IAM ロール) については以下の通りです。

利用資格の管理

利用資格を管理(作成、更新、削除)するプリンシパルには、Privileged Access Manager 管理者 (roles/privilegedaccessmanager.admin) が必要です。

また、利用資格を組織ツリーの中のどこで利用するかによって、以下のいずれかの権限が必要です。

  • 組織全体:セキュリティ管理者(roles/iam.securityAdmin
  • フォルダ:フォルダ IAM 管理者(roles/resourcemanager.folderIamAdmin
  • プロジェクト:Project IAM 管理者(roles/resourcemanager.projectIamAdmin

利用資格の利用 (申請、承認)

利用資格を用いて、権限付与を申請する、あるいは申請を承認するプリンシパルには、Privileged Access Manager 閲覧者 (roles/privilegedaccessmanager.viewer) が必要です。

全体構成

当記事では利用資格を Terraform と GitHub Actions で管理します。

利用資格は組織/フォルダ/プロジェクトレベルで設定可能ですが、今回は組織とプロジェクトレベルに PAM をデプロイします。

連携方式

Google Cloud と GitHub Actions の連携には Direct Workload Identity を使用します。

サービスアカウントキーやサービスアカウントの権限を借用する従来方式とは異なり、Workload Identity プールに必要な IAM 権限を直接付与します。

ソースコード

Direct Workload Identity および GitHub Actions ワークフロー

以下の記事で Direct Workload Identity を作成する bash スクリプトと GitHub Actions のワークフローを掲載しています。

blog.g-gen.co.jp

なお、上記の記事に掲載したスクリプトで作成される Workload Identity プールに対しては、組織レベルで以下の IAM ロールを付与しており、利用資格の管理に必要な権限を包含しています。

  • オーナー (roles/owner)
  • 組織管理者 (roles/resourcemanager.organizationAdmin)

Terraform

PAM のソースコードは以下のディレクトリ構成にもとづきます。

ディレクトリ構成

.
├── env
│   ├── Test_Environment
│   │   └── yutakei
│   │       ├── backend.tf
│   │       ├── locals.tf
│   │       ├── main.tf
│   │       └── versions.tf
│   └── organization
│       ├── backend.tf
│       ├── locals.tf
│       ├── main.tf
│       └── versions.tf
└── modules
    ├── apis
    │   ├── main.tf
    │   ├── outputs.tf
    │   └── variables.tf
    └── pam
        ├── main.tf
        ├── outputs.tf
        └── variables.tf

env 配下 (呼び出し側)

# backend.tf
terraform {
  backend "gcs" {
    bucket = "common-tfstate"
    prefix = "terraform/organization/state"
  }
}
  
# locals.tf
locals {
  organization_id = "1234567890"
  
  # 利用申請(entitlements)の設定
  entitlements = {
    pam_org1 = {
      entitlement_id       = "pam-organization-acm-demo"
      max_request_duration = "3600s"
      eligible_users       = ["user:demo-user01@dev.g-gen.co.jp"]
      resource_type        = "cloudresourcemanager.googleapis.com/Organization"
      resource             = "//cloudresourcemanager.googleapis.com/organizations/1234567890"
      roles = [
        "roles/accesscontextmanager.gcpAccessReader",
        "roles/accesscontextmanager.policyReader"
      ]
      require_approver_justification = true
      approvals_needed               = 1
      approvers                      = ["user:demo-user01@g-gen.co.jp"]
    }
  }
}
  
# main.tf
# 組織レベルでPAMを有効にするには、PAMサービスアカウントにPAMサービスエージェントロールが必要
resource "google_organization_iam_member" "pam_service_agent" {
  org_id = local.organization_id
  role   = "roles/privilegedaccessmanager.serviceAgent"
  member = "serviceAccount:service-org-${local.organization_id}@gcp-sa-pam.iam.gserviceaccount.com"
}
  
module "pam" {
  source       = "../../modules/pam"
  entitlements = local.entitlements
  parent       = "organizations/${local.organization_id}"
  location     = "global"
}
  
# versions.tf
terraform {
  required_version = "~> 1.9.7"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 6.6.0"
    }
  }
}
  
provider "google" {
  user_project_override = true
}
# backend.tf
terraform {
  backend "gcs" {
    bucket = "common-tfstate"
    prefix = "terraform/yutakei/state"
  }
}
  
# locals.tf
locals {
  project_id = "yutakei"
  
  apis = [
    "privilegedaccessmanager.googleapis.com",
  ]
  
  # 利用申請(entitlements)の設定
  entitlements = {
    pam1 = {
      entitlement_id                 = "pam-yutakei-bigquery-demo"
      max_request_duration           = "3600s"
      eligible_users                 = ["user:demo-user01@dev.g-gen.co.jp"]
      resource_type                  = "cloudresourcemanager.googleapis.com/Project"
      resource                       = "//cloudresourcemanager.googleapis.com/projects/yutakei"
      roles                          = ["roles/bigquery.jobUser", "roles/bigquery.dataViewer"]
      require_approver_justification = true
      approvals_needed               = 1
      approvers                      = ["user:demo-user01@g-gen.co.jp"]
    }
    
    pam2 = {
      entitlement_id                 = "pam-yutakei-gcs-demo"
      max_request_duration           = "3600s"
      eligible_users                 = ["user:demo-user01@dev.g-gen.co.jp"]
      resource_type                  = "cloudresourcemanager.googleapis.com/Project"
      resource                       = "//cloudresourcemanager.googleapis.com/projects/yutakei"
      roles                          = ["roles/storage.admin"]
      require_approver_justification = true
      approvals_needed               = 1
      approvers                      = ["user:demo-user01@g-gen.co.jp"]
    }
  }
}
  
# main.tf
module "apis" {
  source     = "../../../modules/apis"
  project_id = local.project_id
  apis       = local.apis
}
  
module "pam" {
  source       = "../../../modules/pam"
  entitlements = local.entitlements
  parent       = "projects/${local.project_id}"
  location     = "global"
}
  
# versions.tf
割愛

modules 配下 (モジュール)

# main.tf
resource "google_privileged_access_manager_entitlement" "pam" {
  for_each             = var.entitlements
  entitlement_id       = each.value.entitlement_id
  location             = var.location
  max_request_duration = each.value.max_request_duration
  parent               = var.parent
  
  requester_justification_config {
    unstructured {}
  }
  
  eligible_users {
    principals = each.value.eligible_users
  }
  
  privileged_access {
    gcp_iam_access {
      resource_type = each.value.resource_type
      resource      = each.value.resource
  
      # 複数のrole_bindingsを生成
      dynamic "role_bindings" {
        for_each = each.value.roles
        content {
          role = role_bindings.value
        }
      }
    }
  }
  
  approval_workflow {
    manual_approvals {
      require_approver_justification = each.value.require_approver_justification
      steps {
        approvals_needed = each.value.approvals_needed
        approvers {
          principals = each.value.approvers
        }
      }
    }
  }
}
  
# outputs.tf
output "entitlement_ids" {
  description = "List of entitlement IDs created"
  value       = [for entitlement in google_privileged_access_manager_entitlement.pam : entitlement.entitlement_id]
}
  
variable "entitlements" {
  description = "A map of entitlement configurations"
  type = map(object({
    entitlement_id                 = string
    max_request_duration           = string
    eligible_users                 = list(string)
    resource_type                  = string
    resource                       = string
    roles                          = list(string)
    require_approver_justification = bool
    approvals_needed               = number
    approvers                      = list(string)
  }))
}
  
# variables.tf
variable "parent" {
  description = "Parent resource (e.g., project, folder, or organization)"
  type        = string
}
  
variable "location" {
  description = "Location for the entitlement"
  type        = string
  default     = "global"
}
# main.tf
resource "google_project_service" "apis" {
  for_each           = toset(var.apis)
  project            = var.project_id
  service            = each.value
  disable_on_destroy = false
}
  
# APIの有効化には時間がかかるため、待機時間を設定
resource "null_resource" "delay" {
  provisioner "local-exec" {
    command = "sleep 180"
  }
  
  depends_on = [google_project_service.apis]
}
  
# outputs.tf
output "enabled_apis" {
  description = "List of enabled APIs for the project"
  value       = [for service in google_project_service.apis : service.id]
}
  
# variables.tf
variable "apis" {
  description = "List of APIs to enable"
  type        = list(string)
}
  
variable "project_id" {
  description = "The ID of the project to create resources in"
  type        = string
}

デプロイ

terraform plan

GitHub Actions (terraform plan) の実行結果です。

# 組織向けのワークフロー(terraform plan)
  
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  
Terraform will perform the following actions:
  
  # google_organization_iam_member.pam_service_agent will be created
  + resource "google_organization_iam_member" "pam_service_agent" {
      + etag   = (known after apply)
      + id     = (known after apply)
      + member = "serviceAccount:service-org-1234567890@gcp-sa-pam.iam.gserviceaccount.com"
      + org_id = "1234567890"
      + role   = "roles/privilegedaccessmanager.serviceAgent"
    }
  
  # module.pam.google_privileged_access_manager_entitlement.pam["pam_org1"] will be created
  + resource "google_privileged_access_manager_entitlement" "pam" {
      + create_time          = (known after apply)
      + entitlement_id       = "pam-organization-acm-demo"
      + etag                 = (known after apply)
      + id                   = (known after apply)
      + location             = "global"
      + max_request_duration = "3600s"
      + name                 = (known after apply)
      + parent               = "organizations/1234567890"
      + state                = (known after apply)
      + update_time          = (known after apply)
  
      + approval_workflow {
          + manual_approvals {
              + require_approver_justification = true
  
              + steps {
                  + approvals_needed = 1
  
                  + approvers {
                      + principals = [
                          + "user:demo-user01@g-gen.co.jp",
                        ]
                    }
                }
            }
        }
  
      + eligible_users {
          + principals = [
              + "user:demo-user01@dev.g-gen.co.jp",
            ]
        }
  
      + privileged_access {
          + gcp_iam_access {
              + resource      = "//cloudresourcemanager.googleapis.com/organizations/1234567890"
              + resource_type = "cloudresourcemanager.googleapis.com/Organization"
  
              + role_bindings {
                  + role = "roles/accesscontextmanager.gcpAccessReader"
                }
              + role_bindings {
                  + role = "roles/accesscontextmanager.policyReader"
                }
            }
        }
  
      + requester_justification_config {
          + unstructured {}
        }
    }
  
Plan: 2 to add, 0 to change, 0 to destroy.
# プロジェクト向けのワークフロー(terraform plan)
  
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  
Terraform will perform the following actions:
  
  # module.apis.google_project_service.apis["privilegedaccessmanager.googleapis.com"] will be created
  + resource "google_project_service" "apis" {
      + disable_on_destroy = false
      + id                 = (known after apply)
      + project            = "yutakei"
      + service            = "privilegedaccessmanager.googleapis.com"
    }
  
  # module.apis.null_resource.delay will be created
  + resource "null_resource" "delay" {
      + id = (known after apply)
    }
  
  # module.pam.google_privileged_access_manager_entitlement.pam["pam1"] will be created
  + resource "google_privileged_access_manager_entitlement" "pam" {
      + create_time          = (known after apply)
      + entitlement_id       = "pam-yutakei-bigquery-demo"
      + etag                 = (known after apply)
      + id                   = (known after apply)
      + location             = "global"
      + max_request_duration = "3600s"
      + name                 = (known after apply)
      + parent               = "projects/yutakei"
      + state                = (known after apply)
      + update_time          = (known after apply)
  
      + approval_workflow {
          + manual_approvals {
              + require_approver_justification = true
  
              + steps {
                  + approvals_needed = 1
  
                  + approvers {
                      + principals = [
                          + "user:demo-user01@g-gen.co.jp",
                        ]
                    }
                }
            }
        }
  
      + eligible_users {
          + principals = [
              + "user:demo-user01@dev.g-gen.co.jp",
            ]
        }
  
      + privileged_access {
          + gcp_iam_access {
              + resource      = "//cloudresourcemanager.googleapis.com/projects/yutakei"
              + resource_type = "cloudresourcemanager.googleapis.com/Project"
  
              + role_bindings {
                  + role = "roles/bigquery.jobUser"
                }
              + role_bindings {
                  + role = "roles/bigquery.dataViewer"
                }
            }
        }
  
      + requester_justification_config {
          + unstructured {}
        }
    }
  
  # module.pam.google_privileged_access_manager_entitlement.pam["pam2"] will be created
  + resource "google_privileged_access_manager_entitlement" "pam" {
      + create_time          = (known after apply)
      + entitlement_id       = "pam-yutakei-gcs-demo"
      + etag                 = (known after apply)
      + id                   = (known after apply)
      + location             = "global"
      + max_request_duration = "3600s"
      + name                 = (known after apply)
      + parent               = "projects/yutakei"
      + state                = (known after apply)
      + update_time          = (known after apply)
  
      + approval_workflow {
          + manual_approvals {
              + require_approver_justification = true
  
              + steps {
                  + approvals_needed = 1
  
                  + approvers {
                      + principals = [
                          + "user:demo-user01@g-gen.co.jp",
                        ]
                    }
                }
            }
        }
  
      + eligible_users {
          + principals = [
              + "user:demo-user01@dev.g-gen.co.jp",
            ]
        }
  
      + privileged_access {
          + gcp_iam_access {
              + resource      = "//cloudresourcemanager.googleapis.com/projects/yutakei"
              + resource_type = "cloudresourcemanager.googleapis.com/Project"
  
              + role_bindings {
                  + role = "roles/storage.admin"
                }
            }
        }
  
      + requester_justification_config {
          + unstructured {}
        }
    }
  
Plan: 4 to add, 0 to change, 0 to destroy.

terraform apply

GitHub Actions (terraform apply) の実行結果です。戻り値は先ほど同様のため割愛します。

リソース

組織では PAM サービスアカウントに対する IAM Policy利用資格がデプロイされました。

プロジェクトでも利用資格がデプロイされました。

動作確認

申請

申請者のアカウントで PAM の管理画面にアクセスし、権限付与をリクエスト をクリックします。

以下3項目を入力し、権限付与をリクエストをクリックすると、承認フローが承認者へと進みます。

  • 権限付与の期間 (必須、最大時間が1時間の場合、30分/45分/1時間から選択できる)
  • 理由 (必須)
  • 通知の受信者 (任意、省略しても利用資格で設定した承認者にメール通知が行われる)

承認されるまでの間、当該利用資格のステータスは Approval Awaited となります。

`

承認

承認者のアカウントで PAM の管理画面にアクセスすると、申請者からの承認フローが回ってきたことがわかります。

以下のメール通知が届きます。

承認 / 拒否をクリックし申請内容を確認します。

コメント欄 (必須) に承認する旨を入力し、承認をクリックします。

権限付与

承認者には以下のメール通知が届きます。

利用資格のステータスも Approval Awaited > Active となっており、権限付与の残り時間も表示されています。

IAM Policy の管理画面を確認すると、今回利用資格の中で定義した2つのロールが PAM によって付与されたことがわかります。

権限はく奪

申請時に希望した付与時間 (今回は30分) が経過すると、先ほどまで付与されていたロールが PAM によってはく奪されていることがわかります。

再申請

利用資格のステータスが Available (申請開始前の状態) に戻っており、必要な際には再度同じ利用資格を使って申請が行えます。

武井 祐介 (記事一覧)

クラウドソリューション部所属。G-gen唯一の山梨県在住エンジニア

Google Cloud Partner Top Engineer 2025 選出。IaC や CI/CD 周りのサービスやプロダクトが興味分野です。

趣味はロードバイク、ロードレースやサッカー観戦です。