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