【How-to Guides】GCP 維運老司機 — 快速產置帳號權限資訊(稽核應付標配)

Defending Against Auditors: GCP Audit Defense 101

Kellen
24 min readFeb 24, 2024
Shopping Mug

這篇比較面向平台或雲端管理員的主題,開發者應該是不需要關心此議題。本篇將探索如何快速產置帳號權限報表,關於雲端稽核的其中一個議題。透過這篇指南,你將學會使用 Python 程式來列出組織項下基於 Folder 與專案的樹狀圖,並提取每個專案的 IAM Policy 中的角色、成員資訊及授權的範圍為何,最後將這些資料整理成一份易於管理人員閱讀及應付稽核的報表,先從個別專案的 IAM Policy 開始吧!後續再進階到把全組織、資料夾、專案全部列示出來。

先科普一下一個(IAM)政策約束與資料結構的長相

Who(誰)?Can do what(權利)?On which resource(在哪些資源上)

GCP 使用 IAM Policy 將它們全部綁在一起,這是連接帳號權限的黏合劑。IAM 策略(也稱為允許策略)是定義對雲端資源的存取的的集合,實際 Policy 資料長相如下

紅框上表示為客製化 Role;紅框下表示為 GCP 提供的 Role
值得留意的是 ➡️ 紅框表示客製化的 Role

其他 IAM Condition

service account 或人員帳號有 IAM Condition
【How-to Guides】GCP 踩坑日常 — 如何操控 IAM conditin 細化 prefix 不同權限

資料結構說明

  1. bindings:這是一個包含 IAM 角色綁定的 List。每個綁定包括成員(用戶、群組或服務帳戶)、角色和可能的條件(如果有的話)。

(1) members:List,包含被賦予角色的成員列表。成員可以是用戶、群組或服務帳戶,並使用格式 type:identifier 來表示。type 是成員的類型,而 identifier 是成員的 principle 識別。如成員 Type 可以是使用者(以 "user:" 開頭)或服務帳戶(以 "serviceAccount:" 開頭)

(2) role:這是被賦予給成員的 IAM 角色。角色可以是 Project 級別的角色名稱,也可以是 GCP 角色的簡稱。例如:projects/fubar/roles/591,其中 fubar 是專案的 ID 或名稱,591 是角色的 ID。而 GCP 提供的不是只針對特定專案,這些角色的名稱不包含專案的 ID 或名稱,僅以角色名稱表示。例如:roles/aiplatform.customCodeServiceAgent

(3) condition:這是一個可選區段,包含用於限制綁定的條件。它定義了一個布林表達式,如果表達式為 true,則綁定將生效。"expression" 屬性定義了一個 CEL 表達式,該表達式定義資源的存取方式。在這個例子中,表達式表示只有當資源是特定 Bucket(projects/_/buckets/temp-demolab-bucket)或該桶子中特定物件(以 projects/_/buckets/temp-demolab-bucket/objects/limit 開頭)時,才允許存取

2. etag:這是資源的 ETag(實體標籤)。是一種在多個使用者或 process 同時訪問共享資源時,確保資源訪問的一致性和有效性的機制(Concurrency Control),參考 GCP 使用 ETag

3. version:這是 IAM Policy 的版本號,它標識了資源配置的政策版本

範例資料如下參考

gcloud projects get-iam-policy [project]

{
"bindings": [
{
"members": [
"serviceAccount:service-776000000005@compute-system.iam.gserviceaccount.com"
],
"role": "projects/fubar/roles/591"
},
{
"members": [
"serviceAccount:service-776000000005@gcp-sa-aiplatform-cc.iam.gserviceaccount.com"
],
"role": "roles/aiplatform.customCodeServiceAgent"
},
{
"condition": {
"expression": "resource.name == \"projects/_/buckets/temp-demolab-bucket\" ||\r\nresource.name.startsWith(\"projects/_/buckets/temp-demolab-bucket/objects/limit\")",
"title": "access-limit"
},
"members": [
"serviceAccount:my-service-account@fubar.iam.gserviceaccount.com"
],
"role": "roles/storage.objectAdmin"
},

...

{
"members": [
"serviceAccount:service-776000000005@gcp-sa-vpcaccess.iam.gserviceaccount.com"
],
"role": "roles/vpcaccess.serviceAgent"
}
],
"etag": "BwYQ2O_SJ_w=",
"version": 3
}

主題一:撈取專案項下的帳號及權限清單

事前準備

在開始之前,確保你已經安裝了以下套件

  • google-auth
  • google-api-python-client

程式碼說明

