Skip to main content

rigg_core/
identity.rs

1//! The identity graph (spec §3): which service-identity roles does this
2//! configuration require, on which scopes, and what settings must hold?
3//!
4//! Edges are derived from the documents themselves — every infrastructure
5//! reference the registry knows about ([`crate::infra::extract`]) — and
6//! scoped through the environment's bindings ([`EnvBindings`]), so a scope is
7//! an ARM id whenever the binding resolves and an explicit "unbound" marker
8//! when it does not. `rigg auth doctor` verifies and repairs them; `rigg push`
9//! runs the same graph as a preflight over its plan.
10
11use 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
21/// Built-in Azure role definitions, by GUID (spec §3.2).
22pub mod roles {
23    /// A role definition: its built-in GUID and its display name.
24    #[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    // The three Foundry roles carry the names Microsoft renamed them to
87    // (Azure AI User / Project Manager / Account Owner → Foundry …). The
88    // GUIDs are unchanged and are what rigg assigns and prints: Microsoft's
89    // own guidance during the rename rollout is to use the role definition
90    // id rather than the name, because a name resolves against the tenant's
91    // role definitions and those are renamed on their own schedule.
92    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    /// Not an ARM role: the marker carried by [`super::EdgeKind::AppAuthorization`]
103    /// edges, where authorization lives in an Entra app registration (Easy
104    /// Auth) rather than in a role assignment.
105    pub const APP_AUTHORIZATION: Role = role("app-authorization", "app authorization (Entra)");
106}
107
108/// Whose identity must hold the role (spec §3.1).
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
110pub enum Principal {
111    /// The search service's system-assigned managed identity.
112    SearchSystem,
113    /// A user-assigned managed identity named by an `identity`/`authIdentity`
114    /// field; `binding` is the environment binding that owns it (or the bare
115    /// physical name when nothing binds it).
116    SearchUser { binding: String },
117    /// The Foundry project's system-assigned managed identity.
118    FoundryProject,
119    /// The caller (az login user, or the service principal in CI).
120    Operator,
121    /// Any named principal (`--principal`), e.g. a CI identity.
122    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/// Where a role must be assigned: an ARM id when the binding resolves, or an
144/// explicit "not bound / not resolved yet" marker so callers can say exactly
145/// what is missing instead of dropping the requirement.
146#[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        /// The binding that names this resource — empty when nothing in the
153        /// environment binds it at all.
154        binding: String,
155        kind: Option<BindingType>,
156        physical: String,
157    },
158}
159
160impl Scope {
161    /// The ARM id, when this scope resolved to one.
162    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    /// Stable key for edge/check identity and de-duplication.
170    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    /// Human phrasing for reports.
180    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/// A requirement that is not a role assignment but still gates the edge.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
200#[serde(rename_all = "kebab-case")]
201pub enum Constraint {
202    /// The referenced Cognitive Services account must be of kind `AIServices`.
203    AiServicesKindRequired,
204    /// Storage's trusted-services exception works only with the search
205    /// service's system-assigned identity — a UAMI cannot use it.
206    TrustedServiceNeedsSystemIdentity,
207    /// The feature this edge comes from exists on the preview channel only.
208    PreviewOnly(&'static str),
209}
210
211/// The file evidence for an edge or check: which document, and where in it.
212#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
213pub struct Source {
214    pub kind: ResourceKind,
215    pub name: String,
216    /// Concrete (indexed) document path, e.g. `skills[2].uri`.
217    pub path: String,
218}
219
220/// How an edge is verified.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
222#[serde(rename_all = "kebab-case")]
223pub enum EdgeKind {
224    /// An ARM role assignment — verifiable and fixable.
225    Rbac,
226    /// Entra app authorization (Easy Auth audience) rather than ARM RBAC.
227    AppAuthorization,
228    /// Reported with guidance only.
229    Informational,
230}
231
232/// One identity requirement: principal, role, scope, and why.
233#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
234pub struct Edge {
235    /// Stable id: `"<principal>|<role id>|<scope key>"`.
236    pub id: String,
237    pub principal: Principal,
238    pub role: roles::Role,
239    /// Roles that satisfy this edge instead of `role` (operator edges only).
240    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/// A setting or network condition that must hold (spec §3.3), plus the
292/// operator's own ability to grant a role (spec §3.2).
293#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
294#[serde(rename_all = "camelCase", tag = "check")]
295pub enum CheckKind {
296    /// Free SKU has no managed identity; knowledge bases need Basic+.
297    SearchSku,
298    /// A system-assigned identity (or the referenced UAMI) must exist.
299    SearchIdentity,
300    /// The service must accept bearer tokens (`authOptions.aadOrApiKey` or
301    /// `disableLocalAuth`).
302    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    /// The operator can create a role assignment at this scope
326    /// (`Microsoft.Authorization/roleAssignments/write`).
327    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/// One setting/network/permission requirement, with its file evidence.
359#[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/// The identity graph for a set of documents: service-identity edges, the
384/// settings they depend on, and (when computed) the operator's own edges.
385#[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
392// ---------------------------------------------------------------------
393// graph construction
394// ---------------------------------------------------------------------
395
396/// Accumulates edges and checks, de-duplicating by id and merging evidence.
397struct 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
456/// One document under inspection, with its infrastructure references
457/// already extracted at concrete paths.
458struct 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    /// The reference at exactly `path`, if the document has one there.
479    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    /// The value at a concrete (indexed) path, e.g. `skills[2].authResourceId`.
488    /// Segments are split on `.`, so keys that contain one (`@odata.type`)
489    /// must be read from the value this returns rather than addressed here.
490    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
506/// `a.b.resourceUri` → `a.b.<leaf>` — the sibling field of an infra
507/// reference (its `authIdentity`/`identity` companion).
508fn 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
515/// Which principal an `identity`/`authIdentity` reference names: the binding
516/// that owns the user-assigned identity, or the search service's
517/// system-assigned identity when there is no such reference.
518fn 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
532/// The scope a reference to `physical` resolves to, through the
533/// environment's bindings.
534fn 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
560/// The environment's implicit `search` target as a scope.
561fn 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
567/// The environment's implicit `foundry` account as a scope.
568fn 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
574/// Build the identity graph for `docs` (spec §3.2 and §3.3). `operator` is
575/// left empty — operator edges depend on the plan, see [`operator_edges`].
576pub 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
603/// Row 1 — a blob data source reads from its storage account.
604fn 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
647/// Row 2 — a blob knowledge source reads its container, and writes its
648/// enrichment asset store when it declares one.
649fn 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
689/// Row 3 — a skillset's knowledge store writes projections.
690fn 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
740/// A storage edge, with the trusted-services constraint attached whenever a
741/// user-assigned identity is used (the exception works only with the search
742/// service's system-assigned identity — spec §3.3).
743fn 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
788/// Rows 4 and 5 — model access on the host named by a `resourceUri`.
789fn 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        // Chat completion (knowledge base answer synthesis, knowledge-source
798        // verbalization) uses the Cognitive Services User role; embeddings
799        // use the narrower Azure OpenAI User role.
800        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
819/// Row 6 — identity-based AI services enrichment.
820fn 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
852/// Row 7 — a Web API skill authenticating with a managed identity needs the
853/// target app to accept its token (Easy Auth), not an ARM role.
854fn 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
925/// Row 8 — an agent reaching a knowledge base through an MCP connection that
926/// authenticates with the Foundry project's own identity.
927fn 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        // The connection must authenticate with the project's identity. When
946        // the connection is not among `docs` (a plan-scoped graph), the edge
947        // is kept — an unverifiable requirement beats a silently dropped one.
948        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
979/// Row 9 — customer-managed encryption keys without an explicit credential.
980fn 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    // `encryptionKey.identity` (a `DataUserAssignedIdentity`) names the
993    // identity the service uses to reach the vault; without it the key is
994    // fetched with the system-assigned identity.
995    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
1010/// Every model deployment must be available (model, version, region, quota).
1011fn 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
1030/// The three checks every environment with a search target carries.
1031fn 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
1054// ---------------------------------------------------------------------
1055// operator edges
1056// ---------------------------------------------------------------------
1057
1058/// The operator's own rights for a plan (spec §3.2): what the caller must
1059/// hold to apply `kinds_in_plan`, plus — for every edge in `needs_grants` —
1060/// the ability to create a role assignment at that edge's scope.
1061///
1062/// `verify` covers the data-plane reads `rigg push --verify`, `rigg query`
1063/// and `rigg ask` perform.
1064///
1065/// `foundry_project_id` is the ARM id of the environment's Foundry *project*
1066/// (`<account id>/projects/<project>`), which the binding table does not
1067/// carry — the caller resolves it. Foundry User is scoped there (spec
1068/// §3.2); the account-level roles stay on the account. Without it the
1069/// project-scoped edge falls back to the account, where an `atScope()` check
1070/// would miss a project-only assignment.
1071pub 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            // Creating a project connection is a pure control-plane write.
1125            // Foundry Project Manager carries `dataActions:
1126            // ["Microsoft.CognitiveServices/*"]`, which no subscription
1127            // Owner covers; Cognitive Services Contributor is control-plane
1128            // only, so Owner/Contributor satisfy this edge through their
1129            // effective permissions.
1130            .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
1168// ---------------------------------------------------------------------
1169// compatibility
1170// ---------------------------------------------------------------------
1171
1172/// The identity edges ONE document requires, with no environment bindings —
1173/// scopes resolve only where the document itself is unambiguous. Used by
1174/// push to diagnose an RBAC-shaped rejection of that document.
1175pub 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
1180/// Parse `ResourceId=/subscriptions/...;<rest>` connection strings.
1181pub 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    /// An environment whose dependency bindings are declared as full ARM ids
1213    /// and whose implicit `search`/`foundry` targets come from a resolution
1214    /// cache — so every scope in these tests resolves.
1215    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    // ---- row 1: data source ----------------------------------------
1300
1301    #[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    // ---- row 2: knowledge source -----------------------------------
1392
1393    #[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    // ---- row 3: knowledge store ------------------------------------
1419
1420    #[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    // ---- row 4: embeddings -----------------------------------------
1474
1475    #[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    // ---- row 5: chat completion ------------------------------------
1538
1539    #[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    // ---- row 6: AI services ----------------------------------------
1572
1573    #[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    // ---- row 7: Web API skills -------------------------------------
1595
1596    #[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    // ---- row 8: agent → connection → search ------------------------
1628
1629    #[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    // ---- row 9: customer-managed keys ------------------------------
1669
1670    #[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    /// I-5: `encryptionKey.identity` names the identity that fetches the
1707    /// key, exactly like every other sibling `identity`/`authIdentity`
1708    /// field — the CMK edge (and therefore `--fix`) must follow it rather
1709    /// than grant the system identity a role it never uses.
1710    #[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    // ---- scopes ----------------------------------------------------
1739
1740    #[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    // ---- checks ----------------------------------------------------
1839
1840    #[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    // ---- operator edges --------------------------------------------
1860
1861    #[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        // I-1: creating a project connection is a pure control-plane write.
1912        // Azure's Foundry Project Manager definition carries
1913        // `dataActions: ["Microsoft.CognitiveServices/*"]`, which no
1914        // subscription Owner covers, so the edge names a control-plane
1915        // alternative that Owner/Contributor do cover.
1916        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        // The account-level role stays on the account.
1947        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    // ---- compatibility ---------------------------------------------
1999
2000    #[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}