Skip to main content

azure_lite_rs/api/
security.rs

1//! Azure Defender for Cloud API client.
2//!
3//! Wraps the ARM management plane read operations for Defender for Cloud:
4//! alerts, secure scores, and assessments. All URL construction is in
5//! `ops::security::SecurityOps`.
6//! `subscription_id` is auto-injected from the parent `AzureHttpClient`.
7
8use crate::{
9    AzureHttpClient, Result,
10    ops::security::SecurityOps,
11    types::security::{
12        Alert, AlertListResult, Assessment, AssessmentListResult, SecureScore,
13        SecureScoreListResult,
14    },
15};
16
17/// Client for the Azure Defender for Cloud ARM management plane.
18///
19/// Wraps [`SecurityOps`] with ergonomic signatures that auto-inject
20/// `subscription_id` from the parent [`AzureHttpClient`].
21pub struct SecurityClient<'a> {
22    ops: SecurityOps<'a>,
23    client: &'a AzureHttpClient,
24}
25
26impl<'a> SecurityClient<'a> {
27    /// Create a new Azure Defender for Cloud API client.
28    pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
29        Self {
30            ops: SecurityOps::new(client),
31            client,
32        }
33    }
34
35    // --- Alert operations ---
36
37    /// Lists all alerts associated with the subscription.
38    pub async fn list_alerts(&self) -> Result<AlertListResult> {
39        self.ops.list_alerts(self.client.subscription_id()).await
40    }
41
42    /// Gets an alert within a resource group.
43    ///
44    /// `asc_location` is the ASC location (e.g. `"eastus"`).
45    /// Use `az security location list` to discover the location for your tenant.
46    pub async fn get_alert(
47        &self,
48        resource_group_name: &str,
49        asc_location: &str,
50        alert_name: &str,
51    ) -> Result<Alert> {
52        self.ops
53            .get_alert(
54                self.client.subscription_id(),
55                resource_group_name,
56                asc_location,
57                alert_name,
58            )
59            .await
60    }
61
62    /// Updates the status of an alert.
63    ///
64    /// `alert_update_action_type` is one of: `activate`, `dismiss`, `resolve`,
65    /// `inProgress`, `close`.
66    pub async fn update_alert_status(
67        &self,
68        resource_group_name: &str,
69        asc_location: &str,
70        alert_name: &str,
71        alert_update_action_type: &str,
72    ) -> Result<()> {
73        self.ops
74            .update_alert_status(
75                self.client.subscription_id(),
76                resource_group_name,
77                asc_location,
78                alert_name,
79                alert_update_action_type,
80            )
81            .await
82    }
83
84    // --- Secure Score operations ---
85
86    /// Lists all secure scores for the subscription.
87    pub async fn list_secure_scores(&self) -> Result<SecureScoreListResult> {
88        self.ops
89            .list_secure_scores(self.client.subscription_id())
90            .await
91    }
92
93    /// Gets the secure score for a specific Defender for Cloud initiative.
94    ///
95    /// Use `"ascScore"` for the built-in Microsoft Defender for Cloud score.
96    pub async fn get_secure_score(&self, secure_score_name: &str) -> Result<SecureScore> {
97        self.ops
98            .get_secure_score(self.client.subscription_id(), secure_score_name)
99            .await
100    }
101
102    // --- Assessment operations ---
103
104    /// Lists all security assessments for the subscription.
105    pub async fn list_assessments(&self) -> Result<AssessmentListResult> {
106        self.ops
107            .list_assessments(self.client.subscription_id())
108            .await
109    }
110
111    /// Gets a specific security assessment.
112    ///
113    /// `assessment_name` is the UUID of the assessment definition
114    /// (e.g. `"4fb67663-9ab9-475d-b026-8c544cced439"` for OS vulnerabilities).
115    ///
116    /// NOTE: This endpoint requires `api-version=2021-06-01` on the live API,
117    /// while the service manifest uses `2020-01-01` (required by secureScores).
118    /// As a result this will return 405 against the live API. Use the unit test
119    /// mock to verify the URL and deserialization logic.
120    pub async fn get_assessment(&self, assessment_name: &str) -> Result<Assessment> {
121        self.ops
122            .get_assessment(self.client.subscription_id(), assessment_name)
123            .await
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::MockClient;
131
132    const SUB_ID: &str = "test-subscription-id";
133    const ALERT_NAME: &str = "2517376_cloud-lite-test-alert";
134    const ASC_LOCATION: &str = "eastus";
135    const RG: &str = "test-rg";
136    const ASSESSMENT_NAME: &str = "050ac097-3dda-4d24-ab6d-82568e7a50cf";
137
138    fn make_client(mock: MockClient) -> AzureHttpClient {
139        AzureHttpClient::from_mock(mock)
140    }
141
142    fn alert_json() -> serde_json::Value {
143        serde_json::json!({
144            "id": format!("/subscriptions/{SUB_ID}/resourceGroups/{RG}/providers/Microsoft.Security/locations/{ASC_LOCATION}/alerts/{ALERT_NAME}"),
145            "name": ALERT_NAME,
146            "type": "Microsoft.Security/alerts",
147            "properties": {
148                "alertDisplayName": "Suspicious PowerShell activity",
149                "alertType": "VM_SuspiciousPowerShellActivity",
150                "severity": "High",
151                "status": "Active",
152                "description": "A suspicious PowerShell command was executed.",
153                "remediationSteps": ["Investigate the activity", "Revoke credentials"],
154                "intent": "Execution"
155            }
156        })
157    }
158
159    fn secure_score_json() -> serde_json::Value {
160        serde_json::json!({
161            "id": format!("/subscriptions/{SUB_ID}/providers/Microsoft.Security/secureScores/ascScore"),
162            "name": "ascScore",
163            "type": "Microsoft.Security/secureScores",
164            "properties": {
165                "displayName": "ASC score",
166                "score": {
167                    "max": 21,
168                    "current": 12.55,
169                    "percentage": 0.5976
170                },
171                "weight": 1
172            }
173        })
174    }
175
176    fn assessment_json() -> serde_json::Value {
177        serde_json::json!({
178            "id": format!("/subscriptions/{SUB_ID}/providers/Microsoft.Security/assessments/{ASSESSMENT_NAME}"),
179            "name": ASSESSMENT_NAME,
180            "type": "Microsoft.Security/assessments",
181            "properties": {
182                "displayName": "Disabled accounts with owner permissions should be removed",
183                "status": {
184                    "code": "Healthy",
185                    "cause": "OffByPolicy",
186                    "description": "All accounts are enabled"
187                }
188            }
189        })
190    }
191
192    #[tokio::test]
193    async fn list_alerts_returns_list() {
194        let mut mock = MockClient::new();
195        mock.expect_get(&format!(
196            "/subscriptions/{SUB_ID}/providers/Microsoft.Security/alerts"
197        ))
198        .returning_json(serde_json::json!({ "value": [alert_json()] }));
199        let client = make_client(mock);
200        let result = client
201            .security()
202            .list_alerts()
203            .await
204            .expect("list_alerts failed");
205        assert_eq!(result.value.len(), 1);
206        let a = &result.value[0];
207        assert_eq!(a.name.as_deref(), Some(ALERT_NAME));
208        let props = a.properties.as_ref().unwrap();
209        assert_eq!(props.severity.as_deref(), Some("High"));
210        assert_eq!(props.status.as_deref(), Some("Active"));
211        assert_eq!(props.remediation_steps.len(), 2);
212    }
213
214    #[tokio::test]
215    async fn get_alert_deserializes_properties() {
216        let mut mock = MockClient::new();
217        mock.expect_get(
218            &format!("/subscriptions/{SUB_ID}/resourceGroups/{RG}/providers/Microsoft.Security/locations/{ASC_LOCATION}/alerts/{ALERT_NAME}"),
219        )
220        .returning_json(alert_json());
221        let client = make_client(mock);
222        let a = client
223            .security()
224            .get_alert(RG, ASC_LOCATION, ALERT_NAME)
225            .await
226            .expect("get_alert failed");
227        assert_eq!(a.name.as_deref(), Some(ALERT_NAME));
228        let props = a.properties.as_ref().unwrap();
229        assert_eq!(
230            props.alert_type.as_deref(),
231            Some("VM_SuspiciousPowerShellActivity")
232        );
233        assert_eq!(props.intent.as_deref(), Some("Execution"));
234    }
235
236    #[tokio::test]
237    async fn update_alert_status_constructs_url() {
238        let mut mock = MockClient::new();
239        mock.expect_post(
240            &format!("/subscriptions/{SUB_ID}/resourceGroups/{RG}/providers/Microsoft.Security/locations/{ASC_LOCATION}/alerts/{ALERT_NAME}/dismiss"),
241        )
242        .returning_json(serde_json::json!({}));
243        let client = make_client(mock);
244        client
245            .security()
246            .update_alert_status(RG, ASC_LOCATION, ALERT_NAME, "dismiss")
247            .await
248            .expect("update_alert_status failed");
249    }
250
251    #[tokio::test]
252    async fn list_secure_scores_returns_asc_score() {
253        let mut mock = MockClient::new();
254        mock.expect_get(&format!(
255            "/subscriptions/{SUB_ID}/providers/Microsoft.Security/secureScores"
256        ))
257        .returning_json(serde_json::json!({ "value": [secure_score_json()] }));
258        let client = make_client(mock);
259        let result = client
260            .security()
261            .list_secure_scores()
262            .await
263            .expect("list_secure_scores failed");
264        assert_eq!(result.value.len(), 1);
265        let score = &result.value[0];
266        assert_eq!(score.name.as_deref(), Some("ascScore"));
267        let props = score.properties.as_ref().unwrap();
268        let details = props.score.as_ref().unwrap();
269        assert_eq!(details.max, Some(21));
270        assert_eq!(details.current, Some(12.55));
271    }
272
273    #[tokio::test]
274    async fn get_assessment_deserializes_status() {
275        let mut mock = MockClient::new();
276        mock.expect_get(&format!(
277            "/subscriptions/{SUB_ID}/providers/Microsoft.Security/assessments/{ASSESSMENT_NAME}"
278        ))
279        .returning_json(assessment_json());
280        let client = make_client(mock);
281        let a = client
282            .security()
283            .get_assessment(ASSESSMENT_NAME)
284            .await
285            .expect("get_assessment failed");
286        assert_eq!(a.name.as_deref(), Some(ASSESSMENT_NAME));
287        let props = a.properties.as_ref().unwrap();
288        let status = props.status.as_ref().unwrap();
289        assert_eq!(status.code.as_deref(), Some("Healthy"));
290        assert_eq!(status.cause.as_deref(), Some("OffByPolicy"));
291    }
292}