From f38df8a953f476806a08d5f8a7a3f841f0d065e9 Mon Sep 17 00:00:00 2001 From: Mick Grove Date: Thu, 19 Feb 2026 19:36:43 -0800 Subject: [PATCH] added more access-maps --- CHANGELOG.md | 2 +- .../kingfisher-rules/data/rules/anthropic.yml | 1 + src/access_map/anthropic.rs | 156 +++++- src/access_map/openai.rs | 506 ++++++++++++++---- 4 files changed, 558 insertions(+), 107 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f58095f..d217f01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. - Added/updated `pipedrive` and `amplitude` rules - Access Map: added Buildkite provider. Enumerates token scopes, user identity, organizations, and pipelines with severity classification based on scope risk. - Access Map: added Harness provider. Uses `x-api-key` authentication to enumerate organizations/projects when permitted (best-effort). -- Access Map: added OpenAI provider. Supports standalone `access-map openai` and automatic mapping for validated `kingfisher.openai.*` findings. +- Access Map: added OpenAI provider. Supports standalone `access-map openai` and automatic mapping for validated `kingfisher.openai.*` findings. Enumerates organizations (from `/v1/me`), projects, and API key permission scopes by probing endpoints for restricted key detection. - Access Map: added Anthropic provider. Supports standalone `access-map anthropic` and automatic mapping for validated `kingfisher.anthropic.*` findings. - Access Map: added Salesforce provider. Supports standalone `access-map salesforce` (token + instance) and automatic mapping for validated `kingfisher.salesforce.*` findings. - Access Map CLI: added providers `buildkite`, `harness`, `openai`, `anthropic`, `salesforce`. diff --git a/crates/kingfisher-rules/data/rules/anthropic.yml b/crates/kingfisher-rules/data/rules/anthropic.yml index 860de92..6cd0e3e 100644 --- a/crates/kingfisher-rules/data/rules/anthropic.yml +++ b/crates/kingfisher-rules/data/rules/anthropic.yml @@ -46,4 +46,5 @@ rules: - type: WordMatch words: - '"type":"message"' + - 'credit balance is too low' url: https://api.anthropic.com/v1/messages \ No newline at end of file diff --git a/src/access_map/anthropic.rs b/src/access_map/anthropic.rs index f07bd2b..1130de2 100644 --- a/src/access_map/anthropic.rs +++ b/src/access_map/anthropic.rs @@ -28,6 +28,32 @@ struct AnthropicModel { display_name: Option, } +#[derive(Debug, Deserialize, Default, Clone)] +struct AnthropicApiKey { + #[serde(default)] + id: Option, + #[serde(default)] + name: Option, + #[serde(default)] + created_at: Option, + #[serde(default)] + permissions: Vec, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct AnthropicApiKeysResponse { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Default, Clone)] +struct KeyIntrospection { + permissions: Vec, + id: Option, + name: Option, + created_at: Option, +} + pub async fn map_access(args: &AccessMapArgs) -> Result { let token = if let Some(path) = args.credential_path.as_deref() { let raw = std::fs::read_to_string(path) @@ -50,6 +76,12 @@ pub async fn map_access_from_token(token: &str) -> Result { let mut roles = Vec::new(); let mut permissions = PermissionSummary::default(); let mut resources = Vec::new(); + let key_info = fetch_key_permissions(&client, token).await.unwrap_or_else(|err| { + warn!("Anthropic access-map: key permission lookup failed: {err}"); + risk_notes.push(format!("Key permission lookup failed: {err}")); + KeyIntrospection::default() + }); + let mut token_scopes = key_info.permissions.clone(); let models = list_models(&client, token).await.unwrap_or_else(|err| { warn!("Anthropic access-map: model enumeration failed: {err}"); @@ -63,6 +95,20 @@ pub async fn map_access_from_token(token: &str) -> Result { source: "anthropic".into(), permissions: vec![format!("token:{token_kind}")], }); + + token_scopes.sort(); + token_scopes.dedup(); + for scope in &token_scopes { + roles.push(RoleBinding { + name: format!("permission:{scope}"), + source: "anthropic".into(), + permissions: vec![format!("key:{scope}")], + }); + match scope.as_str() { + "full_access" => permissions.admin.push("key:full_access".to_string()), + _ => permissions.risky.push(format!("key:{scope}")), + } + } permissions.read_only.push("models:list".to_string()); for model in models.iter().take(MAX_MODEL_RESOURCES) { @@ -101,7 +147,7 @@ pub async fn map_access_from_token(token: &str) -> Result { permissions.read_only.sort(); permissions.read_only.dedup(); - let severity = Severity::Low; + let severity = derive_severity(&permissions); Ok(AccessMapResult { cloud: "anthropic".into(), @@ -119,7 +165,7 @@ pub async fn map_access_from_token(token: &str) -> Result { recommendations: build_recommendations(severity), risk_notes, token_details: Some(AccessTokenDetails { - name: None, + name: key_info.name, username: None, account_type: Some("api_key".into()), company: None, @@ -127,11 +173,11 @@ pub async fn map_access_from_token(token: &str) -> Result { email: None, url: Some("https://console.anthropic.com/settings/keys".into()), token_type: Some(token_kind.to_string()), - created_at: None, + created_at: key_info.created_at, last_used_at: None, expires_at: None, - user_id: None, - scopes: Vec::new(), + user_id: key_info.id, + scopes: token_scopes, }), provider_metadata: None, fingerprint: None, @@ -170,6 +216,106 @@ fn detect_token_type(token: &str) -> &'static str { } } +async fn fetch_key_permissions(client: &Client, token: &str) -> Result { + if let Ok(Some(key)) = fetch_permissions_from_endpoint( + client, + token, + &format!("{ANTHROPIC_API}/organizations/api_keys/me"), + ) + .await + { + return Ok(KeyIntrospection { + permissions: key.permissions, + id: key.id, + name: key.name, + created_at: key.created_at, + }); + } + + if let Ok(Some(key)) = + fetch_permissions_from_endpoint(client, token, &format!("{ANTHROPIC_API}/api_keys/me")) + .await + { + return Ok(KeyIntrospection { + permissions: key.permissions, + id: key.id, + name: key.name, + created_at: key.created_at, + }); + } + + let list_resp = client + .get(format!("{ANTHROPIC_API}/organizations/api_keys")) + .header("x-api-key", token) + .header("anthropic-version", ANTHROPIC_VERSION) + .header(header::ACCEPT, "application/json") + .send() + .await + .context("Anthropic access-map: failed to list API keys")?; + + if !list_resp.status().is_success() { + return Err(anyhow!( + "Anthropic access-map: API key listing failed with HTTP {}", + list_resp.status() + )); + } + + let body: AnthropicApiKeysResponse = + list_resp.json().await.context("Anthropic access-map: invalid API key list JSON")?; + + if body.data.len() == 1 { + let key = &body.data[0]; + return Ok(KeyIntrospection { + permissions: key.permissions.clone(), + id: key.id.clone(), + name: key.name.clone(), + created_at: key.created_at.clone(), + }); + } + + Err(anyhow!("Anthropic access-map: unable to map listed key permissions to this token")) +} + +async fn fetch_permissions_from_endpoint( + client: &Client, + token: &str, + url: &str, +) -> Result> { + let resp = client + .get(url) + .header("x-api-key", token) + .header("anthropic-version", ANTHROPIC_VERSION) + .header(header::ACCEPT, "application/json") + .send() + .await + .with_context(|| format!("Anthropic access-map: failed to query {url}"))?; + + if !resp.status().is_success() { + return Ok(None); + } + + let body: AnthropicApiKey = resp + .json() + .await + .with_context(|| format!("Anthropic access-map: invalid API key JSON from {url}"))?; + + if body.permissions.is_empty() { + Ok(None) + } else { + Ok(Some(body)) + } +} + +fn derive_severity(permissions: &PermissionSummary) -> Severity { + if !permissions.admin.is_empty() { + return Severity::High; + } + if !permissions.risky.is_empty() { + return Severity::Medium; + } + Severity::Low +} + fn severity_to_str(severity: Severity) -> &'static str { match severity { Severity::Low => "low", diff --git a/src/access_map/openai.rs b/src/access_map/openai.rs index a6b30b6..6a50402 100644 --- a/src/access_map/openai.rs +++ b/src/access_map/openai.rs @@ -1,8 +1,7 @@ -use std::collections::BTreeSet; - use anyhow::{anyhow, Context, Result}; -use reqwest::{header, Client, StatusCode}; +use reqwest::{header, Client, Method, StatusCode}; use serde::Deserialize; +use serde_json::json; use tracing::warn; use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT}; @@ -13,7 +12,10 @@ use super::{ }; const OPENAI_API: &str = "https://api.openai.com/v1"; -const MAX_MODEL_RESOURCES: usize = 50; + +// --------------------------------------------------------------------------- +// Deserialization types +// --------------------------------------------------------------------------- #[derive(Debug, Deserialize, Default, Clone)] struct OpenAiMe { @@ -23,20 +25,33 @@ struct OpenAiMe { name: Option, #[serde(default)] email: Option, -} - -#[derive(Debug, Deserialize, Default, Clone)] -struct OpenAiModelsResponse { #[serde(default)] - data: Vec, + #[allow(dead_code)] + role: Option, + #[serde(default)] + orgs: Option, } #[derive(Debug, Deserialize, Default, Clone)] -struct OpenAiModel { +struct OpenAiOrgsData { + #[serde(default)] + data: Vec, +} + +#[derive(Debug, Deserialize, Default, Clone)] +struct OpenAiOrg { #[serde(default)] id: Option, - #[serde(default, rename = "owned_by")] - owned_by: Option, + #[serde(default)] + title: Option, + #[serde(default)] + name: Option, + #[serde(default)] + personal: Option, + #[serde(default)] + is_default: Option, + #[serde(default)] + role: Option, } #[derive(Debug, Deserialize, Default, Clone)] @@ -55,6 +70,267 @@ struct OpenAiProject { archived: bool, } +// --------------------------------------------------------------------------- +// Scope probing +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct ScopeResult { + /// Human-readable scope name (e.g. "/v1/models"). + scope: &'static str, + /// Individual endpoints covered by this scope. + endpoints: Vec<&'static str>, + /// "Read", "Write", or "Read & Write". + permission: &'static str, +} + +struct EndpointProbe { + path: &'static str, + method: Method, + body: Option, +} + +/// Returns true when the status indicates the scope is **not** granted. +fn is_scope_denied(status: StatusCode) -> bool { + status == StatusCode::FORBIDDEN || status == StatusCode::UNAUTHORIZED +} + +async fn probe_endpoint(client: &Client, token: &str, probe: &EndpointProbe) -> bool { + let url = format!("{OPENAI_API}{}", probe.path); + let mut req = client + .request(probe.method.clone(), &url) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::ACCEPT, "application/json"); + + if let Some(body) = &probe.body { + req = req.header(header::CONTENT_TYPE, "application/json").json(body); + } + + match req.send().await { + Ok(resp) => !is_scope_denied(resp.status()), + Err(_) => false, + } +} + +async fn probe_api_scopes(client: &Client, token: &str) -> (Vec, bool) { + let mut scopes = Vec::new(); + let mut any_denied = false; + + // -- /v1/models (Read) -- + let models_ok = probe_endpoint( + client, + token, + &EndpointProbe { path: "/models", method: Method::GET, body: None }, + ) + .await; + if models_ok { + scopes.push(ScopeResult { + scope: "/v1/models", + endpoints: vec!["/v1/models"], + permission: "Read", + }); + } else { + any_denied = true; + } + + // -- Model capabilities (Write) – one probe covers the whole scope -- + let chat_ok = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/chat/completions", + method: Method::POST, + body: Some(json!({"model": "_probe_"})), + }, + ) + .await; + if chat_ok { + scopes.push(ScopeResult { + scope: "/v1/model_capabilities", + endpoints: vec![ + "/v1/audio", + "/v1/chat/completions", + "/v1/embeddings", + "/v1/images", + "/v1/moderations", + ], + permission: "Write", + }); + } else { + any_denied = true; + } + + // -- /v1/assistants (Read & Write) -- + let assist_read = probe_endpoint( + client, + token, + &EndpointProbe { path: "/assistants", method: Method::GET, body: None }, + ) + .await; + let assist_write = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/assistants", + method: Method::POST, + body: Some(json!({"model": "_probe_"})), + }, + ) + .await; + push_rw_scope( + &mut scopes, + &mut any_denied, + "/v1/assistants", + &["/v1/assistants"], + assist_read, + assist_write, + ); + + // -- /v1/threads (Read & Write) – read via fake thread GET -- + let threads_read = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/threads/thread_00000000000000000000000000", + method: Method::GET, + body: None, + }, + ) + .await; + let threads_write = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/threads", + method: Method::POST, + body: Some(json!({"metadata": {"_probe": "1"}})), + }, + ) + .await; + push_rw_scope( + &mut scopes, + &mut any_denied, + "/v1/threads", + &["/v1/threads"], + threads_read, + threads_write, + ); + + // -- /v1/fine_tuning (Read & Write) -- + let ft_read = probe_endpoint( + client, + token, + &EndpointProbe { path: "/fine_tuning/jobs", method: Method::GET, body: None }, + ) + .await; + let ft_write = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/fine_tuning/jobs", + method: Method::POST, + body: Some(json!({"model": "_probe_", "training_file": "_probe_"})), + }, + ) + .await; + push_rw_scope( + &mut scopes, + &mut any_denied, + "/v1/fine_tuning", + &["/v1/fine_tuning"], + ft_read, + ft_write, + ); + + // -- /v1/files (Read & Write) – write needs multipart so only probe read -- + let files_read = probe_endpoint( + client, + token, + &EndpointProbe { path: "/files", method: Method::GET, body: None }, + ) + .await; + push_rw_scope( + &mut scopes, + &mut any_denied, + "/v1/files", + &["/v1/files"], + files_read, + files_read, + ); + + // -- /v1/evals (Read & Write) -- + let evals_read = probe_endpoint( + client, + token, + &EndpointProbe { path: "/evals", method: Method::GET, body: None }, + ) + .await; + let evals_write = probe_endpoint( + client, + token, + &EndpointProbe { path: "/evals", method: Method::POST, body: Some(json!({})) }, + ) + .await; + push_rw_scope( + &mut scopes, + &mut any_denied, + "/v1/evals", + &["/v1/evals"], + evals_read, + evals_write, + ); + + // -- /v1/responses (Write) -- + let responses_ok = probe_endpoint( + client, + token, + &EndpointProbe { + path: "/responses", + method: Method::POST, + body: Some(json!({"model": "_probe_", "input": "x"})), + }, + ) + .await; + if responses_ok { + scopes.push(ScopeResult { + scope: "/v1/responses", + endpoints: vec!["/v1/responses"], + permission: "Write", + }); + } else { + any_denied = true; + } + + (scopes, any_denied) +} + +fn push_rw_scope( + scopes: &mut Vec, + any_denied: &mut bool, + scope: &'static str, + endpoints: &[&'static str], + read: bool, + write: bool, +) { + let permission = match (read, write) { + (true, true) => "Read & Write", + (true, false) => "Read", + (false, true) => "Write", + (false, false) => { + *any_denied = true; + return; + } + }; + if !read || !write { + *any_denied = true; + } + scopes.push(ScopeResult { scope, endpoints: endpoints.to_vec(), permission }); +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + pub async fn map_access(args: &AccessMapArgs) -> Result { let token = if let Some(path) = args.credential_path.as_deref() { let raw = std::fs::read_to_string(path) @@ -78,22 +354,11 @@ pub async fn map_access_from_token(token: &str) -> Result { let mut permissions = PermissionSummary::default(); let mut resources = Vec::new(); - let models_result = list_models(&client, token).await; - let me_result = fetch_me(&client, token).await; - if models_result.is_err() && me_result.is_err() { - return Err(anyhow!( - "OpenAI access-map: both /models and /me lookups failed; token may not be valid for access mapping" - )); - } - - let models = models_result.unwrap_or_else(|err| { - warn!("OpenAI access-map: model enumeration failed: {err}"); - risk_notes.push(format!("Model enumeration failed: {err}")); - Vec::new() - }); - let me = me_result.unwrap_or_else(|err| { + // -- Identity & organizations (/v1/me) -- + let me = fetch_me(&client, token).await.unwrap_or_else(|err| { warn!("OpenAI access-map: /me lookup failed: {err}"); - risk_notes.push(format!("Identity lookup failed: {err}")); + risk_notes + .push(format!("Identity lookup failed (key may be a restricted project key): {err}")); OpenAiMe::default() }); @@ -104,44 +369,44 @@ pub async fn map_access_from_token(token: &str) -> Result { permissions: vec![format!("token:{token_kind}")], }); - permissions.read_only.push("models:list".to_string()); + let orgs = me.orgs.as_ref().map(|o| o.data.clone()).unwrap_or_default(); + for org in &orgs { + let org_id = org.id.as_deref().unwrap_or("unknown"); + let org_title = org.title.as_deref().or(org.name.as_deref()).unwrap_or("unknown"); + let org_role = org.role.as_deref().unwrap_or("unknown"); + let is_default = org.is_default.unwrap_or(false); + let is_personal = org.personal.unwrap_or(false); + + let label = + if is_personal { format!("{org_title} (Personal)") } else { org_title.to_string() }; + + let risk = match org_role { + "owner" => Severity::High, + "reader" => Severity::Low, + _ => Severity::Medium, + }; + + resources.push(ResourceExposure { + resource_type: "organization".into(), + name: format!("{org_id} — {label}"), + permissions: vec![format!("role:{org_role}"), format!("default:{is_default}")], + risk: severity_to_str(risk).to_string(), + reason: format!("Organization membership with {org_role} role"), + }); + + if org_role == "owner" { + permissions.admin.push(format!("org:{org_id}:owner")); + } + } + + // -- Projects -- let projects = list_projects(&client, token).await.unwrap_or_else(|err| { warn!("OpenAI access-map: project enumeration failed: {err}"); risk_notes.push(format!("Project enumeration failed: {err}")); Vec::new() }); - if !projects.is_empty() { - permissions.risky.push("projects:list".to_string()); - } - - let identity_id = me - .email - .clone() - .or_else(|| me.name.clone()) - .or_else(|| me.id.clone()) - .unwrap_or_else(|| "openai_api_key".to_string()); - - let mut owners = BTreeSet::new(); - for model in &models { - if let Some(owner) = model.owned_by.as_ref() { - if !owner.is_empty() { - owners.insert(owner.clone()); - } - } - } - - for owner in owners { - resources.push(ResourceExposure { - resource_type: "organization".into(), - name: owner, - permissions: vec!["models:list".to_string()], - risk: severity_to_str(Severity::Low).to_string(), - reason: "Organization inferred from accessible models".to_string(), - }); - } - for project in &projects { let project_name = project .name @@ -158,28 +423,55 @@ pub async fn map_access_from_token(token: &str) -> Result { }); } - let mut model_count = 0usize; - for model in &models { - if model_count >= MAX_MODEL_RESOURCES { - break; - } - if let Some(model_id) = model.id.as_ref() { + if !projects.is_empty() { + permissions.read_only.push("projects:list".to_string()); + } + + // -- API key scope probing -- + let (scope_results, is_restricted) = probe_api_scopes(&client, token).await; + + if is_restricted { + risk_notes.push("Restricted API key — limited permissions available".into()); + } else if !scope_results.is_empty() { + risk_notes.push("Unrestricted API key — all scopes available".into()); + } + + let mut scope_labels = Vec::new(); + let has_model_capabilities = scope_results.iter().any(|s| s.scope == "/v1/model_capabilities"); + + for sr in &scope_results { + let scope_tag = + format!("{}:{}", sr.scope, sr.permission.to_lowercase().replace(" & ", "_")); + scope_labels.push(scope_tag.clone()); + + for ep in &sr.endpoints { resources.push(ResourceExposure { - resource_type: "model".into(), - name: model_id.clone(), - permissions: vec!["model:read".to_string()], - risk: severity_to_str(Severity::Low).to_string(), - reason: "Model accessible to this OpenAI key".to_string(), + resource_type: "api_scope".into(), + name: ep.to_string(), + permissions: vec![sr.permission.to_string()], + risk: if sr.permission.contains("Write") { "medium".into() } else { "low".into() }, + reason: format!("Endpoint accessible under scope {}", sr.scope), }); - model_count += 1; + } + + match sr.permission { + "Read" => permissions.read_only.push(scope_tag), + "Write" => permissions.risky.push(scope_tag), + "Read & Write" => { + permissions.read_only.push(format!("{}:read", sr.scope)); + permissions.risky.push(format!("{}:write", sr.scope)); + } + _ => {} } } - if models.len() > MAX_MODEL_RESOURCES { - risk_notes.push(format!( - "Model resource list truncated to first {MAX_MODEL_RESOURCES} entries ({} total models visible)", - models.len() - )); - } + + // -- Identity -- + let identity_id = me + .email + .clone() + .or_else(|| me.name.clone()) + .or_else(|| me.id.clone()) + .unwrap_or_else(|| "openai_api_key".to_string()); if resources.is_empty() { resources.push(ResourceExposure { @@ -189,9 +481,25 @@ pub async fn map_access_from_token(token: &str) -> Result { risk: severity_to_str(Severity::Low).to_string(), reason: "OpenAI account associated with this API key".to_string(), }); - risk_notes.push("No projects, organizations, or models were enumerable".to_string()); } + // -- Risk notes -- + if has_model_capabilities { + risk_notes.push( + "Key can make inference requests (chat completions, embeddings, images, audio, moderations)" + .into(), + ); + } + if scope_results.iter().any(|s| s.scope == "/v1/fine_tuning" && s.permission.contains("Write")) + { + risk_notes + .push("Key can create fine-tuning jobs (potential training data exfiltration)".into()); + } + if scope_results.iter().any(|s| s.scope == "/v1/files" && s.permission.contains("Write")) { + risk_notes.push("Key can upload files".into()); + } + + // -- Severity -- permissions.admin.sort(); permissions.admin.dedup(); permissions.risky.sort(); @@ -199,13 +507,13 @@ pub async fn map_access_from_token(token: &str) -> Result { permissions.read_only.sort(); permissions.read_only.dedup(); - let severity = derive_severity(&permissions, projects.len(), models.len()); + let severity = derive_severity(&permissions, &orgs, has_model_capabilities); Ok(AccessMapResult { cloud: "openai".into(), identity: AccessSummary { id: identity_id, - access_type: "token".into(), + access_type: token_kind.into(), project: None, tenant: None, account_id: me.id.clone(), @@ -229,30 +537,16 @@ pub async fn map_access_from_token(token: &str) -> Result { last_used_at: None, expires_at: None, user_id: me.id, - scopes: Vec::new(), + scopes: scope_labels, }), provider_metadata: None, fingerprint: None, }) } -async fn list_models(client: &Client, token: &str) -> Result> { - let resp = client - .get(format!("{OPENAI_API}/models")) - .header(header::AUTHORIZATION, format!("Bearer {token}")) - .header(header::ACCEPT, "application/json") - .send() - .await - .context("OpenAI access-map: failed to list models")?; - - if !resp.status().is_success() { - return Err(anyhow!("OpenAI access-map: model listing failed with HTTP {}", resp.status())); - } - - let body: OpenAiModelsResponse = - resp.json().await.context("OpenAI access-map: invalid model list JSON")?; - Ok(body.data) -} +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- async fn fetch_me(client: &Client, token: &str) -> Result { let resp = client @@ -293,6 +587,10 @@ async fn list_projects(client: &Client, token: &str) -> Result &'static str { if token.starts_with("sk-proj-") { "project_api_key" @@ -305,14 +603,20 @@ fn detect_token_type(token: &str) -> &'static str { } } -fn derive_severity(permissions: &PermissionSummary, projects: usize, models: usize) -> Severity { - if !permissions.admin.is_empty() { +fn derive_severity( + permissions: &PermissionSummary, + orgs: &[OpenAiOrg], + has_model_capabilities: bool, +) -> Severity { + let is_org_owner = orgs.iter().any(|o| o.role.as_deref() == Some("owner")); + + if !permissions.admin.is_empty() || is_org_owner { return Severity::High; } - if !permissions.risky.is_empty() || projects > 0 { + if has_model_capabilities || !permissions.risky.is_empty() { return Severity::Medium; } - if models > 0 { + if !permissions.read_only.is_empty() { return Severity::Low; } Severity::Low