azure-deploy-cli 0.1.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,177 @@
1
+ """Managed identity (user-assigned identity) lifecycle management."""
2
+
3
+ from azure.mgmt.msi import ManagedServiceIdentityClient
4
+
5
+ from ..utils.azure_cli import get_credential, get_subscription_and_tenant
6
+ from ..utils.logging import get_logger
7
+ from .models import ManagedIdentity
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def get_user_identity(
13
+ identity_name: str, resource_group: str, subscription_id: str
14
+ ) -> ManagedIdentity | None:
15
+ """Get existing managed identity by name."""
16
+ try:
17
+ credential = get_credential(cache=True)
18
+ msi_client = ManagedServiceIdentityClient(credential, subscription_id)
19
+
20
+ logger.info(f"Looking up managed identity '{identity_name}'")
21
+ identities = msi_client.user_assigned_identities.list_by_resource_group(resource_group)
22
+
23
+ for identity in identities:
24
+ if identity.name == identity_name:
25
+ logger.info(f"Found managed identity '{identity_name}'")
26
+ principal_id = identity.principal_id
27
+ if not principal_id:
28
+ raise ValueError("Principal ID is missing from identity")
29
+
30
+ return ManagedIdentity(
31
+ resourceId=identity.id,
32
+ principalId=principal_id,
33
+ )
34
+
35
+ return None
36
+
37
+ except Exception as e:
38
+ logger.error(f"Failed to retrieve managed identity: {str(e)}")
39
+ raise
40
+
41
+
42
+ def create_or_get_user_identity(
43
+ identity_name: str,
44
+ resource_group: str,
45
+ location: str,
46
+ ) -> ManagedIdentity:
47
+ """
48
+ Create a managed identity if it doesn't already exist.
49
+
50
+ Args:
51
+ identity_name: Name of the managed identity to create
52
+ resource_group: Azure resource group name
53
+ location: Azure region location
54
+
55
+ Returns:
56
+ ManagedIdentity containing resourceId and principalId
57
+
58
+ Raises:
59
+ Exception: If creation fails
60
+ """
61
+ try:
62
+ subscription_id, _ = get_subscription_and_tenant()
63
+
64
+ logger.info(f"Checking if managed identity '{identity_name}' exists")
65
+ result = get_user_identity(identity_name, resource_group, subscription_id)
66
+ if result is not None:
67
+ logger.warning(f"Managed identity '{identity_name}' already exists")
68
+ return result
69
+
70
+ logger.critical(f"Creating managed identity '{identity_name}'")
71
+
72
+ credential = get_credential(cache=True)
73
+ msi_client = ManagedServiceIdentityClient(credential, subscription_id)
74
+
75
+ identity_params = {
76
+ "location": location,
77
+ }
78
+
79
+ identity = msi_client.user_assigned_identities.create_or_update(
80
+ resource_group, identity_name, identity_params
81
+ )
82
+
83
+ principal_id = identity.principal_id
84
+ if not principal_id:
85
+ raise ValueError("Principal ID is missing from created identity")
86
+
87
+ logger.success(f"Managed identity '{identity_name}' created successfully")
88
+
89
+ return ManagedIdentity(
90
+ resourceId=identity.id,
91
+ principalId=principal_id,
92
+ )
93
+
94
+ except Exception as e:
95
+ logger.error(f"Failed to create managed identity: {str(e)}")
96
+ raise
97
+
98
+
99
+ def get_identity_principal_id(identity_id: str) -> str:
100
+ """
101
+ Get the principal ID (object ID) of a managed identity.
102
+
103
+ Args:
104
+ identity_id: Resource ID of the managed identity
105
+
106
+ Returns:
107
+ Principal ID (object ID) of the identity
108
+
109
+ Raises:
110
+ Exception: If identity not found or principal ID cannot be retrieved
111
+ """
112
+ try:
113
+ if not identity_id or not identity_id.strip():
114
+ raise ValueError("Identity ID cannot be empty")
115
+
116
+ logger.info(f"Retrieving principal ID for identity: {identity_id}")
117
+
118
+ subscription_id, _ = get_subscription_and_tenant()
119
+ credential = get_credential(cache=True)
120
+ msi_client = ManagedServiceIdentityClient(credential, subscription_id)
121
+
122
+ # Parse resource ID to extract resource group and name
123
+ # Format: /subscriptions/{sub}/resourceGroups/{rg}/providers/
124
+ # Microsoft.ManagedIdentity/userAssignedIdentities/{name}
125
+ parts = identity_id.split("/")
126
+ if len(parts) < 9:
127
+ raise ValueError(f"Invalid identity resource ID format: {identity_id}")
128
+
129
+ resource_group = parts[4]
130
+ identity_name = parts[8]
131
+
132
+ identity = msi_client.user_assigned_identities.get(resource_group, identity_name)
133
+
134
+ principal_id = identity.principal_id
135
+ if not principal_id:
136
+ raise ValueError(f"Principal ID is empty for identity: {identity_id}")
137
+
138
+ logger.success(f"Retrieved principal ID: {principal_id}")
139
+ return str(principal_id)
140
+
141
+ except Exception as e:
142
+ logger.error(f"Failed to retrieve principal ID for identity: {str(e)}")
143
+ raise
144
+
145
+
146
+ def delete_user_identity(identity_name: str, resource_group: str) -> None:
147
+ """
148
+ Delete a managed identity by name.
149
+
150
+ Args:
151
+ identity_name: Name of the managed identity to delete
152
+ resource_group: Azure resource group name
153
+
154
+ Raises:
155
+ Exception: If deletion fails
156
+ """
157
+ try:
158
+ subscription_id, _ = get_subscription_and_tenant()
159
+
160
+ logger.info(f"Looking up managed identity '{identity_name}'")
161
+ identity = get_user_identity(identity_name, resource_group, subscription_id)
162
+
163
+ if not identity:
164
+ logger.success(f"Managed identity '{identity_name}' does not exist; nothing to delete")
165
+ return
166
+
167
+ logger.info(f"Found managed identity with resource ID: {identity.resourceId}")
168
+
169
+ credential = get_credential(cache=True)
170
+ msi_client = ManagedServiceIdentityClient(credential, subscription_id)
171
+
172
+ msi_client.user_assigned_identities.delete(resource_group, identity_name)
173
+ logger.success(f"Managed identity '{identity_name}' deleted successfully")
174
+
175
+ except Exception as e:
176
+ logger.error(f"Failed to delete managed identity: {str(e)}")
177
+ raise
@@ -0,0 +1,167 @@
1
+ from dataclasses import dataclass
2
+ from typing import Any
3
+
4
+ from pydantic import BaseModel, field_validator
5
+
6
+
7
+ @dataclass
8
+ class SPAuthCredentialsWithSecret:
9
+ clientId: str
10
+ clientSecret: str
11
+ subscriptionId: str
12
+ tenantId: str
13
+
14
+ def to_dict(self):
15
+ return {
16
+ "clientId": self.clientId,
17
+ "clientSecret": self.clientSecret,
18
+ "subscriptionId": self.subscriptionId,
19
+ "tenantId": self.tenantId,
20
+ }
21
+
22
+ def __post_init__(self) -> None:
23
+ if not self.clientId or not self.clientId.strip():
24
+ raise ValueError("clientId cannot be empty")
25
+ if self.clientSecret is None:
26
+ raise ValueError("clientSecret cannot be None")
27
+ if not self.clientSecret or not self.clientSecret.strip():
28
+ raise ValueError("clientSecret cannot be empty")
29
+ if not self.subscriptionId or not self.subscriptionId.strip():
30
+ raise ValueError("subscriptionId cannot be empty")
31
+ if not self.tenantId or not self.tenantId.strip():
32
+ raise ValueError("tenantId cannot be empty")
33
+
34
+
35
+ @dataclass
36
+ class SPAuthCredentials:
37
+ clientId: str
38
+ subscriptionId: str
39
+ tenantId: str
40
+
41
+ def to_dict(self):
42
+ return {
43
+ "clientId": self.clientId,
44
+ "subscriptionId": self.subscriptionId,
45
+ "tenantId": self.tenantId,
46
+ }
47
+
48
+ def __post_init__(self) -> None:
49
+ if not self.clientId or not self.clientId.strip():
50
+ raise ValueError("clientId cannot be empty")
51
+ if not self.subscriptionId or not self.subscriptionId.strip():
52
+ raise ValueError("subscriptionId cannot be empty")
53
+ if not self.tenantId or not self.tenantId.strip():
54
+ raise ValueError("tenantId cannot be empty")
55
+
56
+
57
+ @dataclass
58
+ class SPCreateResult:
59
+ """Result of service principal creation."""
60
+
61
+ objectId: str
62
+ authCredentials: SPAuthCredentialsWithSecret | SPAuthCredentials
63
+
64
+ def __post_init__(self) -> None:
65
+ if not self.objectId or not self.objectId.strip():
66
+ raise ValueError("objectId cannot be empty")
67
+
68
+
69
+ @dataclass
70
+ class AzureGroup:
71
+ """Result of security group lookup for role assignment."""
72
+
73
+ objectId: str
74
+ displayName: str
75
+
76
+ def __post_init__(self) -> None:
77
+ if not self.objectId or not self.objectId.strip():
78
+ raise ValueError("objectId cannot be empty")
79
+ if not self.displayName or not self.displayName.strip():
80
+ raise ValueError("displayName cannot be empty")
81
+
82
+
83
+ @dataclass
84
+ class ManagedIdentity:
85
+ """Result of managed identity creation/retrieval."""
86
+
87
+ resourceId: str # Full resource ID
88
+ principalId: str # Object ID for role assignment
89
+
90
+ def __post_init__(self) -> None:
91
+ if not self.resourceId or not self.resourceId.strip():
92
+ raise ValueError("resourceId cannot be empty")
93
+ if not self.principalId or not self.principalId.strip():
94
+ raise ValueError("principalId cannot be empty")
95
+
96
+
97
+ class RoleDefinition(BaseModel):
98
+ """Role definition for assignment to service principals."""
99
+
100
+ type: str = "rbac" # 'cosmos-db' or 'rbac'
101
+ account: str | None = None # Required for cosmos-db type
102
+ role: str # Role name (varies by type)
103
+ scope: str # Resource scope (for rbac) or '/' (for cosmos-db)
104
+ description: str | None = None
105
+
106
+ @field_validator("role")
107
+ @classmethod
108
+ def role_not_empty(cls, v: str) -> str:
109
+ """Validate that role is not empty"""
110
+ if not v or not v.strip():
111
+ raise ValueError("Role cannot be empty")
112
+ return v.strip()
113
+
114
+ @field_validator("scope")
115
+ @classmethod
116
+ def scope_not_empty(cls, v: str) -> str:
117
+ """Validate that scope is not empty"""
118
+ if not v or not v.strip():
119
+ raise ValueError("Scope cannot be empty")
120
+ return v.strip()
121
+
122
+ @field_validator("type")
123
+ @classmethod
124
+ def type_valid(cls, v: str) -> str:
125
+ """Validate that type is one of allowed values"""
126
+ valid_types = {"rbac", "cosmos-db"}
127
+ if v not in valid_types:
128
+ raise ValueError(f"Type must be one of {valid_types}, got '{v}'")
129
+ return v
130
+
131
+ def model_post_init(self, __context: Any) -> None:
132
+ """Validate cosmos-db specific requirements after initialization"""
133
+ if self.type == "cosmos-db" and not self.account:
134
+ raise ValueError("Cosmos DB role configuration must include 'account' field")
135
+
136
+ class Config:
137
+ """Pydantic config"""
138
+
139
+ str_strip_whitespace = True
140
+
141
+
142
+ class RoleConfig(BaseModel):
143
+ """Configuration for role assignments."""
144
+
145
+ description: str
146
+ roles: list[RoleDefinition]
147
+
148
+ @field_validator("description")
149
+ @classmethod
150
+ def description_not_empty(cls, v: str) -> str:
151
+ """Validate that description is not empty"""
152
+ if not v or not v.strip():
153
+ raise ValueError("Description cannot be empty")
154
+ return v.strip()
155
+
156
+ @field_validator("roles")
157
+ @classmethod
158
+ def roles_not_empty(cls, v: list[RoleDefinition]) -> list[RoleDefinition]:
159
+ """Validate that roles list is not empty"""
160
+ if not v:
161
+ raise ValueError("Roles list cannot be empty")
162
+ return v
163
+
164
+ class Config:
165
+ """Pydantic config"""
166
+
167
+ str_strip_whitespace = True
File without changes
@@ -0,0 +1,338 @@
1
+ """Role assignment for service principals (RBAC and Cosmos DB)."""
2
+
3
+ import json
4
+ import subprocess
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from azure.mgmt.authorization import AuthorizationManagementClient
10
+ from azure.mgmt.authorization.v2022_04_01.models import (
11
+ RoleAssignmentCreateParameters,
12
+ )
13
+
14
+ from ..utils.azure_cli import get_credential, get_subscription_and_tenant, run_command
15
+ from ..utils.env import load_env_vars_from_files, substitute_env_vars
16
+ from ..utils.logging import get_logger
17
+ from .models import RoleConfig, RoleDefinition
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def load_role_config(roles_config_path: Path) -> RoleConfig:
23
+ try:
24
+ with open(roles_config_path) as f:
25
+ role_config_data = json.load(f)
26
+ return RoleConfig(**role_config_data)
27
+ except FileNotFoundError:
28
+ logger.error(f"Roles config file not found: {roles_config_path}")
29
+ raise
30
+ except Exception as e:
31
+ logger.error(f"Failed to load roles config: {str(e)}")
32
+ raise
33
+
34
+
35
+ def assign_role_by_files(
36
+ object_id: str,
37
+ roles_config: Path,
38
+ env_vars_files: list[Path],
39
+ subscription_id: str | None = None,
40
+ object_type: str = "ServicePrincipal",
41
+ ) -> None:
42
+ role_config = load_role_config(roles_config)
43
+
44
+ try:
45
+ env_vars = load_env_vars_from_files(env_vars_files)
46
+ except Exception as e:
47
+ logger.error(f"Failed to load environment variables: {str(e)}")
48
+ raise
49
+
50
+ if subscription_id is None:
51
+ try:
52
+ subscription_id, _ = get_subscription_and_tenant()
53
+ except Exception as e:
54
+ logger.error(f"Failed to get subscription info: {str(e)}")
55
+ raise
56
+
57
+ if not subscription_id:
58
+ raise ValueError("Subscription ID is required for role assignment")
59
+
60
+ env_vars["SUBSCRIPTION_ID"] = subscription_id
61
+
62
+ try:
63
+ assign_roles(object_id, subscription_id, role_config, env_vars, object_type=object_type)
64
+ except Exception as e:
65
+ logger.error(f"Failed to assign roles: {str(e)}")
66
+ raise
67
+
68
+ logger.success("Service principal created and roles assigned successfully")
69
+
70
+
71
+ def assign_roles(
72
+ object_id: str,
73
+ subscription_id: str,
74
+ role_config: RoleConfig,
75
+ env_vars: dict[str, str] | None = None,
76
+ object_type: str = "ServicePrincipal",
77
+ ) -> None:
78
+ """
79
+ Assign roles to a service principal based on role configuration.
80
+
81
+ Args:
82
+ object_id: Object ID of the service principal
83
+ subscription_id: Azure subscription ID
84
+ role_config: RoleConfig object containing description and roles list
85
+ env_vars: Dictionary of environment variables to substitute in scopes
86
+
87
+ Raises:
88
+ ValueError: If any role configuration is invalid
89
+ """
90
+ if env_vars is None:
91
+ env_vars = {}
92
+
93
+ try:
94
+ logger.info(f"Processing role config: {role_config.description}")
95
+ logger.info(f"Validating {len(role_config.roles)} role definitions")
96
+
97
+ for i, role_def in enumerate(role_config.roles):
98
+ logger.critical(f"Processing role {i + 1}/{len(role_config.roles)}: {role_def.role}")
99
+
100
+ if role_def.type == "cosmos-db":
101
+ assign_cosmos_db_role(
102
+ object_id,
103
+ role_def,
104
+ env_vars,
105
+ )
106
+ elif role_def.type == "rbac":
107
+ assign_rbac_role(
108
+ object_id,
109
+ subscription_id,
110
+ role_def,
111
+ env_vars,
112
+ object_type=object_type,
113
+ )
114
+ else:
115
+ logger.warning(f"Unknown role type: {role_def.type}")
116
+
117
+ logger.success("Role assignments completed")
118
+
119
+ except Exception as e:
120
+ logger.error(f"Failed to assign roles: {str(e)}")
121
+ raise
122
+
123
+
124
+ def get_cosmos_accounts() -> list[dict[str, Any]]:
125
+ list_cmd: list[str] = [
126
+ "az",
127
+ "cosmosdb",
128
+ "list",
129
+ "--output",
130
+ "json",
131
+ ]
132
+
133
+ accounts: list[dict[str, Any]] = run_command(list_cmd)
134
+ return accounts
135
+
136
+
137
+ def extract_resource_group(account_name: str, accounts: list[dict[str, Any]]) -> str | None:
138
+ for account in accounts:
139
+ if account.get("name") == account_name:
140
+ return account.get("resourceGroup")
141
+ return None
142
+
143
+
144
+ def get_cosmos_role_def(
145
+ role_name: str, account_name: str, resource_group: str
146
+ ) -> dict[str, Any] | None:
147
+ command = [
148
+ "az",
149
+ "cosmosdb",
150
+ "sql",
151
+ "role",
152
+ "definition",
153
+ "list",
154
+ "--account-name",
155
+ account_name,
156
+ "--resource-group",
157
+ resource_group,
158
+ "--output",
159
+ "json",
160
+ ]
161
+ role_defs: list[dict[str, Any]] = run_command(command)
162
+ for role_def in role_defs:
163
+ if role_def.get("roleName") == role_name:
164
+ return role_def
165
+ return None
166
+
167
+
168
+ def exists_cosmos_role_assignment(
169
+ role_def: dict[str, Any], account_name: str, resource_group: str
170
+ ) -> bool:
171
+ command = [
172
+ "az",
173
+ "cosmosdb",
174
+ "sql",
175
+ "role",
176
+ "assignment",
177
+ "list",
178
+ "--account-name",
179
+ account_name,
180
+ "--resource-group",
181
+ resource_group,
182
+ "--output",
183
+ "json",
184
+ ]
185
+ assignments: list[dict[str, Any]] = run_command(command)
186
+ for assignment in assignments:
187
+ if assignment.get("roleDefinitionId") == role_def.get("id"):
188
+ return True
189
+ return False
190
+
191
+
192
+ def assign_cosmos_db_role(
193
+ object_id: str,
194
+ role_def: RoleDefinition,
195
+ env_vars: dict[str, str],
196
+ ) -> None:
197
+ """
198
+ Assign a Cosmos DB role to a service principal via Azure CLI.
199
+
200
+ Args:
201
+ cosmos_client: Cosmos DB management client
202
+ object_id: Object ID of the service principal
203
+ role_def: Validated role definition with type='cosmos-db'
204
+ env_vars: Environment variables for substitution
205
+
206
+ Raises:
207
+ KeyError: If required environment variables are missing
208
+ subprocess.CalledProcessError: If role assignment fails
209
+ """
210
+ try:
211
+ account_name = substitute_env_vars(role_def.account or "", env_vars)
212
+ scope = role_def.scope
213
+ role_name = role_def.role
214
+
215
+ logger.info(f"Assigning Cosmos DB role '{role_name}' to SP on account '{account_name}'")
216
+
217
+ accounts = get_cosmos_accounts()
218
+ resource_group = extract_resource_group(account_name, accounts)
219
+
220
+ if not resource_group:
221
+ logger.error(f"Cosmos DB account '{account_name}' not found")
222
+ raise ValueError(f"Cosmos DB account '{account_name}' not found")
223
+
224
+ logger.info(f"Found account in resource group '{resource_group}'")
225
+
226
+ role_definition = get_cosmos_role_def(role_name, account_name, resource_group)
227
+ if not role_definition:
228
+ logger.error(f"Role definition '{role_name}' not found in account '{account_name}'")
229
+ raise ValueError(f"Role definition '{role_name}' not found in account '{account_name}'")
230
+
231
+ if exists_cosmos_role_assignment(role_definition, account_name, resource_group):
232
+ logger.success(
233
+ f"Cosmos DB role '{role_name}' is already assigned on account '{account_name}'"
234
+ )
235
+ return
236
+
237
+ assign_cmd: list[str] = [
238
+ "az",
239
+ "cosmosdb",
240
+ "sql",
241
+ "role",
242
+ "assignment",
243
+ "create",
244
+ "--account-name",
245
+ account_name,
246
+ "--resource-group",
247
+ resource_group,
248
+ "--role-definition-name",
249
+ role_name,
250
+ "--principal-id",
251
+ object_id,
252
+ "--scope",
253
+ scope,
254
+ "--output",
255
+ "json",
256
+ ]
257
+
258
+ run_command(assign_cmd)
259
+ logger.success(f"Cosmos DB role '{role_name}' assigned successfully")
260
+
261
+ except KeyError as e:
262
+ logger.error(f"Environment variable substitution failed for Cosmos DB role: {str(e)}")
263
+ raise
264
+ except subprocess.CalledProcessError as e:
265
+ logger.error(f"Failed to assign Cosmos DB role: {str(e)}")
266
+ raise
267
+ except Exception as e:
268
+ logger.error(f"Unexpected error assigning Cosmos DB role: {str(e)}")
269
+ raise
270
+
271
+
272
+ def assign_rbac_role(
273
+ object_id: str,
274
+ subscription_id: str,
275
+ role_def: RoleDefinition,
276
+ env_vars: dict[str, str],
277
+ object_type: str = "ServicePrincipal",
278
+ ) -> None:
279
+ """
280
+ Assign an RBAC role to a service principal.
281
+
282
+ Args:
283
+ object_id: Object ID of the service principal
284
+ subscription_id: Azure subscription ID
285
+ role_def: Validated role definition
286
+ env_vars: Environment variables for substitution
287
+
288
+ Raises:
289
+ Exception: If role assignment fails
290
+ """
291
+ try:
292
+ scope = substitute_env_vars(role_def.scope, env_vars)
293
+ role_name = role_def.role
294
+
295
+ logger.info(f"Looking up role definition for '{role_name}' at scope '{scope}'")
296
+
297
+ credential = get_credential(cache=True)
298
+ auth_client = AuthorizationManagementClient(credential, subscription_id)
299
+
300
+ role_defs = auth_client.role_definitions.list(
301
+ scope=scope,
302
+ filter=f"roleName eq '{role_name}'",
303
+ )
304
+ role_def_list = list(role_defs)
305
+
306
+ if not role_def_list:
307
+ logger.error(f"Role '{role_name}' not found at scope '{scope}'")
308
+ return
309
+
310
+ role_id = role_def_list[0].id
311
+ logger.critical(f"Assigning role '{role_name}' to SP")
312
+
313
+ existing_assignments = auth_client.role_assignments.list_for_scope(
314
+ scope=scope,
315
+ filter=f"principalId eq '{object_id}'",
316
+ )
317
+ existing_role_assignments = [
318
+ a for a in existing_assignments if a.role_definition_id == role_id
319
+ ]
320
+ if existing_role_assignments:
321
+ logger.success(f"Role '{role_name}' is already assigned at scope '{scope}'")
322
+ return
323
+
324
+ auth_client.role_assignments.create(
325
+ scope=scope,
326
+ role_assignment_name=str(uuid.uuid4()),
327
+ parameters=RoleAssignmentCreateParameters(
328
+ role_definition_id=role_id,
329
+ principal_id=object_id,
330
+ principal_type=object_type,
331
+ ),
332
+ )
333
+
334
+ logger.success(f"Role '{role_name}' assigned successfully")
335
+
336
+ except Exception as e:
337
+ logger.error(f"Failed to assign RBAC role: {str(e)}")
338
+ raise