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.
- azure_deploy_cli/__init__.py +44 -0
- azure_deploy_cli/_version.py +34 -0
- azure_deploy_cli/aca/aca_cli.py +518 -0
- azure_deploy_cli/aca/bash/aca-cert/create.sh +203 -0
- azure_deploy_cli/aca/bash/aca-cert/destroy.sh +44 -0
- azure_deploy_cli/aca/deploy_aca.py +794 -0
- azure_deploy_cli/aca/model.py +35 -0
- azure_deploy_cli/cli.py +66 -0
- azure_deploy_cli/identity/__init__.py +36 -0
- azure_deploy_cli/identity/group.py +84 -0
- azure_deploy_cli/identity/identity_cli.py +453 -0
- azure_deploy_cli/identity/managed_identity.py +177 -0
- azure_deploy_cli/identity/models.py +167 -0
- azure_deploy_cli/identity/py.typed +0 -0
- azure_deploy_cli/identity/role.py +338 -0
- azure_deploy_cli/identity/service_principal.py +268 -0
- azure_deploy_cli/py.typed +0 -0
- azure_deploy_cli/utils/__init__.py +0 -0
- azure_deploy_cli/utils/azure_cli.py +96 -0
- azure_deploy_cli/utils/docker.py +137 -0
- azure_deploy_cli/utils/env.py +108 -0
- azure_deploy_cli/utils/key_vault.py +11 -0
- azure_deploy_cli/utils/logging.py +125 -0
- azure_deploy_cli/utils/py.typed +0 -0
- azure_deploy_cli-0.1.6.dist-info/METADATA +678 -0
- azure_deploy_cli-0.1.6.dist-info/RECORD +30 -0
- azure_deploy_cli-0.1.6.dist-info/WHEEL +5 -0
- azure_deploy_cli-0.1.6.dist-info/entry_points.txt +3 -0
- azure_deploy_cli-0.1.6.dist-info/licenses/LICENSE +373 -0
- azure_deploy_cli-0.1.6.dist-info/top_level.txt +1 -0
|
@@ -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
|