feat(import): resolve references and schema composition
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
use std::fmt;
|
||||
|
||||
use crate::{
|
||||
AdminProcessConfig, CacheSettings, DatabaseSettings, McpProcessConfig, MetricsSettings,
|
||||
MigratorConfig, ObservabilitySettings, OtlpSettings, OutboundSettings, RuntimeSettings,
|
||||
AdminProcessConfig, CacheSettings, DatabaseSettings, ExternalReferenceSettings,
|
||||
McpProcessConfig, MetricsSettings, MigratorConfig, ObservabilitySettings, OtlpSettings,
|
||||
OutboundSettings, RuntimeSettings,
|
||||
};
|
||||
|
||||
impl fmt::Debug for DatabaseSettings {
|
||||
@@ -32,6 +33,18 @@ impl fmt::Debug for OutboundSettings {
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for ExternalReferenceSettings {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("ExternalReferenceSettings")
|
||||
.field("allowed_url_prefix_count", &self.allowed_url_prefixes.len())
|
||||
.field("max_depth", &self.max_depth)
|
||||
.field("max_documents", &self.max_documents)
|
||||
.field("max_fetch_bytes", &self.max_fetch_bytes)
|
||||
.field("fetch_timeout_ms", &self.fetch_timeout_ms)
|
||||
.field("max_expanded_nodes", &self.max_expanded_nodes)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for RuntimeSettings {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("RuntimeSettings")
|
||||
@@ -93,6 +106,7 @@ impl fmt::Debug for AdminProcessConfig {
|
||||
f.debug_struct("AdminProcessConfig")
|
||||
.field("database", &self.database)
|
||||
.field("runtime", &self.runtime)
|
||||
.field("external_references", &self.external_references)
|
||||
.field("observability", &self.observability)
|
||||
.field("storage_root", &"configured")
|
||||
.field("session_secret", &self.session_secret)
|
||||
|
||||
@@ -15,8 +15,9 @@ pub use diagnostic::{ConfigError, Diagnostic, DiagnosticCode};
|
||||
pub use migrator::{MigratorConfig, parse_migrator};
|
||||
pub use process::{
|
||||
AdminProcessConfig, CacheBackend, CacheSettings, DatabaseSettings, DeprecationRecord,
|
||||
EffectiveConfig, McpProcessConfig, MetricsSettings, ObservabilitySettings, OtlpSettings,
|
||||
OutboundSettings, PoolSettings, ProcessKind, RateLimitSettings, RuntimeSettings, parse_process,
|
||||
EffectiveConfig, ExternalReferenceSettings, McpProcessConfig, MetricsSettings,
|
||||
ObservabilitySettings, OtlpSettings, OutboundSettings, PoolSettings, ProcessKind,
|
||||
RateLimitSettings, RuntimeSettings, parse_process,
|
||||
};
|
||||
pub use schema::{
|
||||
FieldMode, FieldSpec, ProcessScope, Sensitivity, deployment_field_registry, field_registry,
|
||||
|
||||
@@ -12,7 +12,9 @@ use std::{
|
||||
path::PathBuf,
|
||||
};
|
||||
use url::Url;
|
||||
mod external_references;
|
||||
mod list_parsers;
|
||||
pub use external_references::ExternalReferenceSettings;
|
||||
const MAX_ENV_VALUE_BYTES: usize = 8_192;
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ProcessKind {
|
||||
@@ -127,6 +129,7 @@ pub struct ObservabilitySettings {
|
||||
pub struct AdminProcessConfig {
|
||||
pub database: DatabaseSettings,
|
||||
pub runtime: RuntimeSettings,
|
||||
pub external_references: ExternalReferenceSettings,
|
||||
pub observability: ObservabilitySettings,
|
||||
pub bind_addr: SocketAddr,
|
||||
pub storage_root: PathBuf,
|
||||
@@ -154,8 +157,8 @@ pub struct McpProcessConfig {
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Projection {
|
||||
Admin(AdminProcessConfig),
|
||||
Mcp(McpProcessConfig),
|
||||
Admin(Box<AdminProcessConfig>),
|
||||
Mcp(Box<McpProcessConfig>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -790,9 +793,10 @@ pub fn parse_process(
|
||||
{
|
||||
parser.push(DiagnosticCode::UnsafeCombination, "admin.exposure.tls");
|
||||
}
|
||||
Projection::Admin(AdminProcessConfig {
|
||||
Projection::Admin(Box::new(AdminProcessConfig {
|
||||
database,
|
||||
runtime,
|
||||
external_references: external_references::parse(&mut parser),
|
||||
observability,
|
||||
bind_addr,
|
||||
storage_root: parser.absolute_path("CRANK_STORAGE_ROOT", "/var/lib/crank/storage"),
|
||||
@@ -811,7 +815,7 @@ pub fn parse_process(
|
||||
bootstrap_display_name: parser
|
||||
.string("CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME", Some("Crank Owner")),
|
||||
demo_seed: parser.boolean("CRANK_DEMO_SEED"),
|
||||
})
|
||||
}))
|
||||
}
|
||||
ProcessKind::McpServer => {
|
||||
let rps = parser.number("CRANK_MCP_RATE_LIMIT_RPS") as u32;
|
||||
@@ -819,7 +823,7 @@ pub fn parse_process(
|
||||
if burst < rps {
|
||||
parser.push(DiagnosticCode::UnsafeCombination, "mcp.rate_limit.burst");
|
||||
}
|
||||
Projection::Mcp(McpProcessConfig {
|
||||
Projection::Mcp(Box::new(McpProcessConfig {
|
||||
database,
|
||||
runtime,
|
||||
observability,
|
||||
@@ -829,7 +833,7 @@ pub fn parse_process(
|
||||
requests_per_second: rps,
|
||||
burst,
|
||||
},
|
||||
})
|
||||
}))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -867,6 +871,18 @@ fn fingerprint_parts(kind: ProcessKind, projection: &Projection) -> Vec<String>
|
||||
format!("session_ttl={}", config.session_ttl_hours),
|
||||
format!("trusted_proxies={:?}", config.trusted_proxy_ips),
|
||||
format!("demo={}", config.demo_seed),
|
||||
format!(
|
||||
"external_reference_prefixes={}",
|
||||
config.external_references.allowed_url_prefixes.join(",")
|
||||
),
|
||||
format!(
|
||||
"external_reference_limits={}:{}:{}:{}:{}",
|
||||
config.external_references.max_depth,
|
||||
config.external_references.max_documents,
|
||||
config.external_references.max_fetch_bytes,
|
||||
config.external_references.fetch_timeout_ms,
|
||||
config.external_references.max_expanded_nodes,
|
||||
),
|
||||
"storage=path-configured".to_owned(),
|
||||
format!("session_secret={}", config.session_secret.is_configured()),
|
||||
format!("pepper={}", config.password_pepper.is_configured()),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct ExternalReferenceSettings {
|
||||
/// Canonical HTTP(S) URL prefixes which opt an operator into remote `$ref` fetches.
|
||||
/// An empty list is a deliberate default-deny switch.
|
||||
pub allowed_url_prefixes: Vec<String>,
|
||||
pub max_depth: usize,
|
||||
pub max_documents: usize,
|
||||
pub max_fetch_bytes: usize,
|
||||
pub fetch_timeout_ms: u64,
|
||||
pub max_expanded_nodes: usize,
|
||||
}
|
||||
|
||||
pub(super) fn parse(parser: &mut super::Parser<'_>) -> ExternalReferenceSettings {
|
||||
ExternalReferenceSettings {
|
||||
allowed_url_prefixes: parser
|
||||
.url_prefix_list("CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES"),
|
||||
max_depth: parser.number("CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DEPTH") as usize,
|
||||
max_documents: parser.number("CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DOCUMENTS") as usize,
|
||||
max_fetch_bytes: parser.number("CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_FETCH_BYTES") as usize,
|
||||
fetch_timeout_ms: parser.number("CRANK_IMPORT_EXTERNAL_REFERENCE_FETCH_TIMEOUT_MS"),
|
||||
max_expanded_nodes: parser.number("CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_EXPANDED_NODES")
|
||||
as usize,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
DiagnosticCode,
|
||||
validation::{parse_host_list, parse_ip_list},
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
impl super::Parser<'_> {
|
||||
pub(super) fn host_list(&mut self, name: &'static str) -> Vec<String> {
|
||||
@@ -33,4 +34,41 @@ impl super::Parser<'_> {
|
||||
}
|
||||
parsed.items
|
||||
}
|
||||
|
||||
pub(super) fn url_prefix_list(&mut self, name: &'static str) -> Vec<String> {
|
||||
let Some(raw) = self.optional(name) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut prefixes = Vec::new();
|
||||
let mut invalid = false;
|
||||
for item in raw.split(',') {
|
||||
let item = item.trim();
|
||||
let Ok(url) = Url::parse(item) else {
|
||||
invalid = true;
|
||||
continue;
|
||||
};
|
||||
if !matches!(url.scheme(), "http" | "https")
|
||||
|| url.host_str().is_none()
|
||||
|| !url.username().is_empty()
|
||||
|| url.password().is_some()
|
||||
|| url.query().is_some()
|
||||
|| url.fragment().is_some()
|
||||
{
|
||||
invalid = true;
|
||||
continue;
|
||||
}
|
||||
let canonical = url.to_string();
|
||||
if !prefixes.contains(&canonical) {
|
||||
prefixes.push(canonical);
|
||||
}
|
||||
}
|
||||
if invalid {
|
||||
self.push(DiagnosticCode::InvalidType, name);
|
||||
}
|
||||
if prefixes.len() > 64 {
|
||||
self.push(DiagnosticCode::OutOfRange, name);
|
||||
prefixes.truncate(64);
|
||||
}
|
||||
prefixes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ macro_rules! f {
|
||||
};
|
||||
}
|
||||
|
||||
static FIELDS: [FieldSpec; 59] = [
|
||||
static FIELDS: [FieldSpec; 65] = [
|
||||
FieldSpec {
|
||||
compatibility: Some("legacy URL form"),
|
||||
rules: &[
|
||||
@@ -339,6 +339,78 @@ static FIELDS: [FieldSpec; 59] = [
|
||||
Some(67108864),
|
||||
Public
|
||||
),
|
||||
FieldSpec {
|
||||
rules: &[
|
||||
"empty list disables external OpenAPI reference fetching",
|
||||
"each prefix must be canonical HTTP(S) without userinfo, query, or fragment",
|
||||
],
|
||||
..f!(
|
||||
"import.external_references.allowed_url_prefixes",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES",
|
||||
AdminApi,
|
||||
"url_prefix_list",
|
||||
None,
|
||||
Some(""),
|
||||
None,
|
||||
None,
|
||||
Internal
|
||||
)
|
||||
},
|
||||
f!(
|
||||
"import.external_references.max_depth",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DEPTH",
|
||||
AdminApi,
|
||||
"u32",
|
||||
Some("edges"),
|
||||
Some("8"),
|
||||
Some(1),
|
||||
Some(32),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"import.external_references.max_documents",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DOCUMENTS",
|
||||
AdminApi,
|
||||
"u32",
|
||||
Some("documents"),
|
||||
Some("32"),
|
||||
Some(1),
|
||||
Some(32),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"import.external_references.max_fetch_bytes",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_FETCH_BYTES",
|
||||
AdminApi,
|
||||
"u64",
|
||||
Some("bytes"),
|
||||
Some("262144"),
|
||||
Some(1),
|
||||
Some(4194304),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"import.external_references.fetch_timeout_ms",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_FETCH_TIMEOUT_MS",
|
||||
AdminApi,
|
||||
"u64",
|
||||
Some("milliseconds"),
|
||||
Some("10000"),
|
||||
Some(1),
|
||||
Some(300000),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"import.external_references.max_expanded_nodes",
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_EXPANDED_NODES",
|
||||
AdminApi,
|
||||
"u32",
|
||||
Some("nodes"),
|
||||
Some("10000"),
|
||||
Some(1),
|
||||
Some(100000),
|
||||
Public
|
||||
),
|
||||
f!(
|
||||
"observability.environment",
|
||||
"CRANK_ENVIRONMENT",
|
||||
|
||||
@@ -108,9 +108,64 @@ fn source_for(
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_covers_exactly_the_59_observed_runtime_names() {
|
||||
fn external_reference_contract_is_default_off_and_validates_prefixes_and_limits() {
|
||||
let config = parse_process(
|
||||
ProcessKind::AdminApi,
|
||||
ConfigSource::from_utf8(required_admin()),
|
||||
)
|
||||
.expect("the external reference contract has safe defaults");
|
||||
let references = &config.admin().unwrap().external_references;
|
||||
assert!(references.allowed_url_prefixes.is_empty());
|
||||
assert_eq!(references.max_depth, 8);
|
||||
assert_eq!(references.max_documents, 32);
|
||||
assert_eq!(references.max_fetch_bytes, 262_144);
|
||||
assert_eq!(references.fetch_timeout_ms, 10_000);
|
||||
assert_eq!(references.max_expanded_nodes, 10_000);
|
||||
|
||||
let mut allowed = required_admin();
|
||||
allowed.insert(
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES".to_owned(),
|
||||
"https://schemas.example.test/openapi/,https://schemas.example.test/openapi/".to_owned(),
|
||||
);
|
||||
let config = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(allowed)).unwrap();
|
||||
assert_eq!(
|
||||
config
|
||||
.admin()
|
||||
.unwrap()
|
||||
.external_references
|
||||
.allowed_url_prefixes,
|
||||
vec!["https://schemas.example.test/openapi/".to_owned()]
|
||||
);
|
||||
|
||||
let mut invalid = required_admin();
|
||||
invalid.insert(
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES".to_owned(),
|
||||
"https://user:secret@schemas.example.test/openapi/".to_owned(),
|
||||
);
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(invalid))
|
||||
.expect_err("userinfo must never become an operator allow rule");
|
||||
assert!(error.diagnostics().iter().any(|diagnostic| {
|
||||
diagnostic.code == DiagnosticCode::InvalidType
|
||||
&& diagnostic.field == "import.external_references.allowed_url_prefixes"
|
||||
}));
|
||||
|
||||
let mut out_of_range = required_admin();
|
||||
out_of_range.insert(
|
||||
"CRANK_IMPORT_EXTERNAL_REFERENCE_MAX_DOCUMENTS".to_owned(),
|
||||
"33".to_owned(),
|
||||
);
|
||||
let error = parse_process(ProcessKind::AdminApi, ConfigSource::from_utf8(out_of_range))
|
||||
.expect_err("graph document limit must be bounded");
|
||||
assert!(error.diagnostics().iter().any(|diagnostic| {
|
||||
diagnostic.code == DiagnosticCode::OutOfRange
|
||||
&& diagnostic.field == "import.external_references.max_documents"
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_covers_exactly_the_65_observed_runtime_names() {
|
||||
let registry = field_registry();
|
||||
assert_eq!(registry.len(), 59);
|
||||
assert_eq!(registry.len(), 65);
|
||||
let unique = registry
|
||||
.iter()
|
||||
.map(|field| field.env_name)
|
||||
|
||||
@@ -30,6 +30,9 @@ fn generated_reference_distinguishes_required_and_optional_fields() {
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_LOG_LEVEL` | `observability.log_filter` | `Shared` | `string/-` | `blank` |"
|
||||
));
|
||||
assert!(reference.contains(
|
||||
"| `CRANK_IMPORT_EXTERNAL_REFERENCE_ALLOWED_URL_PREFIXES` | `import.external_references.allowed_url_prefixes` | `AdminApi` | `url_prefix_list/-` | `` |"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user