Skip to main content

drasi_source_dataverse/
config.rs

1// Copyright 2026 The Drasi Authors.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Dataverse Source configuration.
16//!
17//! Configuration for the Microsoft Dataverse change tracking source,
18//! which uses the OData Web API equivalent of `RetrieveEntityChangesRequest`
19//! for polling-based change detection via delta links.
20
21use std::collections::HashMap;
22
23/// Configuration for the Dataverse replication source.
24///
25/// Mirrors the platform Dataverse source configuration which uses
26/// `RetrieveEntityChangesRequest` for change tracking. In the Rust/Web API
27/// implementation, this is achieved via OData change tracking with
28/// `Prefer: odata.track-changes` headers and delta links.
29///
30/// # Required Configuration
31///
32/// - `environment_url`: The Dataverse environment URL (e.g., `https://myorg.crm.dynamics.com`)
33/// - `entities`: List of entity logical names to monitor (e.g., `["account", "contact"]`)
34/// - Authentication (one of):
35///   - Identity provider via [`DataverseSource::builder`] `.with_identity_provider()` (recommended)
36///   - `tenant_id` + `client_id` + `client_secret` for client credentials flow
37///   - `use_azure_cli = true` for Azure CLI authentication (local dev)
38///
39/// # Optional Configuration
40///
41/// - `entity_set_overrides`: Override computed entity set names for specific entities
42/// - `min_interval_ms`: Minimum adaptive polling interval (default: 500)
43/// - `max_interval_seconds`: Maximum adaptive polling interval (default: 30)
44/// - `api_version`: Dataverse Web API version (default: `v9.2`)
45#[derive(Clone)]
46pub struct DataverseSourceConfig {
47    /// Dataverse environment URL (e.g., `https://myorg.crm.dynamics.com`).
48    pub environment_url: String,
49
50    /// Azure AD / Microsoft Entra ID tenant ID for OAuth2 authentication.
51    /// Required for client credentials flow, ignored when `use_azure_cli` is true.
52    pub tenant_id: String,
53
54    /// Azure AD application (client) ID.
55    /// Required for client credentials flow, ignored when `use_azure_cli` is true.
56    pub client_id: String,
57
58    /// Azure AD client secret for OAuth2 client credentials flow.
59    /// Required for client credentials flow, ignored when `use_azure_cli` is true.
60    pub client_secret: String,
61
62    /// Use Azure CLI (`az account get-access-token`) for authentication.
63    /// When true, `tenant_id`, `client_id`, and `client_secret` are not required.
64    /// Requires `az login` to have been run beforehand.
65    pub use_azure_cli: bool,
66
67    /// List of entity logical names to monitor (e.g., `["account", "contact"]`).
68    /// These are the singular logical names matching the platform's
69    /// `RetrieveEntityChangesRequest.EntityName` parameter.
70    pub entities: Vec<String>,
71
72    /// Override the entity set name (Web API plural form) for specific entities.
73    /// By default, entity set names are derived by appending 's' to the logical name.
74    /// Use this for entities with non-standard pluralization.
75    ///
76    /// Example: `{"activityparty": "activityparties"}`
77    pub entity_set_overrides: HashMap<String, String>,
78
79    /// Per-entity column selection. If an entity is not in this map,
80    /// all columns are retrieved (equivalent to `ColumnSet(true)` in the SDK).
81    pub entity_columns: HashMap<String, Vec<String>>,
82
83    /// Minimum adaptive polling interval in milliseconds (default: 500).
84    /// Matches the platform's `MinIntervalMs = 500`.
85    pub min_interval_ms: u64,
86
87    /// Maximum adaptive polling interval per entity in seconds (default: 30).
88    /// This is the single-entity base value, matching the platform's
89    /// `SingleEntityMaxIntervalMs / 1000`. At startup, the effective max is
90    /// scaled by `sqrt(entity_count)` (e.g., 1 entity → 30s, 5 → 67s, 10 → 95s).
91    pub max_interval_seconds: u64,
92
93    /// Dataverse Web API version (default: `v9.2`).
94    pub api_version: String,
95}
96
97/// Manual `Debug` implementation that redacts `client_secret` so it cannot
98/// leak through `tracing`, panic messages, or any `{:?}` formatting.
99impl std::fmt::Debug for DataverseSourceConfig {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("DataverseSourceConfig")
102            .field("environment_url", &self.environment_url)
103            .field("tenant_id", &self.tenant_id)
104            .field("client_id", &self.client_id)
105            .field("client_secret", &"[REDACTED]")
106            .field("use_azure_cli", &self.use_azure_cli)
107            .field("entities", &self.entities)
108            .field("entity_set_overrides", &self.entity_set_overrides)
109            .field("entity_columns", &self.entity_columns)
110            .field("min_interval_ms", &self.min_interval_ms)
111            .field("max_interval_seconds", &self.max_interval_seconds)
112            .field("api_version", &self.api_version)
113            .finish()
114    }
115}
116
117impl DataverseSourceConfig {
118    /// Validate the configuration, returning an error if required fields are missing.
119    pub fn validate(&self) -> Result<(), String> {
120        if self.environment_url.is_empty() {
121            return Err("environment_url is required".to_string());
122        }
123        if !self.use_azure_cli {
124            // Client credentials flow requires all three fields
125            if self.tenant_id.is_empty() {
126                return Err("tenant_id is required (or set use_azure_cli = true)".to_string());
127            }
128            if self.client_id.is_empty() {
129                return Err("client_id is required (or set use_azure_cli = true)".to_string());
130            }
131            if self.client_secret.is_empty() {
132                return Err("client_secret is required (or set use_azure_cli = true)".to_string());
133            }
134        }
135        if self.entities.is_empty() {
136            return Err("entities list must not be empty".to_string());
137        }
138        Ok(())
139    }
140
141    /// Validate config when an external identity provider handles authentication.
142    ///
143    /// Only checks that `environment_url` and `entities` are set — credential
144    /// fields (`tenant_id`, `client_id`, `client_secret`) are not required
145    /// because the identity provider supplies tokens directly.
146    pub fn validate_with_identity_provider(&self) -> Result<(), String> {
147        if self.environment_url.is_empty() {
148            return Err("environment_url is required".to_string());
149        }
150        if self.entities.is_empty() {
151            return Err("entities list must not be empty".to_string());
152        }
153        Ok(())
154    }
155
156    /// Get the entity set name (plural form) for a given entity logical name.
157    ///
158    /// First checks `entity_set_overrides`, then falls back to appending 's'.
159    /// This mirrors how the platform's `RetrieveEntityChangesRequest` uses
160    /// entity logical names but the Web API requires entity set names.
161    pub fn entity_set_name(&self, entity: &str) -> String {
162        if let Some(override_name) = self.entity_set_overrides.get(entity) {
163            override_name.clone()
164        } else {
165            format!("{entity}s")
166        }
167    }
168
169    /// Get the `$select` clause for a given entity, or `None` for all columns.
170    ///
171    /// Ensures that the primary key column (`{entity}id`) is always included
172    /// when a column list is configured, to keep change tracking and bootstrap
173    /// logic functional.
174    pub fn select_columns(&self, entity: &str) -> Option<String> {
175        self.entity_columns.get(entity).map(|cols| {
176            let primary_key = format!("{entity}id");
177            let has_primary_key = cols.iter().any(|c| c.eq_ignore_ascii_case(&primary_key));
178            if has_primary_key {
179                cols.join(",")
180            } else {
181                let mut all_columns = cols.clone();
182                all_columns.push(primary_key);
183                all_columns.join(",")
184            }
185        })
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_config_validation_success() {
195        let config = DataverseSourceConfig {
196            environment_url: "https://myorg.crm.dynamics.com".to_string(),
197            tenant_id: "tenant-1".to_string(),
198            client_id: "client-1".to_string(),
199            client_secret: "secret-1".to_string(),
200            use_azure_cli: false,
201            entities: vec!["account".to_string()],
202            entity_set_overrides: HashMap::new(),
203            entity_columns: HashMap::new(),
204            min_interval_ms: 500,
205            max_interval_seconds: 30,
206            api_version: "v9.2".to_string(),
207        };
208        assert!(config.validate().is_ok());
209    }
210
211    #[test]
212    fn test_config_validation_azure_cli_no_secret_needed() {
213        let config = DataverseSourceConfig {
214            environment_url: "https://myorg.crm.dynamics.com".to_string(),
215            tenant_id: String::new(),
216            client_id: String::new(),
217            client_secret: String::new(),
218            use_azure_cli: true,
219            entities: vec!["account".to_string()],
220            entity_set_overrides: HashMap::new(),
221            entity_columns: HashMap::new(),
222            min_interval_ms: 500,
223            max_interval_seconds: 30,
224            api_version: "v9.2".to_string(),
225        };
226        assert!(config.validate().is_ok());
227    }
228
229    #[test]
230    fn test_config_validation_empty_url() {
231        let config = DataverseSourceConfig {
232            environment_url: String::new(),
233            tenant_id: "t".to_string(),
234            client_id: "c".to_string(),
235            client_secret: "s".to_string(),
236            use_azure_cli: false,
237            entities: vec!["account".to_string()],
238            entity_set_overrides: HashMap::new(),
239            entity_columns: HashMap::new(),
240            min_interval_ms: 500,
241            max_interval_seconds: 30,
242            api_version: "v9.2".to_string(),
243        };
244        assert!(config.validate().is_err());
245    }
246
247    #[test]
248    fn test_config_validation_empty_entities() {
249        let config = DataverseSourceConfig {
250            environment_url: "https://test.crm.dynamics.com".to_string(),
251            tenant_id: "t".to_string(),
252            client_id: "c".to_string(),
253            client_secret: "s".to_string(),
254            use_azure_cli: false,
255            entities: vec![],
256            entity_set_overrides: HashMap::new(),
257            entity_columns: HashMap::new(),
258            min_interval_ms: 500,
259            max_interval_seconds: 30,
260            api_version: "v9.2".to_string(),
261        };
262        assert!(config.validate().is_err());
263    }
264
265    #[test]
266    fn test_entity_set_name_default() {
267        let config = DataverseSourceConfig {
268            environment_url: "https://test.crm.dynamics.com".to_string(),
269            tenant_id: "t".to_string(),
270            client_id: "c".to_string(),
271            client_secret: "s".to_string(),
272            use_azure_cli: false,
273            entities: vec!["account".to_string()],
274            entity_set_overrides: HashMap::new(),
275            entity_columns: HashMap::new(),
276            min_interval_ms: 500,
277            max_interval_seconds: 30,
278            api_version: "v9.2".to_string(),
279        };
280        assert_eq!(config.entity_set_name("account"), "accounts");
281        assert_eq!(config.entity_set_name("contact"), "contacts");
282    }
283
284    #[test]
285    fn test_entity_set_name_override() {
286        let mut overrides = HashMap::new();
287        overrides.insert("activityparty".to_string(), "activityparties".to_string());
288        let config = DataverseSourceConfig {
289            environment_url: "https://test.crm.dynamics.com".to_string(),
290            tenant_id: "t".to_string(),
291            client_id: "c".to_string(),
292            client_secret: "s".to_string(),
293            use_azure_cli: false,
294            entities: vec!["activityparty".to_string()],
295            entity_set_overrides: overrides,
296            entity_columns: HashMap::new(),
297            min_interval_ms: 500,
298            max_interval_seconds: 30,
299            api_version: "v9.2".to_string(),
300        };
301        assert_eq!(config.entity_set_name("activityparty"), "activityparties");
302    }
303
304    #[test]
305    fn test_select_columns() {
306        let mut cols = HashMap::new();
307        cols.insert(
308            "account".to_string(),
309            vec!["name".to_string(), "revenue".to_string()],
310        );
311        let config = DataverseSourceConfig {
312            environment_url: "https://test.crm.dynamics.com".to_string(),
313            tenant_id: "t".to_string(),
314            client_id: "c".to_string(),
315            client_secret: "s".to_string(),
316            use_azure_cli: false,
317            entities: vec!["account".to_string()],
318            entity_set_overrides: HashMap::new(),
319            entity_columns: cols,
320            min_interval_ms: 500,
321            max_interval_seconds: 30,
322            api_version: "v9.2".to_string(),
323        };
324        // Primary key "accountid" is auto-appended
325        assert_eq!(
326            config.select_columns("account"),
327            Some("name,revenue,accountid".to_string())
328        );
329        assert_eq!(config.select_columns("contact"), None);
330    }
331
332    #[test]
333    fn test_select_columns_appends_primary_key() {
334        let mut entity_columns = HashMap::new();
335        entity_columns.insert("account".to_string(), vec!["name".to_string()]);
336        let config = DataverseSourceConfig {
337            environment_url: "https://myorg.crm.dynamics.com".to_string(),
338            tenant_id: "t".to_string(),
339            client_id: "c".to_string(),
340            client_secret: "s".to_string(),
341            use_azure_cli: false,
342            entities: vec!["account".to_string()],
343            entity_set_overrides: HashMap::new(),
344            entity_columns,
345            min_interval_ms: 500,
346            max_interval_seconds: 30,
347            api_version: "v9.2".to_string(),
348        };
349        assert_eq!(
350            config.select_columns("account"),
351            Some("name,accountid".to_string())
352        );
353    }
354
355    #[test]
356    fn test_select_columns_does_not_duplicate_primary_key() {
357        let mut entity_columns = HashMap::new();
358        entity_columns.insert(
359            "account".to_string(),
360            vec!["name".to_string(), "accountid".to_string()],
361        );
362        let config = DataverseSourceConfig {
363            environment_url: "https://myorg.crm.dynamics.com".to_string(),
364            tenant_id: "t".to_string(),
365            client_id: "c".to_string(),
366            client_secret: "s".to_string(),
367            use_azure_cli: false,
368            entities: vec!["account".to_string()],
369            entity_set_overrides: HashMap::new(),
370            entity_columns,
371            min_interval_ms: 500,
372            max_interval_seconds: 30,
373            api_version: "v9.2".to_string(),
374        };
375        assert_eq!(
376            config.select_columns("account"),
377            Some("name,accountid".to_string())
378        );
379    }
380
381    #[test]
382    fn debug_redacts_client_secret() {
383        // Defence-in-depth: any `{:?}` formatting (tracing, panic messages,
384        // structured logs) must never expose the OAuth2 client_secret.
385        let config = DataverseSourceConfig {
386            environment_url: "https://myorg.crm.dynamics.com".to_string(),
387            tenant_id: "tenant-1".to_string(),
388            client_id: "client-1".to_string(),
389            client_secret: "super-secret-do-not-leak".to_string(),
390            use_azure_cli: false,
391            entities: vec!["account".to_string()],
392            entity_set_overrides: HashMap::new(),
393            entity_columns: HashMap::new(),
394            min_interval_ms: 500,
395            max_interval_seconds: 30,
396            api_version: "v9.2".to_string(),
397        };
398        let dbg = format!("{config:?}");
399        assert!(
400            !dbg.contains("super-secret-do-not-leak"),
401            "client_secret must not appear in Debug output: {dbg}"
402        );
403        assert!(dbg.contains("[REDACTED]"));
404        // Non-sensitive fields should still be visible for diagnostics.
405        assert!(dbg.contains("tenant-1"));
406        assert!(dbg.contains("client-1"));
407    }
408}