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 && 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
278fn ai_services_account_from_subdomain(url: &str) -> Option<String> {
280 url.strip_prefix("https://")
281 .and_then(|rest| rest.split('.').next())
282 .filter(|s| !s.is_empty())
283 .map(str::to_string)
284}
285
286fn datasource_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
287 let ds_type = value
288 .get("type")
289 .and_then(Value::as_str)
290 .unwrap_or_default();
291 let conn = value
292 .get("credentials")
293 .and_then(|c| c.get("connectionString"))
294 .and_then(Value::as_str)
295 .unwrap_or_default();
296 let scope = parse_resource_id(conn);
297 match ds_type {
298 "azureblob" | "adlsgen2" | "azurefile" | "azurefiles" | "azuretable" => {
299 edges.push(IdentityEdge::rbac(
300 Principal::SearchService,
301 scope.clone(),
302 scope
303 .clone()
304 .unwrap_or_else(|| "storage account (set ResourceId= in the connection string)".into()),
305 roles::STORAGE_BLOB_DATA_READER,
306 format!("data source '{name}' ({ds_type}) reads from storage"),
307 ));
308 }
309 "cosmosdb" => edges.push(IdentityEdge {
310 principal: Principal::SearchService,
311 scope: scope.clone(),
312 target: scope.unwrap_or_else(|| "Cosmos DB account".into()),
313 role_id: String::new(),
314 role_name: "Cosmos DB Built-in Data Reader (SQL role)".into(),
315 kind: EdgeKind::Informational,
316 reason: format!(
317 "data source '{name}' reads Cosmos DB — grant via `az cosmosdb sql role assignment create` \
318 (Cosmos data-plane roles are not ARM RBAC)"
319 ),
320 }),
321 "azuresql" => edges.push(IdentityEdge {
322 principal: Principal::SearchService,
323 scope: scope.clone(),
324 target: scope.unwrap_or_else(|| "Azure SQL database".into()),
325 role_id: String::new(),
326 role_name: "db_datareader (contained AAD user)".into(),
327 kind: EdgeKind::Informational,
328 reason: format!(
329 "data source '{name}' reads Azure SQL — CREATE USER [search-service-name] FROM EXTERNAL PROVIDER; \
330 ALTER ROLE db_datareader ADD MEMBER [...] (SQL AAD users are not ARM RBAC)"
331 ),
332 }),
333 _ => {}
334 }
335}
336
337pub fn parse_resource_id(conn: &str) -> Option<String> {
339 let start = conn.find("ResourceId=")? + "ResourceId=".len();
340 let rest = &conn[start..];
341 let end = rest.find(';').unwrap_or(rest.len());
342 let id = rest[..end].trim();
343 (id.starts_with("/subscriptions/") && !id.contains('<')).then(|| id.to_string())
344}
345
346fn dedup(edges: Vec<IdentityEdge>) -> Vec<IdentityEdge> {
347 let mut seen = std::collections::BTreeSet::new();
348 edges
349 .into_iter()
350 .filter(|e| {
351 seen.insert((
352 format!("{:?}", e.principal),
353 e.scope.clone().unwrap_or_else(|| e.target.clone()),
354 e.role_id.clone(),
355 e.role_name.clone(),
356 ))
357 })
358 .collect()
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::resources::traits::ResourceRef;
365 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
366 use serde_json::json;
367
368 fn ws_with(resources: &[(ResourceKind, &str, Value)]) -> (tempfile::TempDir, Workspace) {
369 let tmp = tempfile::tempdir().unwrap();
370 std::fs::write(
371 tmp.path().join(WORKSPACE_FILE),
372 "environments:\n dev:\n default: true\n search: { service: s }\n foundry: { account: f, project: p }\n",
373 )
374 .unwrap();
375 let pdir = tmp.path().join(PROJECTS_DIR).join("demo");
376 std::fs::create_dir_all(&pdir).unwrap();
377 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
378 let ws = Workspace::load(tmp.path()).unwrap();
379 {
380 let store = Store::new(ws.project("demo").unwrap(), "dev");
381 for (kind, name, value) in resources {
382 store.write(&ResourceRef::new(*kind, *name), value).unwrap();
383 }
384 }
385 let ws = Workspace::load(tmp.path()).unwrap();
386 (tmp, ws)
387 }
388
389 #[test]
390 fn blob_datasource_yields_storage_edge_with_scope() {
391 let (_tmp, ws) = ws_with(&[(
392 ResourceKind::DataSource,
393 "ds",
394 json!({
395 "name": "ds",
396 "type": "azureblob",
397 "credentials": {"connectionString": "ResourceId=/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct;"},
398 "container": {"name": "c"}
399 }),
400 )]);
401 let edges = identity_edges(&ws, "dev");
402 assert_eq!(edges.len(), 1);
403 let e = &edges[0];
404 assert_eq!(e.principal, Principal::SearchService);
405 assert_eq!(e.kind, EdgeKind::Rbac);
406 assert_eq!(e.role_name, "Storage Blob Data Reader");
407 assert_eq!(
408 e.scope.as_deref(),
409 Some(
410 "/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct"
411 )
412 );
413 }
414
415 #[test]
416 fn cosmos_and_sql_are_informational() {
417 let (_tmp, ws) = ws_with(&[
418 (
419 ResourceKind::DataSource,
420 "cds",
421 json!({"name": "cds", "type": "cosmosdb", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.DocumentDB/databaseAccounts/c;Database=d"}, "container": {"name": "x"}}),
422 ),
423 (
424 ResourceKind::DataSource,
425 "sds",
426 json!({"name": "sds", "type": "azuresql", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Sql/servers/sv;Database=d"}, "container": {"name": "t"}}),
427 ),
428 ]);
429 let edges = identity_edges(&ws, "dev");
430 assert_eq!(edges.len(), 2);
431 assert!(edges.iter().all(|e| e.kind == EdgeKind::Informational));
432 }
433
434 #[test]
435 fn kb_models_agent_grounding_and_vectorizers() {
436 let (_tmp, ws) = ws_with(&[
437 (
438 ResourceKind::KnowledgeBase,
439 "kb",
440 json!({"name": "kb", "models": [{"kind": "azureOpenAI"}], "knowledgeSources": [{"name": "ks"}]}),
441 ),
442 (
443 ResourceKind::Agent,
444 "agent",
445 json!({"name": "agent", "model": "m", "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb"}]}),
446 ),
447 (
448 ResourceKind::Index,
449 "idx",
450 json!({"name": "idx", "fields": [], "vectorSearch": {"vectorizers": [{"name": "v"}]}}),
451 ),
452 ]);
453 let edges = identity_edges(&ws, "dev");
454 let roles: Vec<&str> = edges.iter().map(|e| e.role_name.as_str()).collect();
455 assert!(roles.contains(&"Cognitive Services User"), "{roles:?}");
456 assert!(roles.contains(&"Search Index Data Reader"), "{roles:?}");
457 assert!(
458 roles.contains(&"Cognitive Services OpenAI User"),
459 "{roles:?}"
460 );
461 let grounding = edges
462 .iter()
463 .find(|e| e.role_name == "Search Index Data Reader")
464 .unwrap();
465 assert_eq!(grounding.principal, Principal::FoundryProject);
466 }
467
468 #[test]
469 fn skillset_identity_ai_services_yields_account_edge() {
470 let (_tmp, ws) = ws_with(&[(
471 ResourceKind::Skillset,
472 "ss",
473 json!({"name": "ss", "skills": [], "cognitiveServices": {
474 "@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity",
475 "subdomainUrl": "https://myaisvc.cognitiveservices.azure.com/"
476 }}),
477 )]);
478 let edges = identity_edges(&ws, "dev");
479 let edge = edges
480 .iter()
481 .find(|e| e.target == "AI services account 'myaisvc'")
482 .expect("ai services edge");
483 assert_eq!(edge.kind, EdgeKind::Rbac);
484 assert_eq!(edge.role_name, "Cognitive Services User");
485 assert_eq!(edge.principal, Principal::SearchService);
486 }
487
488 #[test]
489 fn skillset_webapi_edges_are_informational() {
490 let (_tmp, ws) = ws_with(&[(
491 ResourceKind::Skillset,
492 "ss",
493 json!({"name": "ss", "skills": [
494 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
495 "uri": "https://fn.azurewebsites.net/api/enrich",
496 "authResourceId": "api://12345"},
497 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
498 "uri": "https://fn2.azurewebsites.net/api/other"}
499 ]}),
500 )]);
501 let edges = identity_edges(&ws, "dev");
502 let webapi: Vec<_> = edges
503 .iter()
504 .filter(|e| e.target.starts_with("custom Web API"))
505 .collect();
506 assert_eq!(webapi.len(), 2);
507 assert!(webapi.iter().all(|e| e.kind == EdgeKind::Informational));
508 assert!(
509 webapi
510 .iter()
511 .any(|e| e.reason.contains("authResourceId: api://12345"))
512 );
513 assert!(webapi.iter().any(|e| e.reason.contains("without AAD auth")));
514 }
515
516 #[test]
517 fn placeholder_resource_ids_are_not_scopes() {
518 assert_eq!(
519 parse_resource_id("ResourceId=/subscriptions/<subscription-id>/...;"),
520 None
521 );
522 assert_eq!(parse_resource_id("AccountKey=zzz"), None);
523 assert_eq!(
524 parse_resource_id("ResourceId=/subscriptions/a/resourceGroups/b;Database=d").as_deref(),
525 Some("/subscriptions/a/resourceGroups/b")
526 );
527 }
528
529 #[test]
530 fn duplicate_edges_dedup() {
531 let ds = json!({"name": "ds", "type": "azureblob", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/a;"}, "container": {"name": "c"}});
532 let mut ds2 = ds.clone();
533 ds2["name"] = json!("ds2");
534 let (_tmp, ws) = ws_with(&[
535 (ResourceKind::DataSource, "ds", ds),
536 (ResourceKind::DataSource, "ds2", ds2),
537 ]);
538 assert_eq!(
539 identity_edges(&ws, "dev").len(),
540 1,
541 "same scope+role dedups"
542 );
543 }
544}