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