1use serde_json::Value;
9
10use crate::registry;
11use crate::resources::traits::ResourceKind;
12use crate::store::Store;
13use crate::workspace::Workspace;
14
15pub mod roles {
17 pub const STORAGE_BLOB_DATA_READER: (&str, &str) = (
18 "ba92f5b4-2d11-453d-a403-e96b0029c9fe",
19 "Storage Blob Data Reader",
20 );
21 pub const COGNITIVE_SERVICES_OPENAI_USER: (&str, &str) = (
22 "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd",
23 "Cognitive Services OpenAI User",
24 );
25 pub const COGNITIVE_SERVICES_USER: (&str, &str) = (
26 "a97b65f3-24c7-4388-baec-2e87135dc908",
27 "Cognitive Services User",
28 );
29 pub const SEARCH_INDEX_DATA_READER: (&str, &str) = (
30 "1407120a-92aa-4202-b7e9-c0e197c71c8f",
31 "Search Index Data Reader",
32 );
33 pub const KEY_VAULT_SECRETS_USER: (&str, &str) = (
34 "4633458b-17de-408a-b874-0445c86b69e6",
35 "Key Vault Secrets User",
36 );
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Principal {
43 SearchService,
44 FoundryProject,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum EdgeKind {
51 Rbac,
53 Informational,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct IdentityEdge {
60 pub principal: Principal,
61 pub scope: Option<String>,
63 pub target: String,
64 pub role_id: String,
65 pub role_name: String,
66 pub kind: EdgeKind,
67 pub reason: String,
68}
69
70impl IdentityEdge {
71 fn rbac(
72 principal: Principal,
73 scope: Option<String>,
74 target: impl Into<String>,
75 role: (&str, &str),
76 reason: impl Into<String>,
77 ) -> Self {
78 IdentityEdge {
79 principal,
80 scope,
81 target: target.into(),
82 role_id: role.0.to_string(),
83 role_name: role.1.to_string(),
84 kind: EdgeKind::Rbac,
85 reason: reason.into(),
86 }
87 }
88}
89
90pub fn identity_edges(ws: &Workspace, env: &str) -> Vec<IdentityEdge> {
93 let mut edges: Vec<IdentityEdge> = Vec::new();
94 for project in &ws.projects {
95 let store = Store::new(project, env);
96 let Ok(files) = store.list() else { continue };
97 for (r, _) in files {
98 let Ok(value) = store.read(&r) else { continue };
99 match r.kind {
100 ResourceKind::DataSource => datasource_edges(&r.name, &value, &mut edges),
101 ResourceKind::KnowledgeBase
102 if value
103 .get("models")
104 .is_some_and(|m| !m.as_array().is_none_or(|a| a.is_empty()))
105 || value.get("answerInstructions").is_some() =>
106 {
107 edges.push(IdentityEdge::rbac(
108 Principal::SearchService,
109 None,
110 "Foundry account (model access)",
111 roles::COGNITIVE_SERVICES_USER,
112 format!(
113 "knowledge base '{}' uses a model for retrieval/synthesis",
114 r.name
115 ),
116 ));
117 }
118 ResourceKind::Skillset => skillset_edges(&r.name, &value, &mut edges),
119 ResourceKind::Index => {
120 let has_vectorizer = value
121 .get("vectorSearch")
122 .and_then(|v| v.get("vectorizers"))
123 .and_then(Value::as_array)
124 .is_some_and(|a| !a.is_empty());
125 if has_vectorizer {
126 edges.push(IdentityEdge::rbac(
127 Principal::SearchService,
128 None,
129 "Foundry account (model access)",
130 roles::COGNITIVE_SERVICES_OPENAI_USER,
131 format!("index '{}' has vectorizers", r.name),
132 ));
133 }
134 }
135 ResourceKind::Agent => {
136 for (kind, name) in registry::extract_references(r.kind, &value) {
137 if kind == ResourceKind::KnowledgeBase {
138 edges.push(IdentityEdge::rbac(
139 Principal::FoundryProject,
140 None,
141 "Search service (knowledge base retrieval)",
142 roles::SEARCH_INDEX_DATA_READER,
143 format!("agent '{}' grounds on knowledge base '{name}'", r.name),
144 ));
145 }
146 }
147 }
148 _ => {}
149 }
150 if let Some(uri) = value
152 .get("encryptionKey")
153 .and_then(|k| k.get("keyVaultUri"))
154 .and_then(Value::as_str)
155 {
156 edges.push(IdentityEdge::rbac(
157 Principal::SearchService,
158 None,
159 format!("Key Vault {uri}"),
160 roles::KEY_VAULT_SECRETS_USER,
161 format!(
162 "{} '{}' uses customer-managed encryption",
163 r.kind.display_name(),
164 r.name
165 ),
166 ));
167 }
168 }
169 }
170 dedup(edges)
171}
172
173fn skillset_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
178 if value.to_string().contains("AzureOpenAI") {
179 edges.push(IdentityEdge::rbac(
180 Principal::SearchService,
181 None,
182 "Foundry account (model access)",
183 roles::COGNITIVE_SERVICES_OPENAI_USER,
184 format!("skillset '{name}' calls Azure OpenAI"),
185 ));
186 }
187 if let Some(skills) = value.get("skills").and_then(Value::as_array) {
191 for skill in skills {
192 let is_webapi = skill
193 .get("@odata.type")
194 .and_then(Value::as_str)
195 .is_some_and(|t| t.ends_with("WebApiSkill"));
196 if !is_webapi {
197 continue;
198 }
199 let uri = skill.get("uri").and_then(Value::as_str).unwrap_or("?");
200 if let Some(auth) = skill.get("authResourceId").and_then(Value::as_str) {
201 if !auth.trim().is_empty() {
202 edges.push(IdentityEdge {
203 principal: Principal::SearchService,
204 scope: None,
205 target: format!("custom Web API {uri}"),
206 role_id: String::new(),
207 role_name: "app authorization (AAD)".into(),
208 kind: EdgeKind::Informational,
209 reason: format!(
210 "skillset '{name}' calls a custom Web API with AAD auth \
211 (authResourceId: {auth}) — the search identity must be \
212 assigned/permitted on that app registration"
213 ),
214 });
215 }
216 } else {
217 edges.push(IdentityEdge {
218 principal: Principal::SearchService,
219 scope: None,
220 target: format!("custom Web API {uri}"),
221 role_id: String::new(),
222 role_name: "endpoint authorization".into(),
223 kind: EdgeKind::Informational,
224 reason: format!(
225 "skillset '{name}' calls a custom Web API without AAD auth \
226 (no authResourceId) — the endpoint must accept the call \
227 (function key in httpHeaders, or make it identity-based \
228 with authResourceId)"
229 ),
230 });
231 }
232 }
233 }
234 let cs = value.get("cognitiveServices");
235 let identity_based = cs
236 .and_then(|c| c.get("@odata.type"))
237 .and_then(Value::as_str)
238 .is_some_and(|t| t.ends_with("AIServicesByIdentity"));
239 if identity_based {
240 if let Some(account) = cs
241 .and_then(|c| c.get("subdomainUrl"))
242 .and_then(Value::as_str)
243 .and_then(ai_services_account_from_subdomain)
244 {
245 edges.push(IdentityEdge::rbac(
246 Principal::SearchService,
247 None,
248 format!("AI services account '{account}'"),
249 roles::COGNITIVE_SERVICES_USER,
250 format!("skillset '{name}' uses identity-based AI services enrichment"),
251 ));
252 }
253 }
254}
255
256fn ai_services_account_from_subdomain(url: &str) -> Option<String> {
258 url.strip_prefix("https://")
259 .and_then(|rest| rest.split('.').next())
260 .filter(|s| !s.is_empty())
261 .map(str::to_string)
262}
263
264fn datasource_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
265 let ds_type = value
266 .get("type")
267 .and_then(Value::as_str)
268 .unwrap_or_default();
269 let conn = value
270 .get("credentials")
271 .and_then(|c| c.get("connectionString"))
272 .and_then(Value::as_str)
273 .unwrap_or_default();
274 let scope = parse_resource_id(conn);
275 match ds_type {
276 "azureblob" | "adlsgen2" | "azurefile" | "azurefiles" | "azuretable" => {
277 edges.push(IdentityEdge::rbac(
278 Principal::SearchService,
279 scope.clone(),
280 scope
281 .clone()
282 .unwrap_or_else(|| "storage account (set ResourceId= in the connection string)".into()),
283 roles::STORAGE_BLOB_DATA_READER,
284 format!("data source '{name}' ({ds_type}) reads from storage"),
285 ));
286 }
287 "cosmosdb" => edges.push(IdentityEdge {
288 principal: Principal::SearchService,
289 scope: scope.clone(),
290 target: scope.unwrap_or_else(|| "Cosmos DB account".into()),
291 role_id: String::new(),
292 role_name: "Cosmos DB Built-in Data Reader (SQL role)".into(),
293 kind: EdgeKind::Informational,
294 reason: format!(
295 "data source '{name}' reads Cosmos DB — grant via `az cosmosdb sql role assignment create` \
296 (Cosmos data-plane roles are not ARM RBAC)"
297 ),
298 }),
299 "azuresql" => edges.push(IdentityEdge {
300 principal: Principal::SearchService,
301 scope: scope.clone(),
302 target: scope.unwrap_or_else(|| "Azure SQL database".into()),
303 role_id: String::new(),
304 role_name: "db_datareader (contained AAD user)".into(),
305 kind: EdgeKind::Informational,
306 reason: format!(
307 "data source '{name}' reads Azure SQL — CREATE USER [search-service-name] FROM EXTERNAL PROVIDER; \
308 ALTER ROLE db_datareader ADD MEMBER [...] (SQL AAD users are not ARM RBAC)"
309 ),
310 }),
311 _ => {}
312 }
313}
314
315pub fn parse_resource_id(conn: &str) -> Option<String> {
317 let start = conn.find("ResourceId=")? + "ResourceId=".len();
318 let rest = &conn[start..];
319 let end = rest.find(';').unwrap_or(rest.len());
320 let id = rest[..end].trim();
321 (id.starts_with("/subscriptions/") && !id.contains('<')).then(|| id.to_string())
322}
323
324fn dedup(edges: Vec<IdentityEdge>) -> Vec<IdentityEdge> {
325 let mut seen = std::collections::BTreeSet::new();
326 edges
327 .into_iter()
328 .filter(|e| {
329 seen.insert((
330 format!("{:?}", e.principal),
331 e.scope.clone().unwrap_or_else(|| e.target.clone()),
332 e.role_id.clone(),
333 e.role_name.clone(),
334 ))
335 })
336 .collect()
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::resources::traits::ResourceRef;
343 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
344 use serde_json::json;
345
346 fn ws_with(resources: &[(ResourceKind, &str, Value)]) -> (tempfile::TempDir, Workspace) {
347 let tmp = tempfile::tempdir().unwrap();
348 std::fs::write(
349 tmp.path().join(WORKSPACE_FILE),
350 "environments:\n dev:\n default: true\n search: { service: s }\n foundry: { account: f, project: p }\n",
351 )
352 .unwrap();
353 let pdir = tmp.path().join(PROJECTS_DIR).join("demo");
354 std::fs::create_dir_all(&pdir).unwrap();
355 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
356 let ws = Workspace::load(tmp.path()).unwrap();
357 {
358 let store = Store::new(ws.project("demo").unwrap(), "dev");
359 for (kind, name, value) in resources {
360 store.write(&ResourceRef::new(*kind, *name), value).unwrap();
361 }
362 }
363 let ws = Workspace::load(tmp.path()).unwrap();
364 (tmp, ws)
365 }
366
367 #[test]
368 fn blob_datasource_yields_storage_edge_with_scope() {
369 let (_tmp, ws) = ws_with(&[(
370 ResourceKind::DataSource,
371 "ds",
372 json!({
373 "name": "ds",
374 "type": "azureblob",
375 "credentials": {"connectionString": "ResourceId=/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct;"},
376 "container": {"name": "c"}
377 }),
378 )]);
379 let edges = identity_edges(&ws, "dev");
380 assert_eq!(edges.len(), 1);
381 let e = &edges[0];
382 assert_eq!(e.principal, Principal::SearchService);
383 assert_eq!(e.kind, EdgeKind::Rbac);
384 assert_eq!(e.role_name, "Storage Blob Data Reader");
385 assert_eq!(
386 e.scope.as_deref(),
387 Some(
388 "/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct"
389 )
390 );
391 }
392
393 #[test]
394 fn cosmos_and_sql_are_informational() {
395 let (_tmp, ws) = ws_with(&[
396 (
397 ResourceKind::DataSource,
398 "cds",
399 json!({"name": "cds", "type": "cosmosdb", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.DocumentDB/databaseAccounts/c;Database=d"}, "container": {"name": "x"}}),
400 ),
401 (
402 ResourceKind::DataSource,
403 "sds",
404 json!({"name": "sds", "type": "azuresql", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Sql/servers/sv;Database=d"}, "container": {"name": "t"}}),
405 ),
406 ]);
407 let edges = identity_edges(&ws, "dev");
408 assert_eq!(edges.len(), 2);
409 assert!(edges.iter().all(|e| e.kind == EdgeKind::Informational));
410 }
411
412 #[test]
413 fn kb_models_agent_grounding_and_vectorizers() {
414 let (_tmp, ws) = ws_with(&[
415 (
416 ResourceKind::KnowledgeBase,
417 "kb",
418 json!({"name": "kb", "models": [{"kind": "azureOpenAI"}], "knowledgeSources": [{"name": "ks"}]}),
419 ),
420 (
421 ResourceKind::Agent,
422 "agent",
423 json!({"name": "agent", "model": "m", "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb"}]}),
424 ),
425 (
426 ResourceKind::Index,
427 "idx",
428 json!({"name": "idx", "fields": [], "vectorSearch": {"vectorizers": [{"name": "v"}]}}),
429 ),
430 ]);
431 let edges = identity_edges(&ws, "dev");
432 let roles: Vec<&str> = edges.iter().map(|e| e.role_name.as_str()).collect();
433 assert!(roles.contains(&"Cognitive Services User"), "{roles:?}");
434 assert!(roles.contains(&"Search Index Data Reader"), "{roles:?}");
435 assert!(
436 roles.contains(&"Cognitive Services OpenAI User"),
437 "{roles:?}"
438 );
439 let grounding = edges
440 .iter()
441 .find(|e| e.role_name == "Search Index Data Reader")
442 .unwrap();
443 assert_eq!(grounding.principal, Principal::FoundryProject);
444 }
445
446 #[test]
447 fn skillset_identity_ai_services_yields_account_edge() {
448 let (_tmp, ws) = ws_with(&[(
449 ResourceKind::Skillset,
450 "ss",
451 json!({"name": "ss", "skills": [], "cognitiveServices": {
452 "@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity",
453 "subdomainUrl": "https://myaisvc.cognitiveservices.azure.com/"
454 }}),
455 )]);
456 let edges = identity_edges(&ws, "dev");
457 let edge = edges
458 .iter()
459 .find(|e| e.target == "AI services account 'myaisvc'")
460 .expect("ai services edge");
461 assert_eq!(edge.kind, EdgeKind::Rbac);
462 assert_eq!(edge.role_name, "Cognitive Services User");
463 assert_eq!(edge.principal, Principal::SearchService);
464 }
465
466 #[test]
467 fn skillset_webapi_edges_are_informational() {
468 let (_tmp, ws) = ws_with(&[(
469 ResourceKind::Skillset,
470 "ss",
471 json!({"name": "ss", "skills": [
472 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
473 "uri": "https://fn.azurewebsites.net/api/enrich",
474 "authResourceId": "api://12345"},
475 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
476 "uri": "https://fn2.azurewebsites.net/api/other"}
477 ]}),
478 )]);
479 let edges = identity_edges(&ws, "dev");
480 let webapi: Vec<_> = edges
481 .iter()
482 .filter(|e| e.target.starts_with("custom Web API"))
483 .collect();
484 assert_eq!(webapi.len(), 2);
485 assert!(webapi.iter().all(|e| e.kind == EdgeKind::Informational));
486 assert!(
487 webapi
488 .iter()
489 .any(|e| e.reason.contains("authResourceId: api://12345"))
490 );
491 assert!(webapi.iter().any(|e| e.reason.contains("without AAD auth")));
492 }
493
494 #[test]
495 fn placeholder_resource_ids_are_not_scopes() {
496 assert_eq!(
497 parse_resource_id("ResourceId=/subscriptions/<subscription-id>/...;"),
498 None
499 );
500 assert_eq!(parse_resource_id("AccountKey=zzz"), None);
501 assert_eq!(
502 parse_resource_id("ResourceId=/subscriptions/a/resourceGroups/b;Database=d").as_deref(),
503 Some("/subscriptions/a/resourceGroups/b")
504 );
505 }
506
507 #[test]
508 fn duplicate_edges_dedup() {
509 let ds = json!({"name": "ds", "type": "azureblob", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/a;"}, "container": {"name": "c"}});
510 let mut ds2 = ds.clone();
511 ds2["name"] = json!("ds2");
512 let (_tmp, ws) = ws_with(&[
513 (ResourceKind::DataSource, "ds", ds),
514 (ResourceKind::DataSource, "ds2", ds2),
515 ]);
516 assert_eq!(
517 identity_edges(&ws, "dev").len(),
518 1,
519 "same scope+role dedups"
520 );
521 }
522}