kingfisher/src/access_map/plaid.rs
2026-04-01 10:20:52 -07:00

291 lines
9.2 KiB
Rust

use anyhow::{anyhow, Context, Result};
use reqwest::{header, Client, StatusCode};
use serde::Deserialize;
use tracing::warn;
use crate::cli::commands::access_map::AccessMapArgs;
use crate::validation::GLOBAL_USER_AGENT;
use super::{
build_recommendations, AccessMapResult, AccessSummary, AccessTokenDetails, PermissionSummary,
ResourceExposure, RoleBinding, Severity,
};
// ---------------------------------------------------------------------------
// API response types
// ---------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
struct InstitutionsResponse {
#[serde(default)]
total: i64,
}
// ---------------------------------------------------------------------------
// Environment detection
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy)]
enum PlaidEnvironment {
Production,
Development,
Sandbox,
}
impl PlaidEnvironment {
fn host(self) -> &'static str {
match self {
PlaidEnvironment::Production => "production.plaid.com",
PlaidEnvironment::Development => "development.plaid.com",
PlaidEnvironment::Sandbox => "sandbox.plaid.com",
}
}
fn label(self) -> &'static str {
match self {
PlaidEnvironment::Production => "production",
PlaidEnvironment::Development => "development",
PlaidEnvironment::Sandbox => "sandbox",
}
}
fn severity(self) -> Severity {
match self {
PlaidEnvironment::Production => Severity::Critical,
PlaidEnvironment::Development => Severity::High,
PlaidEnvironment::Sandbox => Severity::Low,
}
}
}
// ---------------------------------------------------------------------------
// Public entry points
// ---------------------------------------------------------------------------
pub async fn map_access(args: &AccessMapArgs) -> Result<AccessMapResult> {
let path = args.credential_path.as_deref().ok_or_else(|| {
anyhow!("Plaid access-map requires a credential file with client_id and secret")
})?;
let raw = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read Plaid credential file from {}", path.display()))?;
let json: serde_json::Value = serde_json::from_str(&raw)
.context("Plaid credential file must be valid JSON with client_id and secret")?;
let client_id = json
.get("client_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("Plaid credential JSON missing 'client_id'"))?;
let secret = json
.get("secret")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("Plaid credential JSON missing 'secret'"))?;
map_access_from_credentials(client_id, secret).await
}
pub async fn map_access_from_credentials(client_id: &str, secret: &str) -> Result<AccessMapResult> {
let client = Client::builder()
.user_agent(GLOBAL_USER_AGENT.as_str())
.build()
.context("Failed to build Plaid HTTP client")?;
let mut risk_notes: Vec<String> = Vec::new();
let mut permissions = PermissionSummary::default();
// Detect environment by trying production -> development -> sandbox
let (env, institutions_total) = detect_environment(&client, client_id, secret).await?;
risk_notes.push(format!("Plaid environment: {}", env.label()));
if let Some(total) = institutions_total {
risk_notes.push(format!("Institutions available: {total}"));
}
// Classify permissions based on environment
match env {
PlaidEnvironment::Production => {
permissions.admin.push("production:api_access".to_string());
permissions.admin.push("production:real_financial_data".to_string());
}
PlaidEnvironment::Development => {
permissions.risky.push("development:api_access".to_string());
permissions.risky.push("development:test_financial_data".to_string());
}
PlaidEnvironment::Sandbox => {
permissions.read_only.push("sandbox:api_access".to_string());
permissions.read_only.push("sandbox:mock_data".to_string());
}
}
// Try item/get (will likely fail without an access_token, but that's fine)
match try_item_get(&client, client_id, secret, env).await {
Ok(()) => {
risk_notes.push("item/get endpoint is reachable".to_string());
}
Err(err) => {
// Expected to fail without access_token - not an error
warn!("Plaid access-map: item/get probe (expected to fail): {err}");
}
}
let severity = env.severity();
let roles = vec![RoleBinding {
name: format!("plaid_api_key_{}", env.label()),
source: "plaid".into(),
permissions: permissions
.admin
.iter()
.chain(permissions.risky.iter())
.chain(permissions.read_only.iter())
.cloned()
.collect(),
}];
let resources = vec![ResourceExposure {
resource_type: "plaid_account".into(),
name: format!("{client_id}@{}", env.label()),
permissions: vec![format!("{}:api_access", env.label())],
risk: severity_to_str(severity).to_string(),
reason: format!("Plaid {} API access with real financial data implications", env.label()),
}];
permissions.admin.sort();
permissions.admin.dedup();
permissions.risky.sort();
permissions.risky.dedup();
permissions.read_only.sort();
permissions.read_only.dedup();
Ok(AccessMapResult {
cloud: "plaid".into(),
identity: AccessSummary {
id: client_id.to_string(),
access_type: "api_key".into(),
project: Some(env.label().to_string()),
tenant: None,
account_id: Some(client_id.to_string()),
},
roles,
permissions,
resources,
severity,
recommendations: build_recommendations(severity),
risk_notes,
token_details: Some(AccessTokenDetails {
name: Some(format!("plaid_{}", env.label())),
token_type: Some("client_credentials".into()),
..Default::default()
}),
provider_metadata: None,
fingerprint: None,
})
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async fn detect_environment(
client: &Client,
client_id: &str,
secret: &str,
) -> Result<(PlaidEnvironment, Option<i64>)> {
// Try production first
if let Ok(resp) =
try_institutions_get(client, client_id, secret, PlaidEnvironment::Production).await
{
return Ok((PlaidEnvironment::Production, Some(resp.total)));
}
// Try development
if let Ok(resp) =
try_institutions_get(client, client_id, secret, PlaidEnvironment::Development).await
{
return Ok((PlaidEnvironment::Development, Some(resp.total)));
}
// Try sandbox
if let Ok(resp) =
try_institutions_get(client, client_id, secret, PlaidEnvironment::Sandbox).await
{
return Ok((PlaidEnvironment::Sandbox, Some(resp.total)));
}
Err(anyhow!("Plaid access-map: credentials not valid for production, development, or sandbox"))
}
async fn try_institutions_get(
client: &Client,
client_id: &str,
secret: &str,
env: PlaidEnvironment,
) -> Result<InstitutionsResponse> {
let body = serde_json::json!({
"client_id": client_id,
"secret": secret,
"count": 1,
"offset": 0,
"country_codes": ["US"],
});
let resp = client
.post(format!("https://{}/institutions/get", env.host()))
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json")
.json(&body)
.send()
.await
.with_context(|| {
format!("Plaid access-map: failed to query institutions/get on {}", env.host())
})?;
if resp.status() != StatusCode::OK {
return Err(anyhow!(
"Plaid access-map: institutions/get returned HTTP {} on {}",
resp.status(),
env.host()
));
}
resp.json::<InstitutionsResponse>()
.await
.context("Plaid access-map: invalid institutions/get JSON")
}
async fn try_item_get(
client: &Client,
client_id: &str,
secret: &str,
env: PlaidEnvironment,
) -> Result<()> {
let body = serde_json::json!({
"client_id": client_id,
"secret": secret,
"access_token": "",
});
let resp = client
.post(format!("https://{}/item/get", env.host()))
.header(header::CONTENT_TYPE, "application/json")
.header(header::ACCEPT, "application/json")
.json(&body)
.send()
.await
.context("Plaid access-map: failed to query item/get")?;
if resp.status().is_success() {
Ok(())
} else {
Err(anyhow!("Plaid access-map: item/get returned HTTP {}", resp.status()))
}
}
fn severity_to_str(severity: Severity) -> &'static str {
match severity {
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}