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///
38/// # Optional Configuration
39///
40/// - `entity_set_overrides`: Override computed entity set names for specific entities
41/// - `min_interval_ms`: Minimum adaptive polling interval (default: 500)
42/// - `max_interval_seconds`: Maximum adaptive polling interval (default: 30)
43/// - `api_version`: Dataverse Web API version (default: `v9.2`)
44#[derive(Clone)]
45pub struct DataverseSourceConfig {
46    /// Dataverse environment URL (e.g., `https://myorg.crm.dynamics.com`).
47    pub environment_url: String,
48
49    /// Azure AD / Microsoft Entra ID tenant ID for OAuth2 authentication.
50    /// Required for the built-in client credentials flow. Not required when an
51    /// identity provider is injected by the host.
52    pub tenant_id: String,
53
54    /// Azure AD application (client) ID.
55    /// Required for the built-in client credentials flow. Not required when an
56    /// identity provider is injected by the host.
57    pub client_id: String,
58
59    /// Azure AD client secret for OAuth2 client credentials flow.
60    /// Required for the built-in client credentials flow. Not required when an
61    /// identity provider is injected by the host.
62    pub client_secret: String,
63
64    /// List of entity logical names to monitor (e.g., `["account", "contact"]`).
65    /// These are the singular logical names matching the platform's
66    /// `RetrieveEntityChangesRequest.EntityName` parameter.
67    pub entities: Vec<String>,
68
69    /// Override the entity set name (Web API plural form) for specific entities.
70    /// By default, entity set names are derived by appending 's' to the logical name.
71    /// Use this for entities with non-standard pluralization.
72    ///
73    /// Example: `{"activityparty": "activityparties"}`
74    pub entity_set_overrides: HashMap<String, String>,
75
76    /// Per-entity column selection. If an entity is not in this map,
77    /// all columns are retrieved (equivalent to `ColumnSet(true)` in the SDK).
78    pub entity_columns: HashMap<String, Vec<String>>,
79
80    /// Minimum adaptive polling interval in milliseconds (default: 500).
81    /// Matches the platform's `MinIntervalMs = 500`.
82    pub min_interval_ms: u64,
83
84    /// Maximum adaptive polling interval per entity in seconds (default: 30).
85    /// This is the single-entity base value, matching the platform's
86    /// `SingleEntityMaxIntervalMs / 1000`. At startup, the effective max is
87    /// scaled by `sqrt(entity_count)` (e.g., 1 entity → 30s, 5 → 67s, 10 → 95s).
88    pub max_interval_seconds: u64,
89
90    /// Dataverse Web API version (default: `v9.2`).
91    pub api_version: String,
92}
93
94/// Manual `Debug` implementation that redacts `client_secret` so it cannot
95/// leak through `tracing`, panic messages, or any `{:?}` formatting.
96impl std::fmt::Debug for DataverseSourceConfig {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("DataverseSourceConfig")
99            .field("environment_url", &self.environment_url)
100            .field("tenant_id", &self.tenant_id)
101            .field("client_id", &self.client_id)
102            .field("client_secret", &"[REDACTED]")
103            .field("entities", &self.entities)
104            .field("entity_set_overrides", &self.entity_set_overrides)
105            .field("entity_columns", &self.entity_columns)
106            .field("min_interval_ms", &self.min_interval_ms)
107            .field("max_interval_seconds", &self.max_interval_seconds)
108            .field("api_version", &self.api_version)
109            .finish()
110    }
111}
112
113impl DataverseSourceConfig {
114    /// Validate the configuration for the built-in client credentials flow.
115    ///
116    /// For Azure CLI / developer tools authentication, configure an Azure
117    /// identity provider (`kind: azure`, `authMethod: developer_tools`) and
118    /// reference it from the source — then [`validate_with_identity_provider`]
119    /// applies instead.
120    pub fn validate(&self) -> Result<(), String> {
121        if self.environment_url.is_empty() {
122            return Err("environment_url is required".to_string());
123        }
124        if self.tenant_id.is_empty() {
125            return Err("tenant_id is required (or configure an identity provider)".to_string());
126        }
127        if self.client_id.is_empty() {
128            return Err("client_id is required (or configure an identity provider)".to_string());
129        }
130        if self.client_secret.is_empty() {
131            return Err(
132                "client_secret is required (or configure an identity provider)".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            entities: vec!["account".to_string()],
201            entity_set_overrides: HashMap::new(),
202            entity_columns: HashMap::new(),
203            min_interval_ms: 500,
204            max_interval_seconds: 30,
205            api_version: "v9.2".to_string(),
206        };
207        assert!(config.validate().is_ok());
208    }
209
210    #[test]
211    fn test_validate_with_identity_provider_skips_credentials() {
212        let config = DataverseSourceConfig {
213            environment_url: "https://myorg.crm.dynamics.com".to_string(),
214            tenant_id: String::new(),
215            client_id: String::new(),
216            client_secret: String::new(),
217            entities: vec!["account".to_string()],
218            entity_set_overrides: HashMap::new(),
219            entity_columns: HashMap::new(),
220            min_interval_ms: 500,
221            max_interval_seconds: 30,
222            api_version: "v9.2".to_string(),
223        };
224        assert!(config.validate_with_identity_provider().is_ok());
225    }
226
227    #[test]
228    fn test_config_validation_empty_url() {
229        let config = DataverseSourceConfig {
230            environment_url: String::new(),
231            tenant_id: "t".to_string(),
232            client_id: "c".to_string(),
233            client_secret: "s".to_string(),
234            entities: vec!["account".to_string()],
235            entity_set_overrides: HashMap::new(),
236            entity_columns: HashMap::new(),
237            min_interval_ms: 500,
238            max_interval_seconds: 30,
239            api_version: "v9.2".to_string(),
240        };
241        assert!(config.validate().is_err());
242    }
243
244    #[test]
245    fn test_config_validation_empty_entities() {
246        let config = DataverseSourceConfig {
247            environment_url: "https://test.crm.dynamics.com".to_string(),
248            tenant_id: "t".to_string(),
249            client_id: "c".to_string(),
250            client_secret: "s".to_string(),
251            entities: vec![],
252            entity_set_overrides: HashMap::new(),
253            entity_columns: HashMap::new(),
254            min_interval_ms: 500,
255            max_interval_seconds: 30,
256            api_version: "v9.2".to_string(),
257        };
258        assert!(config.validate().is_err());
259    }
260
261    #[test]
262    fn test_entity_set_name_default() {
263        let config = DataverseSourceConfig {
264            environment_url: "https://test.crm.dynamics.com".to_string(),
265            tenant_id: "t".to_string(),
266            client_id: "c".to_string(),
267            client_secret: "s".to_string(),
268            entities: vec!["account".to_string()],
269            entity_set_overrides: HashMap::new(),
270            entity_columns: HashMap::new(),
271            min_interval_ms: 500,
272            max_interval_seconds: 30,
273            api_version: "v9.2".to_string(),
274        };
275        assert_eq!(config.entity_set_name("account"), "accounts");
276        assert_eq!(config.entity_set_name("contact"), "contacts");
277    }
278
279    #[test]
280    fn test_entity_set_name_override() {
281        let mut overrides = HashMap::new();
282        overrides.insert("activityparty".to_string(), "activityparties".to_string());
283        let config = DataverseSourceConfig {
284            environment_url: "https://test.crm.dynamics.com".to_string(),
285            tenant_id: "t".to_string(),
286            client_id: "c".to_string(),
287            client_secret: "s".to_string(),
288            entities: vec!["activityparty".to_string()],
289            entity_set_overrides: overrides,
290            entity_columns: HashMap::new(),
291            min_interval_ms: 500,
292            max_interval_seconds: 30,
293            api_version: "v9.2".to_string(),
294        };
295        assert_eq!(config.entity_set_name("activityparty"), "activityparties");
296    }
297
298    #[test]
299    fn test_select_columns() {
300        let mut cols = HashMap::new();
301        cols.insert(
302            "account".to_string(),
303            vec!["name".to_string(), "revenue".to_string()],
304        );
305        let config = DataverseSourceConfig {
306            environment_url: "https://test.crm.dynamics.com".to_string(),
307            tenant_id: "t".to_string(),
308            client_id: "c".to_string(),
309            client_secret: "s".to_string(),
310            entities: vec!["account".to_string()],
311            entity_set_overrides: HashMap::new(),
312            entity_columns: cols,
313            min_interval_ms: 500,
314            max_interval_seconds: 30,
315            api_version: "v9.2".to_string(),
316        };
317        // Primary key "accountid" is auto-appended
318        assert_eq!(
319            config.select_columns("account"),
320            Some("name,revenue,accountid".to_string())
321        );
322        assert_eq!(config.select_columns("contact"), None);
323    }
324
325    #[test]
326    fn test_select_columns_appends_primary_key() {
327        let mut entity_columns = HashMap::new();
328        entity_columns.insert("account".to_string(), vec!["name".to_string()]);
329        let config = DataverseSourceConfig {
330            environment_url: "https://myorg.crm.dynamics.com".to_string(),
331            tenant_id: "t".to_string(),
332            client_id: "c".to_string(),
333            client_secret: "s".to_string(),
334            entities: vec!["account".to_string()],
335            entity_set_overrides: HashMap::new(),
336            entity_columns,
337            min_interval_ms: 500,
338            max_interval_seconds: 30,
339            api_version: "v9.2".to_string(),
340        };
341        assert_eq!(
342            config.select_columns("account"),
343            Some("name,accountid".to_string())
344        );
345    }
346
347    #[test]
348    fn test_select_columns_does_not_duplicate_primary_key() {
349        let mut entity_columns = HashMap::new();
350        entity_columns.insert(
351            "account".to_string(),
352            vec!["name".to_string(), "accountid".to_string()],
353        );
354        let config = DataverseSourceConfig {
355            environment_url: "https://myorg.crm.dynamics.com".to_string(),
356            tenant_id: "t".to_string(),
357            client_id: "c".to_string(),
358            client_secret: "s".to_string(),
359            entities: vec!["account".to_string()],
360            entity_set_overrides: HashMap::new(),
361            entity_columns,
362            min_interval_ms: 500,
363            max_interval_seconds: 30,
364            api_version: "v9.2".to_string(),
365        };
366        assert_eq!(
367            config.select_columns("account"),
368            Some("name,accountid".to_string())
369        );
370    }
371
372    #[test]
373    fn debug_redacts_client_secret() {
374        // Defence-in-depth: any `{:?}` formatting (tracing, panic messages,
375        // structured logs) must never expose the OAuth2 client_secret.
376        let config = DataverseSourceConfig {
377            environment_url: "https://myorg.crm.dynamics.com".to_string(),
378            tenant_id: "tenant-1".to_string(),
379            client_id: "client-1".to_string(),
380            client_secret: "super-secret-do-not-leak".to_string(),
381            entities: vec!["account".to_string()],
382            entity_set_overrides: HashMap::new(),
383            entity_columns: HashMap::new(),
384            min_interval_ms: 500,
385            max_interval_seconds: 30,
386            api_version: "v9.2".to_string(),
387        };
388        let dbg = format!("{config:?}");
389        assert!(
390            !dbg.contains("super-secret-do-not-leak"),
391            "client_secret must not appear in Debug output: {dbg}"
392        );
393        assert!(dbg.contains("[REDACTED]"));
394        // Non-sensitive fields should still be visible for diagnostics.
395        assert!(dbg.contains("tenant-1"));
396        assert!(dbg.contains("client-1"));
397    }
398}