Skip to main content

rigg_core/
identity.rs

1//! Identity-graph extraction: which service-to-service RBAC edges does this
2//! workspace's configuration require? (spec §8.2)
3//!
4//! Edges are derived from the actual resource files — data source connection
5//! strings, knowledge-base model wiring, agent→knowledge-base grounding, and
6//! encryption key references. `rigg auth doctor` verifies and repairs them.
7
8use serde_json::Value;
9
10use crate::registry;
11use crate::resources::traits::ResourceKind;
12use crate::store::Store;
13use crate::workspace::Workspace;
14
15/// Built-in Azure role definition IDs.
16pub mod roles {
17    pub const STORAGE_BLOB_DATA_READER: (&str, &str) = (
18        "ba92f5b4-2d11-453d-a403-e96b0029c9fe",
19        "Storage Blob Data Reader",
20    );
21    pub const COGNITIVE_SERVICES_OPENAI_USER: (&str, &str) = (
22        "5e0bd9bd-7b93-4f28-af87-19fc36ad61bd",
23        "Cognitive Services OpenAI User",
24    );
25    pub const COGNITIVE_SERVICES_USER: (&str, &str) = (
26        "a97b65f3-24c7-4388-baec-2e87135dc908",
27        "Cognitive Services User",
28    );
29    pub const SEARCH_INDEX_DATA_READER: (&str, &str) = (
30        "1407120a-92aa-4202-b7e9-c0e197c71c8f",
31        "Search Index Data Reader",
32    );
33    pub const KEY_VAULT_SECRETS_USER: (&str, &str) = (
34        "4633458b-17de-408a-b874-0445c86b69e6",
35        "Key Vault Secrets User",
36    );
37}
38
39/// Whose identity must hold the role.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
41#[serde(rename_all = "kebab-case")]
42pub enum Principal {
43    SearchService,
44    FoundryProject,
45}
46
47/// How the edge can be verified.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
49#[serde(rename_all = "kebab-case")]
50pub enum EdgeKind {
51    /// ARM RBAC role assignment — verifiable and fixable.
52    Rbac,
53    /// Data-plane permission model outside ARM RBAC (Cosmos SQL roles,
54    /// Azure SQL contained users) — reported with guidance only.
55    Informational,
56}
57
58#[derive(Debug, Clone, serde::Serialize)]
59pub struct IdentityEdge {
60    pub principal: Principal,
61    /// ARM resource id of the target scope, when derivable from config.
62    pub scope: Option<String>,
63    pub target: String,
64    pub role_id: String,
65    pub role_name: String,
66    pub kind: EdgeKind,
67    pub reason: String,
68}
69
70impl IdentityEdge {
71    fn rbac(
72        principal: Principal,
73        scope: Option<String>,
74        target: impl Into<String>,
75        role: (&str, &str),
76        reason: impl Into<String>,
77    ) -> Self {
78        IdentityEdge {
79            principal,
80            scope,
81            target: target.into(),
82            role_id: role.0.to_string(),
83            role_name: role.1.to_string(),
84            kind: EdgeKind::Rbac,
85            reason: reason.into(),
86        }
87    }
88}
89
90/// Extract every identity edge the workspace's resources require, for one
91/// environment's tree.
92pub fn identity_edges(ws: &Workspace, env: &str) -> Vec<IdentityEdge> {
93    let mut edges: Vec<IdentityEdge> = Vec::new();
94    for project in &ws.projects {
95        let store = Store::new(project, env);
96        let Ok(files) = store.list() else { continue };
97        for (r, _) in files {
98            let Ok(value) = store.read(&r) else { continue };
99            collect_edges_for(r.kind, &r.name, &value, &mut edges);
100        }
101    }
102    dedup(edges)
103}
104
105/// The identity edges ONE document requires (used by push to diagnose an
106/// RBAC-shaped rejection of that document).
107pub fn edges_for(kind: ResourceKind, name: &str, value: &Value) -> Vec<IdentityEdge> {
108    let mut edges = Vec::new();
109    collect_edges_for(kind, name, value, &mut edges);
110    dedup(edges)
111}
112
113fn collect_edges_for(kind: ResourceKind, name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
114    {
115        {
116            let r = DocRef { kind, name };
117            match r.kind {
118                ResourceKind::DataSource => datasource_edges(r.name, value, edges),
119                ResourceKind::KnowledgeBase
120                    if value
121                        .get("models")
122                        .is_some_and(|m| !m.as_array().is_none_or(|a| a.is_empty()))
123                        || value.get("answerInstructions").is_some() =>
124                {
125                    edges.push(IdentityEdge::rbac(
126                        Principal::SearchService,
127                        None,
128                        "Foundry account (model access)",
129                        roles::COGNITIVE_SERVICES_USER,
130                        format!(
131                            "knowledge base '{}' uses a model for retrieval/synthesis",
132                            r.name
133                        ),
134                    ));
135                }
136                ResourceKind::Skillset => skillset_edges(r.name, value, edges),
137                ResourceKind::Index => {
138                    let has_vectorizer = value
139                        .get("vectorSearch")
140                        .and_then(|v| v.get("vectorizers"))
141                        .and_then(Value::as_array)
142                        .is_some_and(|a| !a.is_empty());
143                    if has_vectorizer {
144                        edges.push(IdentityEdge::rbac(
145                            Principal::SearchService,
146                            None,
147                            "Foundry account (model access)",
148                            roles::COGNITIVE_SERVICES_OPENAI_USER,
149                            format!("index '{}' has vectorizers", r.name),
150                        ));
151                    }
152                }
153                ResourceKind::Agent => {
154                    for (kind, name) in registry::extract_references(r.kind, value) {
155                        if kind == ResourceKind::KnowledgeBase {
156                            edges.push(IdentityEdge::rbac(
157                                Principal::FoundryProject,
158                                None,
159                                "Search service (knowledge base retrieval)",
160                                roles::SEARCH_INDEX_DATA_READER,
161                                format!("agent '{}' grounds on knowledge base '{name}'", r.name),
162                            ));
163                        }
164                    }
165                }
166                _ => {}
167            }
168            // encryption keys → Key Vault
169            if let Some(uri) = value
170                .get("encryptionKey")
171                .and_then(|k| k.get("keyVaultUri"))
172                .and_then(Value::as_str)
173            {
174                edges.push(IdentityEdge::rbac(
175                    Principal::SearchService,
176                    None,
177                    format!("Key Vault {uri}"),
178                    roles::KEY_VAULT_SECRETS_USER,
179                    format!(
180                        "{} '{}' uses customer-managed encryption",
181                        r.kind.display_name(),
182                        r.name
183                    ),
184                ));
185            }
186        }
187    }
188}
189
190/// Borrowed (kind, name) pair for edge extraction.
191struct DocRef<'a> {
192    kind: ResourceKind,
193    name: &'a str,
194}
195
196/// Skillset edges: Azure OpenAI skills need model access on the Foundry
197/// account; an identity-based `cognitiveServices` connection
198/// (AIServicesByIdentity) needs 'Cognitive Services User' on that specific
199/// AI services account (named by its subdomain URL).
200fn skillset_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
201    if value.to_string().contains("AzureOpenAI") {
202        edges.push(IdentityEdge::rbac(
203            Principal::SearchService,
204            None,
205            "Foundry account (model access)",
206            roles::COGNITIVE_SERVICES_OPENAI_USER,
207            format!("skillset '{name}' calls Azure OpenAI"),
208        ));
209    }
210    // Custom Web API skills: AAD auth via `authResourceId` means the search
211    // identity must be authorized on the target app — app-level, not ARM
212    // RBAC, so informational.
213    if let Some(skills) = value.get("skills").and_then(Value::as_array) {
214        for skill in skills {
215            let is_webapi = skill
216                .get("@odata.type")
217                .and_then(Value::as_str)
218                .is_some_and(|t| t.ends_with("WebApiSkill"));
219            if !is_webapi {
220                continue;
221            }
222            let uri = skill.get("uri").and_then(Value::as_str).unwrap_or("?");
223            if let Some(auth) = skill.get("authResourceId").and_then(Value::as_str) {
224                if !auth.trim().is_empty() {
225                    edges.push(IdentityEdge {
226                        principal: Principal::SearchService,
227                        scope: None,
228                        target: format!("custom Web API {uri}"),
229                        role_id: String::new(),
230                        role_name: "app authorization (AAD)".into(),
231                        kind: EdgeKind::Informational,
232                        reason: format!(
233                            "skillset '{name}' calls a custom Web API with AAD auth \
234                             (authResourceId: {auth}) — the search identity must be \
235                             assigned/permitted on that app registration"
236                        ),
237                    });
238                }
239            } else {
240                edges.push(IdentityEdge {
241                    principal: Principal::SearchService,
242                    scope: None,
243                    target: format!("custom Web API {uri}"),
244                    role_id: String::new(),
245                    role_name: "endpoint authorization".into(),
246                    kind: EdgeKind::Informational,
247                    reason: format!(
248                        "skillset '{name}' calls a custom Web API without AAD auth \
249                         (no authResourceId) — the endpoint must accept the call \
250                         (function key in httpHeaders, or make it identity-based \
251                         with authResourceId)"
252                    ),
253                });
254            }
255        }
256    }
257    let cs = value.get("cognitiveServices");
258    let identity_based = cs
259        .and_then(|c| c.get("@odata.type"))
260        .and_then(Value::as_str)
261        .is_some_and(|t| t.ends_with("AIServicesByIdentity"));
262    if identity_based {
263        if let Some(account) = cs
264            .and_then(|c| c.get("subdomainUrl"))
265            .and_then(Value::as_str)
266            .and_then(ai_services_account_from_subdomain)
267        {
268            edges.push(IdentityEdge::rbac(
269                Principal::SearchService,
270                None,
271                format!("AI services account '{account}'"),
272                roles::COGNITIVE_SERVICES_USER,
273                format!("skillset '{name}' uses identity-based AI services enrichment"),
274            ));
275        }
276    }
277}
278
279/// `https://<name>.cognitiveservices.azure.com/` → `<name>`.
280fn ai_services_account_from_subdomain(url: &str) -> Option<String> {
281    url.strip_prefix("https://")
282        .and_then(|rest| rest.split('.').next())
283        .filter(|s| !s.is_empty())
284        .map(str::to_string)
285}
286
287fn datasource_edges(name: &str, value: &Value, edges: &mut Vec<IdentityEdge>) {
288    let ds_type = value
289        .get("type")
290        .and_then(Value::as_str)
291        .unwrap_or_default();
292    let conn = value
293        .get("credentials")
294        .and_then(|c| c.get("connectionString"))
295        .and_then(Value::as_str)
296        .unwrap_or_default();
297    let scope = parse_resource_id(conn);
298    match ds_type {
299        "azureblob" | "adlsgen2" | "azurefile" | "azurefiles" | "azuretable" => {
300            edges.push(IdentityEdge::rbac(
301                Principal::SearchService,
302                scope.clone(),
303                scope
304                    .clone()
305                    .unwrap_or_else(|| "storage account (set ResourceId= in the connection string)".into()),
306                roles::STORAGE_BLOB_DATA_READER,
307                format!("data source '{name}' ({ds_type}) reads from storage"),
308            ));
309        }
310        "cosmosdb" => edges.push(IdentityEdge {
311            principal: Principal::SearchService,
312            scope: scope.clone(),
313            target: scope.unwrap_or_else(|| "Cosmos DB account".into()),
314            role_id: String::new(),
315            role_name: "Cosmos DB Built-in Data Reader (SQL role)".into(),
316            kind: EdgeKind::Informational,
317            reason: format!(
318                "data source '{name}' reads Cosmos DB — grant via `az cosmosdb sql role assignment create` \
319                 (Cosmos data-plane roles are not ARM RBAC)"
320            ),
321        }),
322        "azuresql" => edges.push(IdentityEdge {
323            principal: Principal::SearchService,
324            scope: scope.clone(),
325            target: scope.unwrap_or_else(|| "Azure SQL database".into()),
326            role_id: String::new(),
327            role_name: "db_datareader (contained AAD user)".into(),
328            kind: EdgeKind::Informational,
329            reason: format!(
330                "data source '{name}' reads Azure SQL — CREATE USER [search-service-name] FROM EXTERNAL PROVIDER; \
331                 ALTER ROLE db_datareader ADD MEMBER [...] (SQL AAD users are not ARM RBAC)"
332            ),
333        }),
334        _ => {}
335    }
336}
337
338/// Parse `ResourceId=/subscriptions/...;<rest>` connection strings.
339pub fn parse_resource_id(conn: &str) -> Option<String> {
340    let start = conn.find("ResourceId=")? + "ResourceId=".len();
341    let rest = &conn[start..];
342    let end = rest.find(';').unwrap_or(rest.len());
343    let id = rest[..end].trim();
344    (id.starts_with("/subscriptions/") && !id.contains('<')).then(|| id.to_string())
345}
346
347fn dedup(edges: Vec<IdentityEdge>) -> Vec<IdentityEdge> {
348    let mut seen = std::collections::BTreeSet::new();
349    edges
350        .into_iter()
351        .filter(|e| {
352            seen.insert((
353                format!("{:?}", e.principal),
354                e.scope.clone().unwrap_or_else(|| e.target.clone()),
355                e.role_id.clone(),
356                e.role_name.clone(),
357            ))
358        })
359        .collect()
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::resources::traits::ResourceRef;
366    use crate::workspace::{PROJECT_FILE, PROJECTS_DIR, WORKSPACE_FILE};
367    use serde_json::json;
368
369    fn ws_with(resources: &[(ResourceKind, &str, Value)]) -> (tempfile::TempDir, Workspace) {
370        let tmp = tempfile::tempdir().unwrap();
371        std::fs::write(
372            tmp.path().join(WORKSPACE_FILE),
373            "environments:\n  dev:\n    default: true\n    search: { service: s }\n    foundry: { account: f, project: p }\n",
374        )
375        .unwrap();
376        let pdir = tmp.path().join(PROJECTS_DIR).join("demo");
377        std::fs::create_dir_all(&pdir).unwrap();
378        std::fs::write(pdir.join(PROJECT_FILE), "{}\n").unwrap();
379        let ws = Workspace::load(tmp.path()).unwrap();
380        {
381            let store = Store::new(ws.project("demo").unwrap(), "dev");
382            for (kind, name, value) in resources {
383                store.write(&ResourceRef::new(*kind, *name), value).unwrap();
384            }
385        }
386        let ws = Workspace::load(tmp.path()).unwrap();
387        (tmp, ws)
388    }
389
390    #[test]
391    fn blob_datasource_yields_storage_edge_with_scope() {
392        let (_tmp, ws) = ws_with(&[(
393            ResourceKind::DataSource,
394            "ds",
395            json!({
396                "name": "ds",
397                "type": "azureblob",
398                "credentials": {"connectionString": "ResourceId=/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct;"},
399                "container": {"name": "c"}
400            }),
401        )]);
402        let edges = identity_edges(&ws, "dev");
403        assert_eq!(edges.len(), 1);
404        let e = &edges[0];
405        assert_eq!(e.principal, Principal::SearchService);
406        assert_eq!(e.kind, EdgeKind::Rbac);
407        assert_eq!(e.role_name, "Storage Blob Data Reader");
408        assert_eq!(
409            e.scope.as_deref(),
410            Some(
411                "/subscriptions/s1/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/acct"
412            )
413        );
414    }
415
416    #[test]
417    fn cosmos_and_sql_are_informational() {
418        let (_tmp, ws) = ws_with(&[
419            (
420                ResourceKind::DataSource,
421                "cds",
422                json!({"name": "cds", "type": "cosmosdb", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.DocumentDB/databaseAccounts/c;Database=d"}, "container": {"name": "x"}}),
423            ),
424            (
425                ResourceKind::DataSource,
426                "sds",
427                json!({"name": "sds", "type": "azuresql", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Sql/servers/sv;Database=d"}, "container": {"name": "t"}}),
428            ),
429        ]);
430        let edges = identity_edges(&ws, "dev");
431        assert_eq!(edges.len(), 2);
432        assert!(edges.iter().all(|e| e.kind == EdgeKind::Informational));
433    }
434
435    #[test]
436    fn kb_models_agent_grounding_and_vectorizers() {
437        let (_tmp, ws) = ws_with(&[
438            (
439                ResourceKind::KnowledgeBase,
440                "kb",
441                json!({"name": "kb", "models": [{"kind": "azureOpenAI"}], "knowledgeSources": [{"name": "ks"}]}),
442            ),
443            (
444                ResourceKind::Agent,
445                "agent",
446                json!({"name": "agent", "model": "m", "tools": [{"type": "mcp", "x-rigg-ref": "knowledge-bases/kb"}]}),
447            ),
448            (
449                ResourceKind::Index,
450                "idx",
451                json!({"name": "idx", "fields": [], "vectorSearch": {"vectorizers": [{"name": "v"}]}}),
452            ),
453        ]);
454        let edges = identity_edges(&ws, "dev");
455        let roles: Vec<&str> = edges.iter().map(|e| e.role_name.as_str()).collect();
456        assert!(roles.contains(&"Cognitive Services User"), "{roles:?}");
457        assert!(roles.contains(&"Search Index Data Reader"), "{roles:?}");
458        assert!(
459            roles.contains(&"Cognitive Services OpenAI User"),
460            "{roles:?}"
461        );
462        let grounding = edges
463            .iter()
464            .find(|e| e.role_name == "Search Index Data Reader")
465            .unwrap();
466        assert_eq!(grounding.principal, Principal::FoundryProject);
467    }
468
469    #[test]
470    fn skillset_identity_ai_services_yields_account_edge() {
471        let (_tmp, ws) = ws_with(&[(
472            ResourceKind::Skillset,
473            "ss",
474            json!({"name": "ss", "skills": [], "cognitiveServices": {
475                "@odata.type": "#Microsoft.Azure.Search.AIServicesByIdentity",
476                "subdomainUrl": "https://myaisvc.cognitiveservices.azure.com/"
477            }}),
478        )]);
479        let edges = identity_edges(&ws, "dev");
480        let edge = edges
481            .iter()
482            .find(|e| e.target == "AI services account 'myaisvc'")
483            .expect("ai services edge");
484        assert_eq!(edge.kind, EdgeKind::Rbac);
485        assert_eq!(edge.role_name, "Cognitive Services User");
486        assert_eq!(edge.principal, Principal::SearchService);
487    }
488
489    #[test]
490    fn skillset_webapi_edges_are_informational() {
491        let (_tmp, ws) = ws_with(&[(
492            ResourceKind::Skillset,
493            "ss",
494            json!({"name": "ss", "skills": [
495                {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
496                 "uri": "https://fn.azurewebsites.net/api/enrich",
497                 "authResourceId": "api://12345"},
498                {"@odata.type": "#Microsoft.Skills.Custom.WebApiSkill",
499                 "uri": "https://fn2.azurewebsites.net/api/other"}
500            ]}),
501        )]);
502        let edges = identity_edges(&ws, "dev");
503        let webapi: Vec<_> = edges
504            .iter()
505            .filter(|e| e.target.starts_with("custom Web API"))
506            .collect();
507        assert_eq!(webapi.len(), 2);
508        assert!(webapi.iter().all(|e| e.kind == EdgeKind::Informational));
509        assert!(
510            webapi
511                .iter()
512                .any(|e| e.reason.contains("authResourceId: api://12345"))
513        );
514        assert!(webapi.iter().any(|e| e.reason.contains("without AAD auth")));
515    }
516
517    #[test]
518    fn placeholder_resource_ids_are_not_scopes() {
519        assert_eq!(
520            parse_resource_id("ResourceId=/subscriptions/<subscription-id>/...;"),
521            None
522        );
523        assert_eq!(parse_resource_id("AccountKey=zzz"), None);
524        assert_eq!(
525            parse_resource_id("ResourceId=/subscriptions/a/resourceGroups/b;Database=d").as_deref(),
526            Some("/subscriptions/a/resourceGroups/b")
527        );
528    }
529
530    #[test]
531    fn duplicate_edges_dedup() {
532        let ds = json!({"name": "ds", "type": "azureblob", "credentials": {"connectionString": "ResourceId=/subscriptions/s/resourceGroups/r/providers/Microsoft.Storage/storageAccounts/a;"}, "container": {"name": "c"}});
533        let mut ds2 = ds.clone();
534        ds2["name"] = json!("ds2");
535        let (_tmp, ws) = ws_with(&[
536            (ResourceKind::DataSource, "ds", ds),
537            (ResourceKind::DataSource, "ds2", ds2),
538        ]);
539        assert_eq!(
540            identity_edges(&ws, "dev").len(),
541            1,
542            "same scope+role dedups"
543        );
544    }
545}