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