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) -> Vec<IdentityEdge> {
92 let mut edges: Vec<IdentityEdge> = Vec::new();
93 for project in &ws.projects {
94 let store = Store::new(project);
95 let Ok(files) = store.list() else { continue };
96 for (r, _) in files {
97 let Ok(value) = store.read(&r) else { continue };
98 match r.kind {
99 ResourceKind::DataSource => datasource_edges(&r.name, &value, &mut edges),
100 ResourceKind::KnowledgeBase
101 if value
102 .get("models")
103 .is_some_and(|m| !m.as_array().is_none_or(|a| a.is_empty()))
104 || value.get("answerInstructions").is_some() =>
105 {
106 edges.push(IdentityEdge::rbac(
107 Principal::SearchService,
108 None,
109 "Foundry account (model access)",
110 roles::COGNITIVE_SERVICES_USER,
111 format!(
112 "knowledge base '{}' uses a model for retrieval/synthesis",
113 r.name
114 ),
115 ));
116 }
117 ResourceKind::Skillset if value.to_string().contains("AzureOpenAI") => {
118 edges.push(IdentityEdge::rbac(
119 Principal::SearchService,
120 None,
121 "Foundry account (model access)",
122 roles::COGNITIVE_SERVICES_OPENAI_USER,
123 format!("skillset '{}' calls Azure OpenAI", r.name),
124 ));
125 }
126 ResourceKind::Index => {
127 let has_vectorizer = value
128 .get("vectorSearch")
129 .and_then(|v| v.get("vectorizers"))
130 .and_then(Value::as_array)
131 .is_some_and(|a| !a.is_empty());
132 if has_vectorizer {
133 edges.push(IdentityEdge::rbac(
134 Principal::SearchService,
135 None,
136 "Foundry account (model access)",
137 roles::COGNITIVE_SERVICES_OPENAI_USER,
138 format!("index '{}' has vectorizers", r.name),
139 ));
140 }
141 }
142 ResourceKind::Agent => {
143 for (kind, name) in registry::extract_references(r.kind, &value) {
144 if kind == ResourceKind::KnowledgeBase {
145 edges.push(IdentityEdge::rbac(
146 Principal::FoundryProject,
147 None,
148 "Search service (knowledge base retrieval)",
149 roles::SEARCH_INDEX_DATA_READER,
150 format!("agent '{}' grounds on knowledge base '{name}'", r.name),
151 ));
152 }
153 }
154 }
155 _ => {}
156 }
157 if let Some(uri) = value
159 .get("encryptionKey")
160 .and_then(|k| k.get("keyVaultUri"))
161 .and_then(Value::as_str)
162 {
163 edges.push(IdentityEdge::rbac(
164 Principal::SearchService,
165 None,
166 format!("Key Vault {uri}"),
167 roles::KEY_VAULT_SECRETS_USER,
168 format!(
169 "{} '{}' uses customer-managed encryption",
170 r.kind.display_name(),
171 r.name
172 ),
173 ));
174 }
175 }
176 }
177 dedup(edges)
178}
179
180fn datasource_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
181 let ds_type = value
182 .get("type")
183 .and_then(Value::as_str)
184 .unwrap_or_default();
185 let conn = value
186 .get("credentials")
187 .and_then(|c| c.get("connectionString"))
188 .and_then(Value::as_str)
189 .unwrap_or_default();
190 let scope = parse_resource_id(conn);
191 match ds_type {
192 "azureblob" | "adlsgen2" | "azurefile" | "azurefiles" | "azuretable" => {
193 edges.push(IdentityEdge::rbac(
194 Principal::SearchService,
195 scope.clone(),
196 scope
197 .clone()
198 .unwrap_or_else(|| "storage account (set ResourceId= in the connection string)".into()),
199 roles::STORAGE_BLOB_DATA_READER,
200 format!("data source '{name}' ({ds_type}) reads from storage"),
201 ));
202 }
203 "cosmosdb" => edges.push(IdentityEdge {
204 principal: Principal::SearchService,
205 scope: scope.clone(),
206 target: scope.unwrap_or_else(|| "Cosmos DB account".into()),
207 role_id: String::new(),
208 role_name: "Cosmos DB Built-in Data Reader (SQL role)".into(),
209 kind: EdgeKind::Informational,
210 reason: format!(
211 "data source '{name}' reads Cosmos DB — grant via `az cosmosdb sql role assignment create` \
212 (Cosmos data-plane roles are not ARM RBAC)"
213 ),
214 }),
215 "azuresql" => edges.push(IdentityEdge {
216 principal: Principal::SearchService,
217 scope: scope.clone(),
218 target: scope.unwrap_or_else(|| "Azure SQL database".into()),
219 role_id: String::new(),
220 role_name: "db_datareader (contained AAD user)".into(),
221 kind: EdgeKind::Informational,
222 reason: format!(
223 "data source '{name}' reads Azure SQL — CREATE USER [search-service-name] FROM EXTERNAL PROVIDER; \
224 ALTER ROLE db_datareader ADD MEMBER [...] (SQL AAD users are not ARM RBAC)"
225 ),
226 }),
227 _ => {}
228 }
229}
230
231pub fn parse_resource_id(conn: &str) -> Option<String> {
233 let start = conn.find("ResourceId=")? + "ResourceId=".len();
234 let rest = &conn[start..];
235 let end = rest.find(';').unwrap_or(rest.len());
236 let id = rest[..end].trim();
237 (id.starts_with("/subscriptions/") && !id.contains('<')).then(|| id.to_string())
238}
239
240fn dedup(edges: Vec<IdentityEdge>) -> Vec<IdentityEdge> {
241 let mut seen = std::collections::BTreeSet::new();
242 edges
243 .into_iter()
244 .filter(|e| {
245 seen.insert((
246 format!("{:?}", e.principal),
247 e.scope.clone().unwrap_or_else(|| e.target.clone()),
248 e.role_id.clone(),
249 e.role_name.clone(),
250 ))
251 })
252 .collect()
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use crate::resources::traits::ResourceRef;
259 use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
260 use serde_json::json;
261
262 fn ws_with(resources: &[(ResourceKind, &str, Value)]) -> (tempfile::TempDir, Workspace) {
263 let tmp = tempfile::tempdir().unwrap();
264 std::fs::write(
265 tmp.path().join(WORKSPACE_FILE),
266 "environments:\n dev:\n default: true\n search: { service: s }\n foundry: { account: f, project: p }\n",
267 )
268 .unwrap();
269 let pdir = tmp.path().join(PROJECTS_DIR).join("demo");
270 std::fs::create_dir_all(&pdir).unwrap();
271 std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
272 let ws = Workspace::load(tmp.path()).unwrap();
273 {
274 let store = Store::new(ws.project("demo").unwrap());
275 for (kind, name, value) in resources {
276 store.write(&ResourceRef::new(*kind, *name), value).unwrap();
277 }
278 }
279 let ws = Workspace::load(tmp.path()).unwrap();
280 (tmp, ws)
281 }
282
283 #[test]
284 fn blob_datasource_yields_storage_edge_with_scope() {
285 let (_tmp, ws) = ws_with(&[(
286 ResourceKind::DataSource,
287 "ds",
288 json!({
289 "name": "ds",
290 "type": "azureblob",
291 "credentials": {"connectionString": "ResourceId=/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct;"},
292 "container": {"name": "c"}
293 }),
294 )]);
295 let edges = identity_edges(&ws);
296 assert_eq!(edges.len(), 1);
297 let e = &edges[0];
298 assert_eq!(e.principal, Principal::SearchService);
299 assert_eq!(e.kind, EdgeKind::Rbac);
300 assert_eq!(e.role_name, "Storage Blob Data Reader");
301 assert_eq!(
302 e.scope.as_deref(),
303 Some(
304 "/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct"
305 )
306 );
307 }
308
309 #[test]
310 fn cosmos_and_sql_are_informational() {
311 let (_tmp, ws) = ws_with(&[
312 (
313 ResourceKind::DataSource,
314 "cds",
315 json!({"name": "cds", "type": "cosmosdb", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.DocumentDB/databaseAccounts/c;Database=d"}, "container": {"name": "x"}}),
316 ),
317 (
318 ResourceKind::DataSource,
319 "sds",
320 json!({"name": "sds", "type": "azuresql", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Sql/servers/sv;Database=d"}, "container": {"name": "t"}}),
321 ),
322 ]);
323 let edges = identity_edges(&ws);
324 assert_eq!(edges.len(), 2);
325 assert!(edges.iter().all(|e| e.kind == EdgeKind::Informational));
326 }
327
328 #[test]
329 fn kb_models_agent_grounding_and_vectorizers() {
330 let (_tmp, ws) = ws_with(&[
331 (
332 ResourceKind::KnowledgeBase,
333 "kb",
334 json!({"name": "kb", "models": [{"kind": "azureOpenAI"}], "knowledgeSources": [{"name": "ks"}]}),
335 ),
336 (
337 ResourceKind::Agent,
338 "agent",
339 json!({"name": "agent", "model": "m", "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb"}]}),
340 ),
341 (
342 ResourceKind::Index,
343 "idx",
344 json!({"name": "idx", "fields": [], "vectorSearch": {"vectorizers": [{"name": "v"}]}}),
345 ),
346 ]);
347 let edges = identity_edges(&ws);
348 let roles: Vec<&str> = edges.iter().map(|e| e.role_name.as_str()).collect();
349 assert!(roles.contains(&"Cognitive Services User"), "{roles:?}");
350 assert!(roles.contains(&"Search Index Data Reader"), "{roles:?}");
351 assert!(
352 roles.contains(&"Cognitive Services OpenAI User"),
353 "{roles:?}"
354 );
355 let grounding = edges
356 .iter()
357 .find(|e| e.role_name == "Search Index Data Reader")
358 .unwrap();
359 assert_eq!(grounding.principal, Principal::FoundryProject);
360 }
361
362 #[test]
363 fn placeholder_resource_ids_are_not_scopes() {
364 assert_eq!(
365 parse_resource_id("ResourceId=/subscriptions/<subscription-id>/...;"),
366 None
367 );
368 assert_eq!(parse_resource_id("AccountKey=zzz"), None);
369 assert_eq!(
370 parse_resource_id("ResourceId=/subscriptions/a/resourceGroups/b;Database=d").as_deref(),
371 Some("/subscriptions/a/resourceGroups/b")
372 );
373 }
374
375 #[test]
376 fn duplicate_edges_dedup() {
377 let ds = json!({"name": "ds", "type": "azureblob", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/a;"}, "container": {"name": "c"}});
378 let mut ds2 = ds.clone();
379 ds2["name"] = json!("ds2");
380 let (_tmp, ws) = ws_with(&[
381 (ResourceKind::DataSource, "ds", ds),
382 (ResourceKind::DataSource, "ds2", ds2),
383 ]);
384 assert_eq!(identity_edges(&ws).len(), 1, "same scope+role dedups");
385 }
386}