首先,讓我們快速說明程式碼的結構,程式位置

  1. 使用 Google API Python Client Package(google-api-python-client),使用 googleapiclient 模組中的 discovery.build 函式來建立一個 Google Cloud Resource Manager API 的服務物件
  2. list_projects():這個函式將列出所有 GCP 專案
  3. extract_permissions():這個函式將提取 IAM Policy 中角色和成員資訊
  4. main() 函式:主程式入口,將執行上述兩個函式,並將結果寫入 CSV
使用 resource manager 中的取得專案的方法
撈取所需要資料的邏輯放這 extract_permissions。另外,member.split(":") 是將字串 member 以冒號 : 分割成多個部分,並返回由這些部分組成的列表。然後,[-1] 表示取這個列表的最後一個元素,就會拿到 userPrinciple 了
主程式 — 含每個專案的處理,並將每個專案的結果 dict 塞回至 extracted_data,最後才是寫入工作

等待程式執行完畢,你將在同一目錄下找到一個名為 output.csv ,其中包含了你所需的帳號權限報表。

從專案維度出發,會希望看到 principle 使用者有誰、權限配發狀況、有無殊權限(billing, Owner, … 等)
  • 若有 IAM Condition,權限資訊的整理上自己習慣是先讓 title 以較明確的命名達成可描述目的,而 expression 則是作為補充資訊(不直接提供給稽核人員為原則),若到時有需要再行提供,採行剝洋蔥式的冷處理。
  • 提供的維度可以切換成以 principle 使用者為主體,後面帶出 Project, Role 了解被配發的權限作檢視 (一般檢視還是交由使用單位判定,系統無法得知有否超逾職務所需)
  • Role 的說明要在另外準備說明或資訊給稽核科普或對照
特權會是關心的一個主題,可以新增一個特權或是特別身份註記的欄位,經驗上某個特權人數太多也會是被關心的(毛很多)

透過這個程式,可以快速產置帳號權限報表,基本款的應付稽核工具,可以排入定期排程,確保 GCP 專案在人員的權限配賦上的資訊是透明的。

還有其他要留意的嗎?

當大家都需要從 GCP API 拿到資料並整理成所需的內容時,特別是每個人都從底層的 API 開發,可能會導致資源浪費。隨著組織規模的擴大或需求的複雜化,可以考慮將一些資訊拆解出來,例如專案資訊,並將其存放在資料庫中。這樣做可以讓有狀態的資訊更容易被多人共享和管理,而不需要每個人都對著 GCP API 拆解繁雜的資料進行處理。這種方式可以提高效率,降低重複工作的風險,並讓團隊更專注於開發和解決更具價值的問題。

主題二:處理「組織」項下的資源、存取權限、Label 或 Tag 等

一般公司在開發或營運上都有引入 Organization 並利用 Folder 或 Label 進行資源的管理。

先理解其相關元件

  • 網域是管理組織中使用者的機制,與組織資源直接相關。
  • 🏢 組織資源代表整個組織(例如,公司),是層次結構的頂級節點。組織資源可讓您集中查看和控制層次結構中接下來的各個層級所有資源
  • 層次結構中的下一層是📁 資料夾。可以使用資料夾來隔離父級組織中不同部門和團隊的需求。您同樣可以使用資料夾將生產與開發資源分開
  • 層次結構的底層是專案。項目包含處理工作負載和構成應用的服務級資源(例如運算、儲存和網路資源)
  • 根據Google 關於專案存取控制的文件

事前準備

先查找 Organization ID(當然也可以土炮用 API 再次呼叫)

# gcloud 指令
gcloud organizations list
# 內容
DISPLAY_NAME: fubar.com
ID: 12345678910
DIRECTORY_CUSTOMER_ID: C0xxxxg

# gcloud 指令
gcloud projects get-ancestors prod-project-00
# 內容
ID: prod-project-00
TYPE: project

ID: 272979042249
TYPE: folder

ID: 505614032642
TYPE: folder

ID: 12345678910
TYPE: organization

# gcloud 指令
gcloud resource-manager folders list --organization 12345678910
# 內容
DISPLAY_NAME: IFDepartment
PARENT_NAME: organizations/12345678910
ID: 987654321

熟悉 resourcemanager_v3 套件

此次改使用 resource manager v3,有別於 v1,多了可以查找、設定管理 tag 等資源,之後在組織和管理資源可以使用到,參見官方說明。先前在列示專案是使用 v1 版本,在 v3 同樣 project, folder, organization 一樣都有支持。

聯結
來參考看看 v3 ProjectClient 的 SearchProjectRequest 範例吧
  • page_result 是一個 SearchProjectsPager 物件,代表了搜尋專案的結果,通過這個分頁器物件,我們可以訪問搜尋結果的各個頁面,並遍歷每個頁面中的專案資訊
  • search_projects 方法返回的是一個分頁結果(PageResult),可以直接使用他的屬性來抓取,當調用 search_projects 方法時,它會返回一個 SearchProjectsPager 物件,你可以通過這個物件來訪問搜尋結果。SearchProjectsPager 物件提供了一些方法來處理搜尋結果,比如遍歷、取得下一頁結果等
  • response 是從 page_result 中遍歷出來的每一頁的結果,而每個 response 都是一個物件,代表一個專案的資訊都塞在這邊。每個專案物件都有不同的屬性,包括 nameproject_idstatedisplay_namecreate_time 等等

