use std::fs;
use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
use serde::Serialize;
use crate::output::CommandResult;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Warning,
Info,
}
#[derive(Debug, Clone, Serialize)]
pub struct Recommendation {
pub category: &'static str,
pub severity: Severity,
pub message: String,
pub suggestion: String,
}
#[derive(Debug, Serialize)]
pub struct SchemaFacts {
pub types: usize,
pub fields: usize,
pub queries: usize,
pub mutations: usize,
pub subscriptions: usize,
pub multi_tenant: bool,
}
#[derive(Debug, Serialize)]
pub struct AnalysisSummary {
pub total_recommendations: usize,
pub warnings: usize,
pub health_score: usize,
}
#[derive(Debug, Serialize)]
pub struct AnalysisResult {
pub schema_file: String,
pub recommendations: Vec<Recommendation>,
pub facts: SchemaFacts,
pub summary: AnalysisSummary,
}
pub fn run(schema_path: &str) -> Result<CommandResult> {
let contents = fs::read_to_string(schema_path)
.with_context(|| format!("cannot read schema file `{schema_path}`"))?;
let schema: CompiledSchema = serde_json::from_str(&contents).with_context(|| {
format!(
"`{schema_path}` is not a compiled schema. Run `fraiseql compile` first and pass the \
resulting schema.compiled.json."
)
})?;
let facts = collect_facts(&schema);
let mut recommendations = security_findings(&schema);
recommendations.extend(complexity_findings(&facts));
recommendations.extend(authorization_findings(&schema));
let warnings = recommendations.iter().filter(|r| r.severity == Severity::Warning).count();
let health_score = 100_usize.saturating_sub(warnings.saturating_mul(10)).max(10);
let analysis = AnalysisResult {
schema_file: schema_path.to_string(),
summary: AnalysisSummary {
total_recommendations: recommendations.len(),
warnings,
health_score,
},
recommendations,
facts,
};
Ok(CommandResult::success("analyze", serde_json::to_value(&analysis)?))
}
fn collect_facts(schema: &CompiledSchema) -> SchemaFacts {
SchemaFacts {
types: schema.types.len(),
fields: schema.types.iter().map(|t| t.fields.len()).sum(),
queries: schema.queries.len(),
mutations: schema.mutations.len(),
subscriptions: schema.subscriptions.len(),
multi_tenant: schema.is_multi_tenant(),
}
}
fn security_findings(schema: &CompiledSchema) -> Vec<Recommendation> {
const CATEGORY: &str = "security";
let mut out = Vec::new();
let sec = schema.security.as_ref();
for (flag, label, suggestion) in [
(
sec.and_then(|s| s.rate_limiting.as_ref()).map(|c| c.enabled),
"Rate limiting",
"Add [fraiseql.security.rate_limiting] with enabled = true to throttle per-IP \
request rates.",
),
(
sec.and_then(|s| s.enterprise.as_ref()).map(|e| e.audit_logging_enabled),
"Audit logging",
"Add [fraiseql.security.enterprise] with audit_logging_enabled = true to record \
security events.",
),
(
sec.and_then(|s| s.error_sanitization.as_ref()).map(|c| c.enabled),
"Error sanitization",
"Add [fraiseql.security.error_sanitization] with enabled = true so internal error \
detail is not returned to clients.",
),
] {
out.push(match flag {
Some(true) => Recommendation {
category: CATEGORY,
severity: Severity::Info,
message: format!("{label} is enabled in the compiled schema."),
suggestion: "No action needed.".to_string(),
},
Some(false) => Recommendation {
category: CATEGORY,
severity: Severity::Warning,
message: format!("{label} is present in the compiled schema but disabled."),
suggestion: suggestion.to_string(),
},
None => Recommendation {
category: CATEGORY,
severity: Severity::Warning,
message: format!("{label} is not configured in this schema."),
suggestion: suggestion.to_string(),
},
});
}
if schema.is_multi_tenant() {
let rls_declared = schema.security.as_ref().is_some_and(|s| s.rls.enabled);
out.push(if rls_declared {
Recommendation {
category: CATEGORY,
severity: Severity::Info,
message: "Schema is multi-tenant and declares row-level security.".to_string(),
suggestion: "The server verifies the declaration against the live database at \
boot; `fraiseql doctor --against-db` checks it ahead of deployment."
.to_string(),
}
} else {
Recommendation {
category: CATEGORY,
severity: Severity::Warning,
message: "Schema is multi-tenant but declares no row-level security."
.to_string(),
suggestion: "Declare [security.rls] and install the matching policies; without \
them tenant isolation rests on query construction alone."
.to_string(),
}
});
}
out
}
fn complexity_findings(facts: &SchemaFacts) -> Vec<Recommendation> {
const CATEGORY: &str = "complexity";
let mut out = vec![Recommendation {
category: CATEGORY,
severity: Severity::Info,
message: format!(
"{} type(s), {} field(s), {} query root field(s), {} mutation(s), {} \
subscription(s).",
facts.types, facts.fields, facts.queries, facts.mutations, facts.subscriptions
),
suggestion: "Compare against what you expect this deployment to expose.".to_string(),
}];
if facts.types == 0 {
out.push(Recommendation {
category: CATEGORY,
severity: Severity::Warning,
message: "The schema declares no object types.".to_string(),
suggestion: "Check that the compile step picked up your type definitions.".to_string(),
});
}
out
}
fn authorization_findings(schema: &CompiledSchema) -> Vec<Recommendation> {
let gated = schema.types.iter().filter(|t| t.requires_role.is_some()).count();
let roles = schema.security.as_ref().map_or(0, |s| s.role_definitions.len());
vec![Recommendation {
category: "security",
severity: Severity::Info,
message: format!(
"{gated} of {} type(s) declare `requires_role`; {roles} role definition(s) are declared.",
schema.types.len()
),
suggestion: if gated == 0 {
"Types with no `requires_role` are reachable by any authenticated caller; gate the ones that should not be."
.to_string()
} else {
"Check that every gated type's role is one the identity provider actually issues."
.to_string()
},
}]
}
#[cfg(test)]
mod tests;