use anyhow::{Context, Result, anyhow};
use reqwest::{Client, header};
use serde::Deserialize;
use serde_json::Value;
use tracing::warn;
use crate::{cli::commands::access_map::AccessMapArgs, validation::GLOBAL_USER_AGENT};
use super::{
AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary, ResourceExposure,
RoleBinding, Severity, build_recommendations,
};
const SHOPIFY_API_VERSION: &str = "2024-10";
#[derive(Deserialize)]
struct ShopResponse {
shop: ShopInfo,
}
#[derive(Deserialize)]
struct ShopInfo {
id: Option<u64>,
name: Option<String>,
email: Option<String>,
domain: Option<String>,
plan_name: Option<String>,
}
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
let path = args.credential_path.as_deref().ok_or_else(|| {
anyhow!("Shopify access-map requires a credential file with token and subdomain")
})?;
let raw = std::fs::read_to_string(path).with_context(|| {
format!("Failed to read Shopify credential file from {}", path.display())
})?;
let (token, subdomain) = parse_shopify_credentials(&raw)?;
map_access_from_token_and_subdomain(&token, &subdomain).await
}
pub async fn map_access_from_token_and_subdomain(
token: &str,
subdomain: &str,
) -> Result<AccessMapResult> {
let subdomain = subdomain.trim().trim_matches('/').to_ascii_lowercase();
if subdomain.is_empty() {
return Err(anyhow!("Shopify access-map requires a non-empty store subdomain"));
}
let base_url = format!("https://{subdomain}.myshopify.com");
let client = Client::builder()
.user_agent(GLOBAL_USER_AGENT.as_str())
.build()
.context("Failed to build Shopify HTTP client")?;
let mut risk_notes = Vec::new();
let mut permissions = PermissionSummary::default();
let mut detected_scopes: Vec<String> = Vec::new();
let shop = fetch_shop(&client, token, &base_url).await?;
let shop_id = shop.id.map(|id| id.to_string()).unwrap_or_default();
let shop_name = shop.name.clone().unwrap_or_default();
let shop_email = shop.email.clone();
let shop_domain = shop.domain.clone();
let _plan_name = shop.plan_name.clone();
let probes =
[("orders", "read_orders"), ("customers", "read_customers"), ("products", "read_products")];
for (resource, scope) in &probes {
match probe_endpoint(&client, token, &base_url, resource).await {
Ok(true) => {
detected_scopes.push(scope.to_string());
}
Ok(false) => {}
Err(err) => {
warn!("Shopify access-map: probe for {resource} failed: {err}");
}
}
}
let admin_scopes = ["write_customers", "write_orders", "write_products", "write_script_tags"];
let risky_scopes = ["read_customers", "read_orders", "write_products"];
let read_scopes = ["read_products", "read_inventory"];
for scope in &detected_scopes {
if admin_scopes.contains(&scope.as_str()) {
permissions.admin.push(scope.clone());
} else if risky_scopes.contains(&scope.as_str()) {
permissions.risky.push(scope.clone());
} else if read_scopes.contains(&scope.as_str()) {
permissions.read_only.push(scope.clone());
}
}
permissions.admin.sort();
permissions.admin.dedup();
permissions.risky.sort();
permissions.risky.dedup();
permissions.read_only.sort();
permissions.read_only.dedup();
let has_customer_order_write =
detected_scopes.iter().any(|s| s == "write_customers" || s == "write_orders");
let has_customer_order_read =
detected_scopes.iter().any(|s| s == "read_customers" || s == "read_orders");
let severity = if has_customer_order_write {
Severity::Critical
} else if has_customer_order_read {
Severity::High
} else {
Severity::Medium
};
if has_customer_order_write {
risk_notes.push("Token has write access to customer or order data".to_string());
}
if has_customer_order_read {
risk_notes.push("Token can read customer PII or financial order data".to_string());
}
let roles = vec![RoleBinding {
name: "shopify_access_token".into(),
source: "shopify".into(),
permissions: detected_scopes.clone(),
}];
let mut resources = vec![ResourceExposure {
resource_type: "shopify_store".into(),
name: shop_name.clone(),
permissions: detected_scopes.clone(),
risk: severity_to_str(severity).to_string(),
reason: "Shopify store accessible with this token".to_string(),
}];
if detected_scopes.iter().any(|s| s == "read_customers") {
resources.push(ResourceExposure {
resource_type: "customer_data".into(),
name: format!("{subdomain} customers"),
permissions: vec!["read_customers".into()],
risk: severity_to_str(Severity::High).to_string(),
reason: "Customer PII is accessible".to_string(),
});
}
if detected_scopes.iter().any(|s| s == "read_orders") {
resources.push(ResourceExposure {
resource_type: "order_data".into(),
name: format!("{subdomain} orders"),
permissions: vec!["read_orders".into()],
risk: severity_to_str(Severity::High).to_string(),
reason: "Financial order data is accessible".to_string(),
});
}
Ok(AccessMapResult {
cloud: "shopify".into(),
identity: AccessSummary {
id: shop_email.clone().unwrap_or_else(|| shop_name.clone()),
access_type: "token".into(),
project: Some(subdomain.clone()),
tenant: shop_domain,
account_id: Some(shop_id.clone()),
},
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: Some(shop_name),
username: None,
account_type: Some("shopify_access_token".into()),
company: None,
location: None,
email: shop_email,
url: Some(base_url),
token_type: Some("access_token".into()),
created_at: None,
last_used_at: None,
expires_at: None,
user_id: Some(shop_id),
scopes: detected_scopes,
}),
provider_metadata: None,
fingerprint: None,
})
}
fn parse_shopify_credentials(raw: &str) -> Result<(String, String)> {
if let Ok(json) = serde_json::from_str::<Value>(raw) {
let token = json
.get("token")
.or_else(|| json.get("access_token"))
.or_else(|| json.get("shopify_token"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string());
let subdomain = json
.get("subdomain")
.or_else(|| json.get("store"))
.or_else(|| json.get("shop"))
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string());
if let (Some(token), Some(subdomain)) = (token, subdomain) {
return Ok((token, subdomain));
}
}
let lines: Vec<&str> = raw
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.collect();
if lines.len() >= 2 {
return Ok((lines[0].to_string(), lines[1].to_string()));
}
Err(anyhow!(
"Shopify credential format not recognized. Provide JSON with token + subdomain, or two lines (token, subdomain)."
))
}
async fn fetch_shop(client: &Client, token: &str, base_url: &str) -> Result<ShopInfo> {
let resp = client
.get(format!("{base_url}/admin/api/{SHOPIFY_API_VERSION}/shop.json"))
.header("X-Shopify-Access-Token", token)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Shopify access-map: failed to query shop endpoint")?;
if !resp.status().is_success() {
return Err(anyhow!("Shopify access-map: shop endpoint returned HTTP {}", resp.status()));
}
let shop_resp: ShopResponse =
resp.json().await.context("Shopify access-map: invalid shop JSON")?;
Ok(shop_resp.shop)
}
async fn probe_endpoint(
client: &Client,
token: &str,
base_url: &str,
resource: &str,
) -> Result<bool> {
let resp = client
.get(format!("{base_url}/admin/api/{SHOPIFY_API_VERSION}/{resource}.json?limit=1"))
.header("X-Shopify-Access-Token", token)
.header(header::ACCEPT, "application/json")
.send()
.await
.context("Shopify access-map: probe request failed")?;
Ok(resp.status().is_success())
}
fn severity_to_str(severity: Severity) -> &'static str {
match severity {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}