資料長相如下

# print(page_result) 有組織的長相
SearchProjectsPager<projects {
projects {
name: "projects/lab-xxxxxx"
parent: "organizations/xxxxxxxxx"
project_id: "lab-xxxxxx"
state: ACTIVE
display_name: "newbie-lab"
create_time {
seconds: 1692251243
nanos: 409000000
}

# print(page_result) 無組織的長相
SearchProjectsPager<projects {
projects {
name: "projects/xxxxxxxxxxx"
project_id: "project-billing"
state: ACTIVE
display_name: "project-billing"
create_time {
seconds: 1703739471
nanos: 196000000
}
etag: "W/\"e7ad37f10c605804\""
}

# 每個專案都有一組屬性,包括 name、project_id、state、display_name、create_time
# 要使用這個 page_result 物件,你可以使用遍歷的方式來訪問專案列表中的每個專案,
# 並獲取其屬性值。以下是一個簡單的示例程式碼
# 展示如何使用 page_result 物件來訪問專案列表中的專案
for response in page_result:
print(response.name)

程式碼實作說明

面對不同 Folder 層項下的專案內的人員權限資訊如何取得

探索 ListFolderRequest 請定類型

  • 帶入 parent_id 為 organizations/xxxxxxxxxxx
  • 準備好 request 物件後放入 response 後
  • 拿到 PageResult 開始資料處理
from google.cloud import resourcemanager_v3

def list_folders(parent_id):
"""
列出指定父層級的所有資源群組。

Args:
parent_id (str): 資源群組的父層級 ID。預設為組織 ID。

Returns:
list: 包含所有資源群組資訊的字典列表。
"""
try:
if folders_data is None:
folders_data = []

client = resourcemanager_v3.FoldersClient()
request = resourcemanager_v3.ListFoldersRequest(parent=parent_id)
page_result = client.list_folders(request=request)

for page in page_result:
folder_info = {
'folderId': page.name.split('/')[-1],
'folderDisplayName': page.display_name,
'parentType': 'organization' if parent_id.startswith('organizations/') else 'folder',
'parentId': parent_id,
}
folders_data.append(folder_info)

return folders_data

except Exception as e:
print(f"An error occurred: {str(e)}")
return []

if __name__ == '__main__':
# Replace with the actual parent ID
folders_info = list_folders(parent_id)
parent_id = "organizations/xxxxxxxxxxx"
for folder_info in folders_info:
print(folder_info)

""" Output
{
'folderId': '505614032642',
'folderDisplayName': 'IFDepartment',
'parentType': 'organization',
'parentId': 'organizations/xxxxxxxxxxx'
}
"""

list_folders() 只會到第一層,看起來不是我們所希望的,發現要作遞迴處理,微調整一下

from typing import Dict, List, Union
from google.cloud import resourcemanager_v3

def get_folders(
parent_id: str = "organizations/xxxxxxxxxxxxx",
folders: Union[List[str], None] = None,
) -> List[str]:

if folders is None:
folders = []

client = resourcemanager_v3.FoldersClient()
request = resourcemanager_v3.ListFoldersRequest(parent=parent_id,)
page_result = client.list_folders(request=request)

for page in page_result:
folders.append((page.name, page.display_name))

# 這邊我們採用遞迴的方式,列出樹狀結構所有的 Folder
get_folders(parent_id=page.name, folders=folders)

return folders

print(get_folders(
parent_id="organizations/xxxxxxxxxxxxxx"))

# [('folders/505614032642', 'IFDepartment'), ('folders/272979042249', 'DA-Department'), ('folders/731859827915', 'DE-Department')]

在第二個程式碼中,使用了遞迴的方式來列出所有的資源群組(資料夾)。這是因為資源群組在 Google Cloud Resource Manager 中是以樹狀結構的形式組織的,一個資源群組可以包含多個子資源群組,每個子資源群組又可以包含自己的子資源群組,以此類推。

遞迴在這裡作用是通過不斷地調用自己來遍歷整個資源群組的樹狀結構。當遍歷一個資源群組時,同時也需要遍歷該資源群組下的所有子資源群組。為了實現這一點,我們使用了遞迴的方式,直到所有的資源群組都被列出。

產出結果

總算是自己成立的 Organization 項下的 Folder 層 / Project 處理完畢,這個算是滿重要管理用的基礎工程,建議就寫入資料庫中供後續其他用途使用

from typing import Dict, List, Union
from google.cloud import resourcemanager_v3

def projects_in_folder(folder_id: str) -> List[str]:
"""
檢索指定資料夾內的所有活動專案名稱。
Args:
folder_id (str): 資料夾的ID。
Returns:
List[str]: 資料夾內所有活動專案的名稱列表。
"""
# 建立一個與 Google Cloud 資源管理員 API 互動的客戶端
client = resourcemanager_v3.ProjectsClient()
# 定義一個查詢來搜索指定資料夾內的專案
query = f'parent.type:folder parent.id:{folder_id}'
# 建立一個請求對象來使用指定的查詢搜索專案
request = resourcemanager_v3.SearchProjectsRequest(query=query)
# 發送請求以搜索專案並檢索響應
response = client.search_projects(request=request)
# 從響應中提取並返回活動專案的顯示名稱
projects = []
for project in response:
if project.state == resourcemanager_v3.Project.State.ACTIVE:
projects.append(project.display_name)

return projects

def get_folder_hierarchy(parent_id: str = "organizations/xxxxxxxxxxxxx") -> Dict[str, Union[str, List[str]]]:
"""
檢索以指定父ID開始的 Google Cloud 資源結構的資料夾層級結構。
Args:
parent_id (str): 從其中開始層級結構的父資源的ID。
默認為 "organizations/xxxxxxxxxxxxxx"。
Returns:
Dict[str, Union[str, List[str]]]: 表示資源結構的資料夾層級結構的字典。
鍵表示資料夾名稱,值可以是專案名稱的列表或子層級結構(字典)。
"""
# 建立一個與 Google Cloud 資源管理員 API 互動的客戶端
client = resourcemanager_v3.FoldersClient()
# 發送請求以列出指定父資源下的所有資料夾
response = client.list_folders(parent=parent_id)
# 初始化一個空字典來存儲資料夾層級結構
hierarchy = {}
# 遍歷響應中的每個資料夾
for folder in response:
# 從完整資源名稱中提取資料夾ID
folder_id = folder.name.split('/')[-1]
# 提取資料夾的顯示名稱
folder_name = folder.display_name
# 檢索資料夾內的所有活動專案名稱
projects = projects_in_folder(folder_id)
# 用資料夾名稱作為鍵,將專案名稱列表或子層級結構添加到層級字典中
# 如果 projects 的值為空列表(即沒有活動專案名稱),則 get_folder_hierarchy(folder.name) 函數將被調用,
# 以獲取子資料夾的層級結構,然後將該結果作為層級字典的值。這樣做是為了處理子資料夾的情況,如果該資料夾包含子資料夾,
# 則遞歸調用 get_folder_hierarchy 函數以獲取子資料夾的層級結構,並將其添加到父資料夾的層級字典中。
hierarchy[folder_name] = projects or get_folder_hierarchy(folder.name)
# 返回資料夾層級結構字典
return hierarchy

# 調用函數以檢索並打印資料夾層級結構數據
folder_hierarchy_data = get_folder_hierarchy()
print(folder_hierarchy_data)

# 輸出示例:
# {'IFDepartment': {'DA-Department': ['demolab-406009', 'production'], 'DE-Department': ['development', 'host-project']}}

驗證新增資料夾且無 Project 的結果

符合預期,function 勘用拿來建 Folder / Project 樹狀結構資訊之使用,但建議些資訊就回到資料庫裡頭,除了可以記錄新增、異動外,也比較方便!

{
'Admin': {
'DBA': {},
'Kubernetes': {}
},
'IFDepartment':
{'DA-Department':
['demolab-406009', 'production'],
'DE-Department':
['development', 'host-project']
}
}

排障處理

  • 權限問題

在建立與資源管理器一起使用的自訂角色時,請注意以下幾點:

  • 列出和取得權限(如 resourcemanager.projects.get/list)建議一起授予
  • 當自訂角色包含 folders.listfolders.get 權限時,它還應包含projects.listprojects.get

因此,為了能夠列出組織的每個項目,您需要為自訂角色提供以下權限:

resourcemanager.projects.get/list & resourcemanager.folders.get/list

另外,預定義角色roles/browser會重新組合此範圍的大部分內容,但不會過於寬鬆。

實際未給予權限報錯
An error occurred: 403 Permission ‘resourcemanager.folders.list’ denied on resource ‘//cloudresourcemanager.googleapis.com/organizations/xxxxx’ (or it may not exist). [reason: “IAM_PERMISSION_DENIED”

--

--

Kellen

Backend(Python)/K8s and Container eco-system/Technical&Product Manager/host Developer Experience/早期投入資料創新與 ETL 工作,近期研究 GCP/Azure/AWS 相關的解決方案的 implementation