1use std::collections::BTreeMap;
12
13use serde::Serialize;
14use serde_json::Value;
15
16use crate::binding::{BindingEntry, BindingType, EnvBindings, Wanted};
17use crate::infra::{self, FoundRef, Target};
18use crate::registry::InfraForm;
19use crate::resources::ResourceKind;
20
21pub mod roles {
23 #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
25 pub struct Role {
26 pub id: &'static str,
27 pub name: &'static str,
28 }
29
30 const fn role(id: &'static str, name: &'static str) -> Role {
31 Role { id, name }
32 }
33
34 pub const STORAGE_BLOB_DATA_READER: Role = role(
35 "2a2b9908-6ea1-4ae2-8e65-a410df84e7d1",
36 "Storage Blob Data Reader",
37 );
38 pub const STORAGE_BLOB_DATA_CONTRIBUTOR: Role = role(
39 "ba92f5b4-2d11-453d-a403-e96b0029c9fe",
40 "Storage Blob Data Contributor",
41 );
42 pub const STORAGE_TABLE_DATA_CONTRIBUTOR: Role = role(
43 "0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3",
44 "Storage Table Data Contributor",
45 );
46 pub const READER_AND_DATA_ACCESS: Role = role(
47 "c12c1c16-33a1-487b-954d-41c89c60f349",
48 "Reader and Data Access",
49 );
50 pub const COGNITIVE_SERVICES_OPENAI_USER: Role = role(
51 "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd",
52 "Cognitive Services OpenAI User",
53 );
54 pub const COGNITIVE_SERVICES_USER: Role = role(
55 "a97b65f3-24c7-4388-baec-2e87135dc908",
56 "Cognitive Services User",
57 );
58 pub const COGNITIVE_SERVICES_CONTRIBUTOR: Role = role(
59 "25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68",
60 "Cognitive Services Contributor",
61 );
62 pub const SEARCH_INDEX_DATA_READER: Role = role(
63 "1407120a-92aa-4202-b7e9-c0e197c71c8f",
64 "Search Index Data Reader",
65 );
66 pub const SEARCH_INDEX_DATA_CONTRIBUTOR: Role = role(
67 "8ebe5a00-799e-43f5-93ac-243d3dce84a7",
68 "Search Index Data Contributor",
69 );
70 pub const SEARCH_SERVICE_CONTRIBUTOR: Role = role(
71 "7ca78c08-252a-4471-8644-bb5ff32d4ba0",
72 "Search Service Contributor",
73 );
74 pub const KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER: Role = role(
75 "e147488a-f6f5-4113-8e2d-b22465e65bf6",
76 "Key Vault Crypto Service Encryption User",
77 );
78 pub const KEY_VAULT_CRYPTO_USER: Role = role(
79 "12338af0-0e69-4776-bea7-57ae8d297424",
80 "Key Vault Crypto User",
81 );
82 pub const KEY_VAULT_SECRETS_USER: Role = role(
83 "4633458b-17de-408a-b874-0445c86b69e6",
84 "Key Vault Secrets User",
85 );
86 pub const FOUNDRY_USER: Role = role("53ca6127-db72-4b80-b1b0-d745d6d5456d", "Foundry User");
93 pub const FOUNDRY_PROJECT_MANAGER: Role = role(
94 "eadc314b-1a2d-4efa-be10-5d325db5065e",
95 "Foundry Project Manager",
96 );
97 pub const FOUNDRY_ACCOUNT_OWNER: Role = role(
98 "e47c6f54-e4a2-4754-9501-8e0985b135e1",
99 "Foundry Account Owner",
100 );
101
102 pub const APP_AUTHORIZATION: Role = role("app-authorization", "app authorization (Entra)");
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
110pub enum Principal {
111 SearchSystem,
113 SearchUser { binding: String },
117 FoundryProject,
119 Operator,
121 Named { object_id: String },
123}
124
125impl std::fmt::Display for Principal {
126 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127 match self {
128 Principal::SearchSystem => write!(f, "search-system"),
129 Principal::SearchUser { binding } => write!(f, "search-user:{binding}"),
130 Principal::FoundryProject => write!(f, "foundry-project"),
131 Principal::Operator => write!(f, "operator"),
132 Principal::Named { object_id } => write!(f, "principal:{object_id}"),
133 }
134 }
135}
136
137impl Serialize for Principal {
138 fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
139 s.serialize_str(&self.to_string())
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
147#[serde(rename_all = "camelCase")]
148pub enum Scope {
149 Resolved(String),
150 #[serde(rename_all = "camelCase")]
151 Unresolved {
152 binding: String,
155 kind: Option<BindingType>,
156 physical: String,
157 },
158}
159
160impl Scope {
161 pub fn arm_id(&self) -> Option<&str> {
163 match self {
164 Scope::Resolved(id) => Some(id.as_str()),
165 Scope::Unresolved { .. } => None,
166 }
167 }
168
169 pub fn key(&self) -> String {
171 match self {
172 Scope::Resolved(id) => id.clone(),
173 Scope::Unresolved {
174 binding, physical, ..
175 } => format!("unresolved:{binding}:{physical}"),
176 }
177 }
178
179 pub fn describe(&self) -> String {
181 match self {
182 Scope::Resolved(id) => id.clone(),
183 Scope::Unresolved {
184 binding,
185 kind,
186 physical,
187 } if binding.is_empty() => match kind {
188 Some(k) => format!("'{physical}' (no {k} binding in this environment)"),
189 None => format!("'{physical}' (not bound in this environment)"),
190 },
191 Scope::Unresolved {
192 binding, physical, ..
193 } => format!("binding '{binding}' ({physical}, not resolved yet)"),
194 }
195 }
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum Constraint {
202 AiServicesKindRequired,
204 TrustedServiceNeedsSystemIdentity,
207 PreviewOnly(&'static str),
209}
210
211#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
213pub struct Source {
214 pub kind: ResourceKind,
215 pub name: String,
216 pub path: String,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
222#[serde(rename_all = "kebab-case")]
223pub enum EdgeKind {
224 Rbac,
226 AppAuthorization,
228 Informational,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234pub struct Edge {
235 pub id: String,
237 pub principal: Principal,
238 pub role: roles::Role,
239 pub alternatives: Vec<roles::Role>,
241 pub scope: Scope,
242 pub reason: String,
243 pub sources: Vec<Source>,
244 pub constraints: Vec<Constraint>,
245 pub kind: EdgeKind,
246}
247
248impl Edge {
249 fn new(principal: Principal, role: roles::Role, scope: Scope, kind: EdgeKind) -> Edge {
250 let id = format!("{principal}|{}|{}", role.id, scope.key());
251 Edge {
252 id,
253 principal,
254 role,
255 alternatives: Vec::new(),
256 scope,
257 reason: String::new(),
258 sources: Vec::new(),
259 constraints: Vec::new(),
260 kind,
261 }
262 }
263
264 fn rbac(principal: Principal, role: roles::Role, scope: Scope) -> Edge {
265 Edge::new(principal, role, scope, EdgeKind::Rbac)
266 }
267
268 fn because(mut self, reason: impl Into<String>) -> Edge {
269 self.reason = reason.into();
270 self
271 }
272
273 fn evidence(mut self, source: Source) -> Edge {
274 self.sources.push(source);
275 self
276 }
277
278 fn constrained_by(mut self, constraint: Constraint) -> Edge {
279 if !self.constraints.contains(&constraint) {
280 self.constraints.push(constraint);
281 }
282 self
283 }
284
285 fn or_role(mut self, role: roles::Role) -> Edge {
286 self.alternatives.push(role);
287 self
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
294#[serde(rename_all = "camelCase", tag = "check")]
295pub enum CheckKind {
296 SearchSku,
298 SearchIdentity,
300 SearchRbacEnabled,
303 StorageNetwork {
304 account: Scope,
305 },
306 StorageSoftDelete {
307 account: Scope,
308 },
309 StorageSharedKey {
310 account: Scope,
311 },
312 AiServicesKind {
313 account: Scope,
314 },
315 FunctionAppNetwork {
316 site: Scope,
317 },
318 EasyAuth {
319 site: Scope,
320 audience: String,
321 },
322 DeploymentAvailability {
323 stem: String,
324 },
325 CanGrant {
328 scope: Scope,
329 },
330}
331
332impl CheckKind {
333 fn id(&self) -> String {
334 match self {
335 CheckKind::SearchSku => "search-sku".into(),
336 CheckKind::SearchIdentity => "search-identity".into(),
337 CheckKind::SearchRbacEnabled => "search-rbac-enabled".into(),
338 CheckKind::StorageNetwork { account } => format!("storage-network:{}", account.key()),
339 CheckKind::StorageSoftDelete { account } => {
340 format!("storage-soft-delete:{}", account.key())
341 }
342 CheckKind::StorageSharedKey { account } => {
343 format!("storage-shared-key:{}", account.key())
344 }
345 CheckKind::AiServicesKind { account } => format!("ai-services-kind:{}", account.key()),
346 CheckKind::FunctionAppNetwork { site } => {
347 format!("function-app-network:{}", site.key())
348 }
349 CheckKind::EasyAuth { site, audience } => {
350 format!("easy-auth:{}:{audience}", site.key())
351 }
352 CheckKind::DeploymentAvailability { stem } => format!("deployment-availability:{stem}"),
353 CheckKind::CanGrant { scope } => format!("can-grant:{}", scope.key()),
354 }
355 }
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
360pub struct Check {
361 pub id: String,
362 pub kind: CheckKind,
363 pub reason: String,
364 pub sources: Vec<Source>,
365}
366
367impl Check {
368 fn new(kind: CheckKind, reason: impl Into<String>) -> Check {
369 Check {
370 id: kind.id(),
371 kind,
372 reason: reason.into(),
373 sources: Vec::new(),
374 }
375 }
376
377 fn evidence(mut self, source: Source) -> Check {
378 self.sources.push(source);
379 self
380 }
381}
382
383#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
386pub struct Graph {
387 pub edges: Vec<Edge>,
388 pub checks: Vec<Check>,
389 pub operator: Vec<Edge>,
390}
391
392struct Builder<'a> {
398 env: &'a EnvBindings,
399 edges: Vec<Edge>,
400 edge_ix: BTreeMap<String, usize>,
401 checks: Vec<Check>,
402 check_ix: BTreeMap<String, usize>,
403}
404
405impl<'a> Builder<'a> {
406 fn new(env: &'a EnvBindings) -> Builder<'a> {
407 Builder {
408 env,
409 edges: Vec::new(),
410 edge_ix: BTreeMap::new(),
411 checks: Vec::new(),
412 check_ix: BTreeMap::new(),
413 }
414 }
415
416 fn edge(&mut self, edge: Edge) {
417 match self.edge_ix.get(&edge.id) {
418 Some(&i) => {
419 let existing = &mut self.edges[i];
420 for s in edge.sources {
421 if !existing.sources.contains(&s) {
422 existing.sources.push(s);
423 }
424 }
425 for c in edge.constraints {
426 if !existing.constraints.contains(&c) {
427 existing.constraints.push(c);
428 }
429 }
430 }
431 None => {
432 self.edge_ix.insert(edge.id.clone(), self.edges.len());
433 self.edges.push(edge);
434 }
435 }
436 }
437
438 fn check(&mut self, check: Check) {
439 match self.check_ix.get(&check.id) {
440 Some(&i) => {
441 let existing = &mut self.checks[i];
442 for s in check.sources {
443 if !existing.sources.contains(&s) {
444 existing.sources.push(s);
445 }
446 }
447 }
448 None => {
449 self.check_ix.insert(check.id.clone(), self.checks.len());
450 self.checks.push(check);
451 }
452 }
453 }
454}
455
456struct Doc<'a> {
459 kind: ResourceKind,
460 name: &'a str,
461 value: &'a Value,
462 refs: Vec<FoundRef>,
463}
464
465impl Doc<'_> {
466 fn source(&self, path: &str) -> Source {
467 Source {
468 kind: self.kind,
469 name: self.name.to_string(),
470 path: path.to_string(),
471 }
472 }
473
474 fn label(&self) -> String {
475 format!("{} '{}'", self.kind.display_name(), self.name)
476 }
477
478 fn at(&self, path: &str) -> Option<&FoundRef> {
480 self.refs.iter().find(|r| r.path == path)
481 }
482
483 fn of_form(&self, form: InfraForm) -> impl Iterator<Item = &FoundRef> {
484 self.refs.iter().filter(move |r| r.form == form)
485 }
486
487 fn value_at(&self, path: &str) -> Option<&Value> {
491 let mut cur = self.value;
492 for raw in path.split('.') {
493 let (key, index) = match raw.split_once('[') {
494 Some((key, rest)) => (key, rest.trim_end_matches(']').parse::<usize>().ok()),
495 None => (raw, None),
496 };
497 cur = cur.get(key)?;
498 if let Some(i) = index {
499 cur = cur.get(i)?;
500 }
501 }
502 Some(cur)
503 }
504}
505
506fn sibling(path: &str, leaf: &str) -> String {
509 match path.rfind('.') {
510 Some(i) => format!("{}.{leaf}", &path[..i]),
511 None => leaf.to_string(),
512 }
513}
514
515fn principal_for(env: &EnvBindings, identity: Option<&FoundRef>) -> Principal {
519 match identity {
520 Some(found) => {
521 let physical = &found.physical.physical;
522 let binding = env
523 .find_physical(Wanted::Type(BindingType::Identity), physical)
524 .map(|e| e.name.clone())
525 .unwrap_or_else(|| physical.clone());
526 Principal::SearchUser { binding }
527 }
528 None => Principal::SearchSystem,
529 }
530}
531
532fn scope_for(env: &EnvBindings, target: Target, physical: &str) -> Scope {
535 let entry = env.find_physical(infra::wanted_for(target), physical);
536 scope_of(entry, infra::binding_type_for(target), physical)
537}
538
539fn scope_of(entry: Option<&BindingEntry>, kind: Option<BindingType>, physical: &str) -> Scope {
540 let arm_id = entry.and_then(|e| {
541 e.resolved
542 .as_ref()
543 .and_then(|r| r.arm_id.clone())
544 .or_else(|| {
545 e.declared
546 .as_ref()
547 .and_then(|b| b.arm_id().map(String::from))
548 })
549 });
550 match arm_id {
551 Some(id) => Scope::Resolved(id),
552 None => Scope::Unresolved {
553 binding: entry.map(|e| e.name.clone()).unwrap_or_default(),
554 kind,
555 physical: physical.to_string(),
556 },
557 }
558}
559
560fn search_scope(env: &EnvBindings) -> Scope {
562 let entry = env.get("search");
563 let physical = entry.map(|e| e.physical_name.clone()).unwrap_or_default();
564 scope_of(entry, None, &physical)
565}
566
567fn foundry_scope(env: &EnvBindings) -> Scope {
569 let entry = env.get("foundry");
570 let physical = entry.map(|e| e.physical_name.clone()).unwrap_or_default();
571 scope_of(entry, None, &physical)
572}
573
574pub fn graph_for_docs(env: &EnvBindings, docs: &[(ResourceKind, String, Value)]) -> Graph {
577 let mut b = Builder::new(env);
578 for (kind, name, value) in docs {
579 let doc = Doc {
580 kind: *kind,
581 name,
582 value,
583 refs: infra::extract(*kind, value),
584 };
585 data_source_storage(&mut b, &doc);
586 knowledge_source_storage(&mut b, &doc);
587 knowledge_store_storage(&mut b, &doc);
588 model_host(&mut b, &doc);
589 ai_services(&mut b, &doc);
590 web_api_skills(&mut b, &doc);
591 agent_connections(&mut b, &doc, docs);
592 encryption_keys(&mut b, &doc);
593 deployments(&mut b, &doc);
594 }
595 search_checks(&mut b);
596 Graph {
597 edges: b.edges,
598 checks: b.checks,
599 operator: Vec::new(),
600 }
601}
602
603fn data_source_storage(b: &mut Builder<'_>, doc: &Doc<'_>) {
605 if doc.kind != ResourceKind::DataSource {
606 return;
607 }
608 let Some(found) = doc.at("credentials.connectionString") else {
609 return;
610 };
611 let physical = found.physical.physical.clone();
612 let scope = scope_for(b.env, Target::Storage, &physical);
613 let principal = principal_for(b.env, doc.at("identity"));
614 b.edge(storage_edge(
615 principal,
616 roles::STORAGE_BLOB_DATA_READER,
617 scope.clone(),
618 format!(
619 "{} reads blobs from storage account '{physical}'",
620 doc.label()
621 ),
622 doc.source(&found.path),
623 ));
624 storage_checks(b, doc, &scope, &found.path);
625 let soft_delete = doc
626 .value_at("dataDeletionDetectionPolicy")
627 .and_then(|p| p.get("@odata.type"))
628 .and_then(Value::as_str)
629 .is_some_and(|t| t.ends_with("NativeBlobSoftDeleteDeletionDetectionPolicy"));
630 if soft_delete {
631 b.check(
632 Check::new(
633 CheckKind::StorageSoftDelete {
634 account: scope.clone(),
635 },
636 format!(
637 "{} uses NativeBlobSoftDeleteDeletionDetectionPolicy — blob soft delete must \
638 be enabled on '{physical}' (and blob versioning off)",
639 doc.label()
640 ),
641 )
642 .evidence(doc.source("dataDeletionDetectionPolicy")),
643 );
644 }
645}
646
647fn knowledge_source_storage(b: &mut Builder<'_>, doc: &Doc<'_>) {
650 if doc.kind != ResourceKind::KnowledgeSource {
651 return;
652 }
653 let identity = doc.at("azureBlobParameters.ingestionParameters.identity");
654 let principal = principal_for(b.env, identity);
655 if let Some(found) = doc.at("azureBlobParameters.connectionString") {
656 let physical = found.physical.physical.clone();
657 let scope = scope_for(b.env, Target::Storage, &physical);
658 b.edge(storage_edge(
659 principal.clone(),
660 roles::STORAGE_BLOB_DATA_READER,
661 scope.clone(),
662 format!(
663 "{} ingests blobs from storage account '{physical}'",
664 doc.label()
665 ),
666 doc.source(&found.path),
667 ));
668 storage_checks(b, doc, &scope, &found.path);
669 }
670 if let Some(found) =
671 doc.at("azureBlobParameters.ingestionParameters.assetStore.connectionString")
672 {
673 let physical = found.physical.physical.clone();
674 let scope = scope_for(b.env, Target::Storage, &physical);
675 b.edge(storage_edge(
676 principal,
677 roles::STORAGE_BLOB_DATA_CONTRIBUTOR,
678 scope.clone(),
679 format!(
680 "{} writes extracted assets to storage account '{physical}'",
681 doc.label()
682 ),
683 doc.source(&found.path),
684 ));
685 storage_checks(b, doc, &scope, &found.path);
686 }
687}
688
689fn knowledge_store_storage(b: &mut Builder<'_>, doc: &Doc<'_>) {
691 if doc.kind != ResourceKind::Skillset {
692 return;
693 }
694 let Some(found) = doc.at("knowledgeStore.storageConnectionString") else {
695 return;
696 };
697 let physical = found.physical.physical.clone();
698 let scope = scope_for(b.env, Target::Storage, &physical);
699 let principal = principal_for(b.env, doc.at("knowledgeStore.identity"));
700 b.edge(storage_edge(
701 principal.clone(),
702 roles::STORAGE_BLOB_DATA_CONTRIBUTOR,
703 scope.clone(),
704 format!(
705 "{}'s knowledge store projects into storage account '{physical}'",
706 doc.label()
707 ),
708 doc.source(&found.path),
709 ));
710 let has_tables = doc
711 .value_at("knowledgeStore.projections")
712 .and_then(Value::as_array)
713 .is_some_and(|ps| {
714 ps.iter().any(|p| {
715 p.get("tables")
716 .and_then(Value::as_array)
717 .is_some_and(|t| !t.is_empty())
718 })
719 });
720 if has_tables {
721 for role in [
722 roles::STORAGE_TABLE_DATA_CONTRIBUTOR,
723 roles::READER_AND_DATA_ACCESS,
724 ] {
725 b.edge(storage_edge(
726 principal.clone(),
727 role,
728 scope.clone(),
729 format!(
730 "{}'s knowledge store has table projections on '{physical}'",
731 doc.label()
732 ),
733 doc.source("knowledgeStore.projections"),
734 ));
735 }
736 }
737 storage_checks(b, doc, &scope, &found.path);
738}
739
740fn storage_edge(
744 principal: Principal,
745 role: roles::Role,
746 scope: Scope,
747 reason: String,
748 source: Source,
749) -> Edge {
750 let user_assigned = matches!(principal, Principal::SearchUser { .. });
751 let edge = Edge::rbac(principal, role, scope)
752 .because(reason)
753 .evidence(source);
754 if user_assigned {
755 edge.constrained_by(Constraint::TrustedServiceNeedsSystemIdentity)
756 } else {
757 edge
758 }
759}
760
761fn storage_checks(b: &mut Builder<'_>, doc: &Doc<'_>, scope: &Scope, path: &str) {
762 b.check(
763 Check::new(
764 CheckKind::StorageNetwork {
765 account: scope.clone(),
766 },
767 format!(
768 "{} reaches this storage account — its firewall must admit the search service",
769 doc.label()
770 ),
771 )
772 .evidence(doc.source(path)),
773 );
774 b.check(
775 Check::new(
776 CheckKind::StorageSharedKey {
777 account: scope.clone(),
778 },
779 format!(
780 "{} uses identity-based access — shared-key access may be disabled",
781 doc.label()
782 ),
783 )
784 .evidence(doc.source(path)),
785 );
786}
787
788fn model_host(b: &mut Builder<'_>, doc: &Doc<'_>) {
790 let refs: Vec<(String, String)> = doc
791 .of_form(InfraForm::OpenAiEndpoint)
792 .map(|r| (r.path.clone(), r.physical.physical.clone()))
793 .collect();
794 for (path, physical) in refs {
795 let scope = scope_for(b.env, Target::ModelHost, &physical);
796 let principal = principal_for(b.env, doc.at(&sibling(&path, "authIdentity")));
797 let chat = doc.kind == ResourceKind::KnowledgeBase || path.contains("chatCompletionModel");
801 let role = if chat {
802 roles::COGNITIVE_SERVICES_USER
803 } else {
804 roles::COGNITIVE_SERVICES_OPENAI_USER
805 };
806 let what = if chat {
807 "calls a chat-completion model on"
808 } else {
809 "calls an embedding model on"
810 };
811 b.edge(
812 Edge::rbac(principal, role, scope)
813 .because(format!("{} {what} '{physical}'", doc.label()))
814 .evidence(doc.source(&path)),
815 );
816 }
817}
818
819fn ai_services(b: &mut Builder<'_>, doc: &Doc<'_>) {
821 let refs: Vec<(String, String)> = doc
822 .of_form(InfraForm::AiServicesSubdomain)
823 .map(|r| (r.path.clone(), r.physical.physical.clone()))
824 .collect();
825 for (path, physical) in refs {
826 let scope = scope_for(b.env, Target::AiServices, &physical);
827 let identity = doc
828 .at(&sibling(&path, "identity"))
829 .or_else(|| doc.at("azureBlobParameters.ingestionParameters.identity"));
830 let principal = principal_for(b.env, identity);
831 b.edge(
832 Edge::rbac(principal, roles::COGNITIVE_SERVICES_USER, scope.clone())
833 .because(format!(
834 "{} uses identity-based AI services enrichment on '{physical}'",
835 doc.label()
836 ))
837 .evidence(doc.source(&path))
838 .constrained_by(Constraint::AiServicesKindRequired),
839 );
840 b.check(
841 Check::new(
842 CheckKind::AiServicesKind {
843 account: scope.clone(),
844 },
845 format!("'{physical}' must be a Cognitive Services account of kind 'AIServices'"),
846 )
847 .evidence(doc.source(&path)),
848 );
849 }
850}
851
852fn web_api_skills(b: &mut Builder<'_>, doc: &Doc<'_>) {
855 if doc.kind != ResourceKind::Skillset {
856 return;
857 }
858 let refs: Vec<(String, String, Target)> = doc
859 .of_form(InfraForm::ApiUri)
860 .map(|r| {
861 (
862 r.path.clone(),
863 r.physical.physical.clone(),
864 r.physical.target,
865 )
866 })
867 .collect();
868 for (path, physical, target) in refs {
869 let Some(audience) = doc
870 .value_at(&sibling(&path, "authResourceId"))
871 .and_then(Value::as_str)
872 .map(str::trim)
873 .filter(|s| !s.is_empty())
874 .map(str::to_string)
875 else {
876 continue;
877 };
878 let scope = scope_for(b.env, target, &physical);
879 let principal = principal_for(b.env, doc.at(&sibling(&path, "authIdentity")));
880 b.edge(
881 Edge::new(
882 principal,
883 roles::APP_AUTHORIZATION,
884 scope.clone(),
885 EdgeKind::AppAuthorization,
886 )
887 .because(format!(
888 "{} calls '{physical}' with a managed identity — the app must accept the audience \
889 '{audience}'",
890 doc.label()
891 ))
892 .evidence(doc.source(&path)),
893 );
894 if target != Target::FunctionApp {
895 continue;
896 }
897 b.check(
898 Check::new(
899 CheckKind::FunctionAppNetwork {
900 site: scope.clone(),
901 },
902 format!(
903 "'{physical}' must admit the search service (AzureCognitiveSearch service tag \
904 or the service's IP)"
905 ),
906 )
907 .evidence(doc.source(&path)),
908 );
909 b.check(
910 Check::new(
911 CheckKind::EasyAuth {
912 site: scope.clone(),
913 audience: audience.clone(),
914 },
915 format!(
916 "'{physical}' must have Microsoft Entra authentication enabled with '{audience}' \
917 among its allowed audiences"
918 ),
919 )
920 .evidence(doc.source(&sibling(&path, "authResourceId"))),
921 );
922 }
923}
924
925fn agent_connections(b: &mut Builder<'_>, doc: &Doc<'_>, docs: &[(ResourceKind, String, Value)]) {
928 if doc.kind != ResourceKind::Agent {
929 return;
930 }
931 let Some(tools) = doc.value_at("tools").and_then(Value::as_array) else {
932 return;
933 };
934 for (i, tool) in tools.iter().enumerate() {
935 let is_mcp = tool
936 .get("type")
937 .and_then(Value::as_str)
938 .is_some_and(|t| t.eq_ignore_ascii_case("mcp"));
939 let Some(conn) = tool.get("project_connection_id").and_then(Value::as_str) else {
940 continue;
941 };
942 if !is_mcp || conn.is_empty() {
943 continue;
944 }
945 let declared = docs
949 .iter()
950 .find(|(k, n, _)| *k == ResourceKind::Connection && n == conn);
951 if let Some((_, _, c)) = declared {
952 let auth = c
953 .get("properties")
954 .and_then(|p| p.get("authType"))
955 .or_else(|| c.get("authType"))
956 .and_then(Value::as_str)
957 .unwrap_or("ProjectManagedIdentity");
958 if !auth.eq_ignore_ascii_case("ProjectManagedIdentity") {
959 continue;
960 }
961 }
962 let scope = search_scope(b.env);
963 b.edge(
964 Edge::rbac(
965 Principal::FoundryProject,
966 roles::SEARCH_INDEX_DATA_READER,
967 scope,
968 )
969 .because(format!(
970 "{} calls the search service through connection '{conn}' with the project's \
971 managed identity",
972 doc.label()
973 ))
974 .evidence(doc.source(&format!("tools[{i}].project_connection_id"))),
975 );
976 }
977}
978
979fn encryption_keys(b: &mut Builder<'_>, doc: &Doc<'_>) {
981 let Some(found) = doc.at("encryptionKey.keyVaultUri") else {
982 return;
983 };
984 let has_credentials = doc
985 .value_at("encryptionKey.accessCredentials")
986 .is_some_and(|v| !v.is_null());
987 if has_credentials {
988 return;
989 }
990 let physical = found.physical.physical.clone();
991 let scope = scope_for(b.env, Target::KeyVault, &physical);
992 let principal = principal_for(b.env, doc.at("encryptionKey.identity"));
996 b.edge(
997 Edge::rbac(
998 principal,
999 roles::KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER,
1000 scope,
1001 )
1002 .because(format!(
1003 "{} is encrypted with a customer-managed key in key vault '{physical}'",
1004 doc.label()
1005 ))
1006 .evidence(doc.source(&found.path)),
1007 );
1008}
1009
1010fn deployments(b: &mut Builder<'_>, doc: &Doc<'_>) {
1012 if doc.kind != ResourceKind::Deployment {
1013 return;
1014 }
1015 b.check(
1016 Check::new(
1017 CheckKind::DeploymentAvailability {
1018 stem: doc.name.to_string(),
1019 },
1020 format!(
1021 "{} must name a model and version available in the account's region, with quota \
1022 headroom for its capacity",
1023 doc.label()
1024 ),
1025 )
1026 .evidence(doc.source("properties.model")),
1027 );
1028}
1029
1030fn search_checks(b: &mut Builder<'_>) {
1032 if b.env.get("search").is_none() {
1033 return;
1034 }
1035 for (kind, reason) in [
1036 (
1037 CheckKind::SearchSku,
1038 "the Free SKU has no managed identity, and knowledge bases need Basic or higher",
1039 ),
1040 (
1041 CheckKind::SearchIdentity,
1042 "the search service needs a managed identity to reach anything keylessly",
1043 ),
1044 (
1045 CheckKind::SearchRbacEnabled,
1046 "the search service must accept bearer tokens (authOptions.aadOrApiKey, or \
1047 disableLocalAuth)",
1048 ),
1049 ] {
1050 b.check(Check::new(kind, reason));
1051 }
1052}
1053
1054pub fn operator_edges(
1072 env: &EnvBindings,
1073 kinds_in_plan: &[ResourceKind],
1074 verify: bool,
1075 needs_grants: &[&Edge],
1076 foundry_project_id: Option<&str>,
1077) -> (Vec<Edge>, Vec<Check>) {
1078 let mut edges = Vec::new();
1079 let has = |k: ResourceKind| kinds_in_plan.contains(&k);
1080 let any_search = kinds_in_plan
1081 .iter()
1082 .any(|k| k.domain() == crate::service::ServiceDomain::Search);
1083
1084 if any_search {
1085 edges.push(
1086 Edge::rbac(
1087 Principal::Operator,
1088 roles::SEARCH_SERVICE_CONTRIBUTOR,
1089 search_scope(env),
1090 )
1091 .because("create and update Azure AI Search resources (and run/reset indexers)"),
1092 );
1093 }
1094 if verify {
1095 edges.push(
1096 Edge::rbac(
1097 Principal::Operator,
1098 roles::SEARCH_INDEX_DATA_READER,
1099 search_scope(env),
1100 )
1101 .or_role(roles::SEARCH_INDEX_DATA_CONTRIBUTOR)
1102 .because("query indexes and knowledge bases (rigg query / ask / push --verify)"),
1103 );
1104 }
1105 if has(ResourceKind::Agent) {
1106 let scope = match foundry_project_id {
1107 Some(id) => Scope::Resolved(id.to_string()),
1108 None => foundry_scope(env),
1109 };
1110 edges.push(
1111 Edge::rbac(Principal::Operator, roles::FOUNDRY_USER, scope).because(
1112 "create and update Foundry agents on the project (Owner/Contributor do not \
1113 suffice)",
1114 ),
1115 );
1116 }
1117 if has(ResourceKind::Connection) {
1118 edges.push(
1119 Edge::rbac(
1120 Principal::Operator,
1121 roles::FOUNDRY_PROJECT_MANAGER,
1122 foundry_scope(env),
1123 )
1124 .or_role(roles::COGNITIVE_SERVICES_CONTRIBUTOR)
1131 .because("create project connections"),
1132 );
1133 }
1134 if has(ResourceKind::Deployment) || has(ResourceKind::Guardrail) {
1135 edges.push(
1136 Edge::rbac(
1137 Principal::Operator,
1138 roles::FOUNDRY_ACCOUNT_OWNER,
1139 foundry_scope(env),
1140 )
1141 .or_role(roles::COGNITIVE_SERVICES_CONTRIBUTOR)
1142 .because("create model deployments and content-filter policies on the account"),
1143 );
1144 }
1145
1146 let mut checks: Vec<Check> = Vec::new();
1147 let mut seen = std::collections::BTreeSet::new();
1148 for edge in needs_grants {
1149 if !seen.insert(edge.scope.key()) {
1150 continue;
1151 }
1152 let mut check = Check::new(
1153 CheckKind::CanGrant {
1154 scope: edge.scope.clone(),
1155 },
1156 format!(
1157 "granting '{}' here needs Microsoft.Authorization/roleAssignments/write at {}",
1158 edge.role.name,
1159 edge.scope.describe()
1160 ),
1161 );
1162 check.sources = edge.sources.clone();
1163 checks.push(check);
1164 }
1165 (edges, checks)
1166}
1167
1168pub fn edges_for(kind: ResourceKind, name: &str, value: &Value) -> Vec<Edge> {
1176 let env = EnvBindings::of_env("", &crate::workspace::Environment::default(), None);
1177 graph_for_docs(&env, &[(kind, name.to_string(), value.clone())]).edges
1178}
1179
1180pub fn parse_resource_id(conn: &str) -> Option<String> {
1182 let start = conn.find("ResourceId=")? + "ResourceId=".len();
1183 let rest = &conn[start..];
1184 let end = rest.find(';').unwrap_or(rest.len());
1185 let id = rest[..end].trim();
1186 (id.starts_with("/subscriptions/") && !id.contains('<')).then(|| id.to_string())
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191 use super::*;
1192 use crate::binding::{Binding, BindingCache, ResolvedBinding, TargetKind};
1193 use crate::workspace::{Environment, FoundryConnection, SearchConnection};
1194 use serde_json::json;
1195
1196 const STORAGE_ID: &str =
1197 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct";
1198 const ASSETS_ID: &str =
1199 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/assets";
1200 const FOUNDRY_ID: &str =
1201 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/fndr";
1202 const AISVC_ID: &str =
1203 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.CognitiveServices/accounts/aisvc";
1204 const SEARCH_ID: &str =
1205 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Search/searchServices/srch";
1206 const SITE_ID: &str = "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Web/sites/fn";
1207 const VAULT_ID: &str =
1208 "/subscriptions/s/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/kv";
1209 const UAMI_ID: &str = "/subscriptions/s/resourceGroups/rg/providers/Microsoft.ManagedIdentity/\
1210 userAssignedIdentities/rigg-mi";
1211
1212 fn env() -> EnvBindings {
1216 let deps = [
1217 ("docs", BindingType::Storage, STORAGE_ID),
1218 ("assets", BindingType::Storage, ASSETS_ID),
1219 ("enrichment", BindingType::AiServices, AISVC_ID),
1220 ("fn", BindingType::FunctionApp, SITE_ID),
1221 ("vault", BindingType::KeyVault, VAULT_ID),
1222 ("mi", BindingType::Identity, UAMI_ID),
1223 ];
1224 let environment = Environment {
1225 search: Some(SearchConnection {
1226 service: "srch".into(),
1227 ..Default::default()
1228 }),
1229 foundry: Some(FoundryConnection {
1230 account: "fndr".into(),
1231 project: "p".into(),
1232 ..Default::default()
1233 }),
1234 dependencies: deps
1235 .into_iter()
1236 .map(|(n, kind, value)| {
1237 (
1238 n.to_string(),
1239 Binding {
1240 kind,
1241 value: value.to_string(),
1242 },
1243 )
1244 })
1245 .collect(),
1246 ..Default::default()
1247 };
1248 let mut cache = BindingCache::default();
1249 for (name, kind, physical, arm_id) in [
1250 ("search", TargetKind::Search, "srch", SEARCH_ID),
1251 ("foundry", TargetKind::Foundry, "fndr", FOUNDRY_ID),
1252 ] {
1253 cache.bindings.insert(
1254 name.to_string(),
1255 ResolvedBinding {
1256 name: name.into(),
1257 kind,
1258 physical_name: physical.into(),
1259 arm_id: Some(arm_id.into()),
1260 subscription: Some("s".into()),
1261 resource_group: Some("rg".into()),
1262 location: None,
1263 endpoint: None,
1264 principal_id: None,
1265 resolved_at: "2026-09-10T00:00:00Z".into(),
1266 },
1267 );
1268 }
1269 EnvBindings::of_env("dev", &environment, Some(&cache))
1270 }
1271
1272 fn graph(docs: &[(ResourceKind, &str, Value)]) -> Graph {
1273 let owned: Vec<_> = docs
1274 .iter()
1275 .map(|(k, n, v)| (*k, n.to_string(), v.clone()))
1276 .collect();
1277 graph_for_docs(&env(), &owned)
1278 }
1279
1280 fn rbac_edges(g: &Graph) -> Vec<&Edge> {
1281 g.edges
1282 .iter()
1283 .filter(|e| e.kind == EdgeKind::Rbac)
1284 .collect()
1285 }
1286
1287 fn find(g: &Graph, role: roles::Role) -> Vec<&Edge> {
1288 g.edges.iter().filter(|e| e.role == role).collect()
1289 }
1290
1291 fn conn(id: &str) -> String {
1292 format!("ResourceId={id};")
1293 }
1294
1295 fn has_check(g: &Graph, id: &str) -> bool {
1296 g.checks.iter().any(|c| c.id == id)
1297 }
1298
1299 #[test]
1302 fn data_source_storage_edge_uses_the_system_identity_and_the_bound_scope() {
1303 let g = graph(&[(
1304 ResourceKind::DataSource,
1305 "ds",
1306 json!({
1307 "name": "ds", "type": "azureblob",
1308 "credentials": {"connectionString": conn(STORAGE_ID)},
1309 "container": {"name": "c"}
1310 }),
1311 )]);
1312 let edges = rbac_edges(&g);
1313 assert_eq!(edges.len(), 1, "{edges:?}");
1314 let e = edges[0];
1315 assert_eq!(e.principal, Principal::SearchSystem);
1316 assert_eq!(e.role, roles::STORAGE_BLOB_DATA_READER);
1317 assert_eq!(e.scope, Scope::Resolved(STORAGE_ID.into()));
1318 assert_eq!(
1319 e.sources,
1320 vec![Source {
1321 kind: ResourceKind::DataSource,
1322 name: "ds".into(),
1323 path: "credentials.connectionString".into()
1324 }]
1325 );
1326 assert!(e.constraints.is_empty());
1327 assert_eq!(e.id, format!("search-system|{}|{STORAGE_ID}", e.role.id));
1328 assert!(has_check(&g, &format!("storage-network:{STORAGE_ID}")));
1329 assert!(has_check(&g, &format!("storage-shared-key:{STORAGE_ID}")));
1330 assert!(
1331 !has_check(&g, &format!("storage-soft-delete:{STORAGE_ID}")),
1332 "soft delete is only checked when the policy asks for it"
1333 );
1334 }
1335
1336 #[test]
1337 fn data_source_with_a_user_assigned_identity_names_the_binding_and_the_constraint() {
1338 let g = graph(&[(
1339 ResourceKind::DataSource,
1340 "ds",
1341 json!({
1342 "name": "ds", "type": "azureblob",
1343 "credentials": {"connectionString": conn(STORAGE_ID)},
1344 "identity": {
1345 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1346 "userAssignedIdentity": UAMI_ID
1347 },
1348 "dataDeletionDetectionPolicy": {
1349 "@odata.type": "#Microsoft.Azure.Search.NativeBlobSoftDeleteDeletionDetectionPolicy"
1350 },
1351 "container": {"name": "c"}
1352 }),
1353 )]);
1354 let e = rbac_edges(&g)[0];
1355 assert_eq!(
1356 e.principal,
1357 Principal::SearchUser {
1358 binding: "mi".into()
1359 }
1360 );
1361 assert_eq!(
1362 e.constraints,
1363 vec![Constraint::TrustedServiceNeedsSystemIdentity]
1364 );
1365 assert!(has_check(&g, &format!("storage-soft-delete:{STORAGE_ID}")));
1366 }
1367
1368 #[test]
1369 fn an_unbound_user_assigned_identity_falls_back_to_its_physical_name() {
1370 let g = graph(&[(
1371 ResourceKind::DataSource,
1372 "ds",
1373 json!({
1374 "name": "ds", "type": "azureblob",
1375 "credentials": {"connectionString": conn(STORAGE_ID)},
1376 "identity": {
1377 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1378 "userAssignedIdentity": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.ManagedIdentity/userAssignedIdentities/stray"
1379 },
1380 "container": {"name": "c"}
1381 }),
1382 )]);
1383 assert_eq!(
1384 rbac_edges(&g)[0].principal,
1385 Principal::SearchUser {
1386 binding: "stray".into()
1387 }
1388 );
1389 }
1390
1391 #[test]
1394 fn knowledge_source_reads_blobs_and_writes_its_asset_store() {
1395 let g = graph(&[(
1396 ResourceKind::KnowledgeSource,
1397 "ks",
1398 json!({
1399 "name": "ks", "kind": "azureBlob",
1400 "azureBlobParameters": {
1401 "connectionString": conn(STORAGE_ID),
1402 "containerName": "c",
1403 "ingestionParameters": {
1404 "assetStore": {"connectionString": conn(ASSETS_ID)}
1405 }
1406 }
1407 }),
1408 )]);
1409 let reader = find(&g, roles::STORAGE_BLOB_DATA_READER);
1410 assert_eq!(reader.len(), 1);
1411 assert_eq!(reader[0].scope, Scope::Resolved(STORAGE_ID.into()));
1412 assert_eq!(reader[0].principal, Principal::SearchSystem);
1413 let writer = find(&g, roles::STORAGE_BLOB_DATA_CONTRIBUTOR);
1414 assert_eq!(writer.len(), 1);
1415 assert_eq!(writer[0].scope, Scope::Resolved(ASSETS_ID.into()));
1416 }
1417
1418 #[test]
1421 fn knowledge_store_projections_add_table_roles_only_when_tables_exist() {
1422 let without = graph(&[(
1423 ResourceKind::Skillset,
1424 "ss",
1425 json!({
1426 "name": "ss", "skills": [],
1427 "knowledgeStore": {
1428 "storageConnectionString": conn(STORAGE_ID),
1429 "projections": [{"objects": [{"storageContainer": "o"}]}]
1430 }
1431 }),
1432 )]);
1433 assert_eq!(rbac_edges(&without).len(), 1);
1434 assert_eq!(
1435 rbac_edges(&without)[0].role,
1436 roles::STORAGE_BLOB_DATA_CONTRIBUTOR
1437 );
1438
1439 let with = graph(&[(
1440 ResourceKind::Skillset,
1441 "ss",
1442 json!({
1443 "name": "ss", "skills": [],
1444 "knowledgeStore": {
1445 "storageConnectionString": conn(STORAGE_ID),
1446 "identity": {
1447 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1448 "userAssignedIdentity": UAMI_ID
1449 },
1450 "projections": [{"tables": [{"tableName": "t"}]}]
1451 }
1452 }),
1453 )]);
1454 let roles_used: Vec<&str> = rbac_edges(&with).iter().map(|e| e.role.name).collect();
1455 assert!(
1456 roles_used.contains(&"Storage Blob Data Contributor"),
1457 "{roles_used:?}"
1458 );
1459 assert!(
1460 roles_used.contains(&"Storage Table Data Contributor"),
1461 "{roles_used:?}"
1462 );
1463 assert!(
1464 roles_used.contains(&"Reader and Data Access"),
1465 "{roles_used:?}"
1466 );
1467 assert!(rbac_edges(&with).iter().all(|e| e.principal
1468 == Principal::SearchUser {
1469 binding: "mi".into()
1470 }));
1471 }
1472
1473 #[test]
1476 fn vectorizers_embedding_skills_and_knowledge_source_embeddings_need_openai_user() {
1477 let g = graph(&[
1478 (
1479 ResourceKind::Index,
1480 "idx",
1481 json!({
1482 "name": "idx", "fields": [],
1483 "vectorSearch": {"vectorizers": [{
1484 "name": "v", "kind": "azureOpenAI",
1485 "azureOpenAIParameters": {
1486 "resourceUri": "https://fndr.openai.azure.com",
1487 "deploymentId": "embed"
1488 }
1489 }]}
1490 }),
1491 ),
1492 (
1493 ResourceKind::Skillset,
1494 "ss",
1495 json!({
1496 "name": "ss",
1497 "skills": [{
1498 "@odata.type": "#Microsoft.Skills.Text.AzureOpenAIEmbeddingSkill",
1499 "resourceUri": "https://fndr.openai.azure.com",
1500 "authIdentity": {
1501 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1502 "userAssignedIdentity": UAMI_ID
1503 },
1504 "inputs": [], "outputs": []
1505 }]
1506 }),
1507 ),
1508 (
1509 ResourceKind::KnowledgeSource,
1510 "ks",
1511 json!({
1512 "name": "ks", "kind": "azureBlob",
1513 "azureBlobParameters": {"ingestionParameters": {"embeddingModel": {
1514 "azureOpenAIParameters": {"resourceUri": "https://fndr.openai.azure.com"}
1515 }}}
1516 }),
1517 ),
1518 ]);
1519 let openai = find(&g, roles::COGNITIVE_SERVICES_OPENAI_USER);
1520 assert_eq!(openai.len(), 2, "system + UAMI principals, one edge each");
1521 assert!(
1522 openai
1523 .iter()
1524 .all(|e| e.scope == Scope::Resolved(FOUNDRY_ID.into()))
1525 );
1526 let system = openai
1527 .iter()
1528 .find(|e| e.principal == Principal::SearchSystem)
1529 .expect("system-identity edge");
1530 assert_eq!(system.sources.len(), 2, "index + knowledge source merged");
1531 assert!(openai.iter().any(|e| e.principal
1532 == Principal::SearchUser {
1533 binding: "mi".into()
1534 }));
1535 }
1536
1537 #[test]
1540 fn knowledge_base_models_and_chat_completion_need_cognitive_services_user() {
1541 let g = graph(&[
1542 (
1543 ResourceKind::KnowledgeBase,
1544 "kb",
1545 json!({
1546 "name": "kb", "knowledgeSources": [{"name": "ks"}],
1547 "models": [{"kind": "azureOpenAI", "azureOpenAIParameters": {
1548 "resourceUri": "https://fndr.openai.azure.com", "deploymentId": "chat"
1549 }}]
1550 }),
1551 ),
1552 (
1553 ResourceKind::KnowledgeSource,
1554 "ks",
1555 json!({
1556 "name": "ks", "kind": "azureBlob",
1557 "azureBlobParameters": {"ingestionParameters": {"chatCompletionModel": {
1558 "azureOpenAIParameters": {"resourceUri": "https://fndr.openai.azure.com"}
1559 }}}
1560 }),
1561 ),
1562 ]);
1563 let cs = find(&g, roles::COGNITIVE_SERVICES_USER);
1564 assert_eq!(cs.len(), 1, "one principal, one scope, two sources");
1565 assert_eq!(cs[0].principal, Principal::SearchSystem);
1566 assert_eq!(cs[0].scope, Scope::Resolved(FOUNDRY_ID.into()));
1567 assert_eq!(cs[0].sources.len(), 2);
1568 assert!(find(&g, roles::COGNITIVE_SERVICES_OPENAI_USER).is_empty());
1569 }
1570
1571 #[test]
1574 fn ai_services_by_identity_carries_the_kind_constraint_and_a_check() {
1575 let g = graph(&[(
1576 ResourceKind::Skillset,
1577 "ss",
1578 json!({
1579 "name": "ss", "skills": [],
1580 "cognitiveServices": {
1581 "@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity",
1582 "subdomainUrl": "https://aisvc.cognitiveservices.azure.com/"
1583 }
1584 }),
1585 )]);
1586 let e = rbac_edges(&g)[0];
1587 assert_eq!(e.role, roles::COGNITIVE_SERVICES_USER);
1588 assert_eq!(e.principal, Principal::SearchSystem);
1589 assert_eq!(e.scope, Scope::Resolved(AISVC_ID.into()));
1590 assert_eq!(e.constraints, vec![Constraint::AiServicesKindRequired]);
1591 assert!(has_check(&g, &format!("ai-services-kind:{AISVC_ID}")));
1592 }
1593
1594 #[test]
1597 fn web_api_skill_with_auth_resource_id_is_app_authorization_plus_site_checks() {
1598 let g = graph(&[(
1599 ResourceKind::Skillset,
1600 "ss",
1601 json!({
1602 "name": "ss",
1603 "skills": [
1604 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1605 "uri": "https://fn.azurewebsites.net/api/enrich",
1606 "authResourceId": "api://abc",
1607 "inputs": [], "outputs": []},
1608 {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
1609 "uri": "https://fn.azurewebsites.net/api/other",
1610 "inputs": [], "outputs": []}
1611 ]
1612 }),
1613 )]);
1614 let app: Vec<&Edge> = g
1615 .edges
1616 .iter()
1617 .filter(|e| e.kind == EdgeKind::AppAuthorization)
1618 .collect();
1619 assert_eq!(app.len(), 1, "only the authResourceId skill yields an edge");
1620 assert_eq!(app[0].principal, Principal::SearchSystem);
1621 assert_eq!(app[0].scope, Scope::Resolved(SITE_ID.into()));
1622 assert!(app[0].reason.contains("api://abc"));
1623 assert!(has_check(&g, &format!("function-app-network:{SITE_ID}")));
1624 assert!(has_check(&g, &format!("easy-auth:{SITE_ID}:api://abc")));
1625 }
1626
1627 #[test]
1630 fn agent_mcp_tool_over_a_project_managed_identity_connection_needs_index_data_reader() {
1631 let agent = json!({
1632 "name": "a", "model": "m",
1633 "tools": [{"type": "mcp", "project_connection_id": "kb-conn"}]
1634 });
1635 let g = graph(&[
1636 (ResourceKind::Agent, "a", agent.clone()),
1637 (
1638 ResourceKind::Connection,
1639 "kb-conn",
1640 json!({"name": "kb-conn", "properties": {
1641 "category": "RemoteTool", "authType": "ProjectManagedIdentity",
1642 "target": "https://srch.search.windows.net/knowledgebases/kb/mcp"
1643 }}),
1644 ),
1645 ]);
1646 let e = find(&g, roles::SEARCH_INDEX_DATA_READER);
1647 assert_eq!(e.len(), 1);
1648 assert_eq!(e[0].principal, Principal::FoundryProject);
1649 assert_eq!(e[0].scope, Scope::Resolved(SEARCH_ID.into()));
1650
1651 let keyed = graph(&[
1652 (ResourceKind::Agent, "a", agent),
1653 (
1654 ResourceKind::Connection,
1655 "kb-conn",
1656 json!({"name": "kb-conn", "properties": {
1657 "category": "RemoteTool", "authType": "ApiKey",
1658 "target": "https://srch.search.windows.net/knowledgebases/kb/mcp"
1659 }}),
1660 ),
1661 ]);
1662 assert!(
1663 find(&keyed, roles::SEARCH_INDEX_DATA_READER).is_empty(),
1664 "a key-based connection needs no role"
1665 );
1666 }
1667
1668 #[test]
1671 fn encryption_key_without_access_credentials_needs_crypto_service_encryption_user() {
1672 let g = graph(&[(
1673 ResourceKind::Index,
1674 "idx",
1675 json!({
1676 "name": "idx", "fields": [],
1677 "encryptionKey": {
1678 "keyVaultUri": "https://kv.vault.azure.net",
1679 "keyVaultKeyName": "k", "keyVaultKeyVersion": "1"
1680 }
1681 }),
1682 )]);
1683 let e = rbac_edges(&g)[0];
1684 assert_eq!(e.role, roles::KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER);
1685 assert_eq!(e.principal, Principal::SearchSystem);
1686 assert_eq!(e.scope, Scope::Resolved(VAULT_ID.into()));
1687
1688 let with_creds = graph(&[(
1689 ResourceKind::Index,
1690 "idx",
1691 json!({
1692 "name": "idx", "fields": [],
1693 "encryptionKey": {
1694 "keyVaultUri": "https://kv.vault.azure.net",
1695 "keyVaultKeyName": "k", "keyVaultKeyVersion": "1",
1696 "accessCredentials": {"applicationId": "app"}
1697 }
1698 }),
1699 )]);
1700 assert!(
1701 rbac_edges(&with_creds).is_empty(),
1702 "explicit credentials, no identity edge"
1703 );
1704 }
1705
1706 #[test]
1711 fn encryption_key_identity_attributes_the_cmk_edge_to_the_user_assigned_identity() {
1712 let g = graph(&[(
1713 ResourceKind::Index,
1714 "idx",
1715 json!({
1716 "name": "idx", "fields": [],
1717 "encryptionKey": {
1718 "keyVaultUri": "https://kv.vault.azure.net",
1719 "keyVaultKeyName": "k", "keyVaultKeyVersion": "1",
1720 "identity": {
1721 "@odata.type": "#Microsoft.Azure.Search.DataUserAssignedIdentity",
1722 "userAssignedIdentity": UAMI_ID
1723 }
1724 }
1725 }),
1726 )]);
1727 let e = rbac_edges(&g)[0];
1728 assert_eq!(e.role, roles::KEY_VAULT_CRYPTO_SERVICE_ENCRYPTION_USER);
1729 assert_eq!(
1730 e.principal,
1731 Principal::SearchUser {
1732 binding: "mi".to_string()
1733 }
1734 );
1735 assert_eq!(e.scope, Scope::Resolved(VAULT_ID.into()));
1736 }
1737
1738 #[test]
1741 fn a_binding_without_an_arm_id_yields_an_unresolved_scope_naming_it() {
1742 let environment = Environment {
1743 search: Some(SearchConnection {
1744 service: "srch".into(),
1745 ..Default::default()
1746 }),
1747 dependencies: [(
1748 "docs".to_string(),
1749 Binding {
1750 kind: BindingType::Storage,
1751 value: "acct".into(),
1752 },
1753 )]
1754 .into_iter()
1755 .collect(),
1756 ..Default::default()
1757 };
1758 let bindings = EnvBindings::of_env("dev", &environment, None);
1759 let g = graph_for_docs(
1760 &bindings,
1761 &[(
1762 ResourceKind::DataSource,
1763 "ds".to_string(),
1764 json!({
1765 "name": "ds", "type": "azureblob",
1766 "credentials": {"connectionString": conn(STORAGE_ID)},
1767 "container": {"name": "c"}
1768 }),
1769 )],
1770 );
1771 assert_eq!(
1772 g.edges[0].scope,
1773 Scope::Unresolved {
1774 binding: "docs".into(),
1775 kind: Some(BindingType::Storage),
1776 physical: "acct".into()
1777 }
1778 );
1779 assert_eq!(
1780 g.edges[0].id,
1781 "search-system|2a2b9908-6ea1-4ae2-8e65-a410df84e7d1|unresolved:docs:acct"
1782 );
1783 }
1784
1785 #[test]
1786 fn an_unbound_reference_still_yields_an_edge_with_an_empty_binding() {
1787 let bindings = EnvBindings::of_env("dev", &Environment::default(), None);
1788 let g = graph_for_docs(
1789 &bindings,
1790 &[(
1791 ResourceKind::DataSource,
1792 "ds".to_string(),
1793 json!({
1794 "name": "ds", "type": "azureblob",
1795 "credentials": {"connectionString": conn(STORAGE_ID)},
1796 "container": {"name": "c"}
1797 }),
1798 )],
1799 );
1800 assert_eq!(
1801 g.edges[0].scope,
1802 Scope::Unresolved {
1803 binding: String::new(),
1804 kind: Some(BindingType::Storage),
1805 physical: "acct".into()
1806 }
1807 );
1808 assert!(
1809 g.checks.iter().all(|c| c.kind != CheckKind::SearchSku),
1810 "no search target, no search checks"
1811 );
1812 }
1813
1814 #[test]
1815 fn edges_dedup_by_principal_role_and_scope_merging_their_sources() {
1816 let ds = |name: &str| {
1817 json!({
1818 "name": name, "type": "azureblob",
1819 "credentials": {"connectionString": conn(STORAGE_ID)},
1820 "container": {"name": "c"}
1821 })
1822 };
1823 let g = graph(&[
1824 (ResourceKind::DataSource, "one", ds("one")),
1825 (ResourceKind::DataSource, "two", ds("two")),
1826 ]);
1827 assert_eq!(rbac_edges(&g).len(), 1);
1828 assert_eq!(rbac_edges(&g)[0].sources.len(), 2);
1829 let network: Vec<&Check> = g
1830 .checks
1831 .iter()
1832 .filter(|c| c.id.starts_with("storage-network:"))
1833 .collect();
1834 assert_eq!(network.len(), 1, "checks dedup too");
1835 assert_eq!(network[0].sources.len(), 2);
1836 }
1837
1838 #[test]
1841 fn every_environment_with_a_search_target_carries_the_three_service_checks() {
1842 let g = graph(&[]);
1843 assert!(has_check(&g, "search-sku"));
1844 assert!(has_check(&g, "search-identity"));
1845 assert!(has_check(&g, "search-rbac-enabled"));
1846 }
1847
1848 #[test]
1849 fn a_deployment_yields_an_availability_check_named_by_its_stem() {
1850 let g = graph(&[(
1851 ResourceKind::Deployment,
1852 "gpt-4o",
1853 json!({"name": "gpt-4o", "sku": {"name": "GlobalStandard", "capacity": 1},
1854 "properties": {"model": {"name": "gpt-4o", "version": "2024-11-20"}}}),
1855 )]);
1856 assert!(has_check(&g, "deployment-availability:gpt-4o"));
1857 }
1858
1859 #[test]
1862 fn operator_edges_follow_the_plan_contents() {
1863 let env = env();
1864 let (edges, checks) = operator_edges(&env, &[ResourceKind::Index], false, &[], None);
1865 assert_eq!(edges.len(), 1);
1866 assert_eq!(edges[0].role, roles::SEARCH_SERVICE_CONTRIBUTOR);
1867 assert_eq!(edges[0].principal, Principal::Operator);
1868 assert_eq!(edges[0].scope, Scope::Resolved(SEARCH_ID.into()));
1869 assert!(checks.is_empty());
1870
1871 let (verify, _) = operator_edges(&env, &[ResourceKind::Index], true, &[], None);
1872 let reader = verify
1873 .iter()
1874 .find(|e| e.role == roles::SEARCH_INDEX_DATA_READER)
1875 .expect("--verify adds the data-plane read");
1876 assert_eq!(
1877 reader.alternatives,
1878 vec![roles::SEARCH_INDEX_DATA_CONTRIBUTOR]
1879 );
1880
1881 let (foundry, _) = operator_edges(
1882 &env,
1883 &[
1884 ResourceKind::Agent,
1885 ResourceKind::Connection,
1886 ResourceKind::Deployment,
1887 ],
1888 false,
1889 &[],
1890 None,
1891 );
1892 let used: Vec<&str> = foundry.iter().map(|e| e.role.name).collect();
1893 assert_eq!(
1894 used,
1895 vec![
1896 "Foundry User",
1897 "Foundry Project Manager",
1898 "Foundry Account Owner"
1899 ]
1900 );
1901 assert!(
1902 foundry
1903 .iter()
1904 .all(|e| e.scope == Scope::Resolved(FOUNDRY_ID.into()))
1905 );
1906 let owner = foundry.last().unwrap();
1907 assert_eq!(
1908 owner.alternatives,
1909 vec![roles::COGNITIVE_SERVICES_CONTRIBUTOR]
1910 );
1911 let manager = foundry
1917 .iter()
1918 .find(|e| e.role == roles::FOUNDRY_PROJECT_MANAGER)
1919 .expect("connections need Foundry Project Manager");
1920 assert_eq!(
1921 manager.alternatives,
1922 vec![roles::COGNITIVE_SERVICES_CONTRIBUTOR]
1923 );
1924
1925 let (guardrail, _) = operator_edges(&env, &[ResourceKind::Guardrail], false, &[], None);
1926 assert_eq!(guardrail.len(), 1);
1927 assert_eq!(guardrail[0].role, roles::FOUNDRY_ACCOUNT_OWNER);
1928 }
1929
1930 #[test]
1931 fn foundry_user_is_scoped_at_the_foundry_project_when_one_is_given() {
1932 let env = env();
1933 let project = format!("{FOUNDRY_ID}/projects/p");
1934 let (edges, _) = operator_edges(
1935 &env,
1936 &[ResourceKind::Agent, ResourceKind::Connection],
1937 false,
1938 &[],
1939 Some(&project),
1940 );
1941 let user = edges
1942 .iter()
1943 .find(|e| e.role == roles::FOUNDRY_USER)
1944 .expect("agents need Foundry User");
1945 assert_eq!(user.scope, Scope::Resolved(project));
1946 let manager = edges
1948 .iter()
1949 .find(|e| e.role == roles::FOUNDRY_PROJECT_MANAGER)
1950 .expect("connections need Foundry Project Manager");
1951 assert_eq!(manager.scope, Scope::Resolved(FOUNDRY_ID.into()));
1952 }
1953
1954 #[test]
1955 fn operator_checks_are_one_can_grant_per_distinct_scope() {
1956 let env = env();
1957 let g = graph(&[
1958 (
1959 ResourceKind::DataSource,
1960 "ds",
1961 json!({
1962 "name": "ds", "type": "azureblob",
1963 "credentials": {"connectionString": conn(STORAGE_ID)},
1964 "container": {"name": "c"}
1965 }),
1966 ),
1967 (
1968 ResourceKind::Index,
1969 "idx",
1970 json!({
1971 "name": "idx", "fields": [],
1972 "vectorSearch": {"vectorizers": [{"name": "v", "kind": "azureOpenAI",
1973 "azureOpenAIParameters": {"resourceUri": "https://fndr.openai.azure.com"}}]}
1974 }),
1975 ),
1976 ]);
1977 let missing: Vec<&Edge> = g.edges.iter().collect();
1978 assert_eq!(missing.len(), 2);
1979 let (_, checks) = operator_edges(&env, &[ResourceKind::Index], false, &missing, None);
1980 assert_eq!(checks.len(), 2);
1981 assert!(
1982 checks
1983 .iter()
1984 .any(|c| c.id == format!("can-grant:{STORAGE_ID}"))
1985 );
1986 assert!(
1987 checks
1988 .iter()
1989 .any(|c| c.id == format!("can-grant:{FOUNDRY_ID}"))
1990 );
1991 assert!(checks.iter().all(|c| !c.sources.is_empty()));
1992
1993 let same: Vec<&Edge> = vec![missing[0], missing[0]];
1994 let (_, deduped) = operator_edges(&env, &[], false, &same, None);
1995 assert_eq!(deduped.len(), 1);
1996 }
1997
1998 #[test]
2001 fn edges_for_one_document_works_without_any_bindings() {
2002 let edges = edges_for(
2003 ResourceKind::Skillset,
2004 "ss",
2005 &json!({
2006 "name": "ss", "skills": [],
2007 "cognitiveServices": {
2008 "@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity",
2009 "subdomainUrl": "https://aisvc.cognitiveservices.azure.com/"
2010 }
2011 }),
2012 );
2013 assert_eq!(edges.len(), 1);
2014 assert_eq!(edges[0].role, roles::COGNITIVE_SERVICES_USER);
2015 assert!(matches!(edges[0].scope, Scope::Unresolved { .. }));
2016 assert_eq!(edges[0].scope.arm_id(), None);
2017 }
2018
2019 #[test]
2020 fn placeholder_resource_ids_are_not_scopes() {
2021 assert_eq!(
2022 parse_resource_id("ResourceId=/subscriptions/<subscription-id>/...;"),
2023 None
2024 );
2025 assert_eq!(parse_resource_id("AccountKey=zzz"), None);
2026 assert_eq!(
2027 parse_resource_id("ResourceId=/subscriptions/a/resourceGroups/b;Database=d").as_deref(),
2028 Some("/subscriptions/a/resourceGroups/b")
2029 );
2030 }
2031}