diff --git a/Cargo.lock b/Cargo.lock index d71fdc3..b3d0133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aead" version = "0.5.2" @@ -141,6 +147,18 @@ dependencies = [ "term", ] +[[package]] +name = "async-compression" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "atomic" version = "0.6.1" @@ -548,6 +566,23 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "compression-codecs" +version = "0.4.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "convert_case" version = "0.10.0" @@ -592,6 +627,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "critical-section" version = "1.2.0" @@ -970,6 +1014,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -990,7 +1044,7 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "forge-auth" -version = "0.2.0" +version = "0.3.0" dependencies = [ "axum", "forge-types", @@ -1006,7 +1060,7 @@ dependencies = [ [[package]] name = "forge-bin" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aws-lc-rs", "clap", @@ -1019,6 +1073,8 @@ dependencies = [ "forge-storage", "forge-types", "pasetors", + "rpassword", + "serde_json", "tempfile", "tokio", "toml", @@ -1028,10 +1084,11 @@ dependencies = [ [[package]] name = "forge-cli" -version = "0.2.0" +version = "0.3.0" dependencies = [ "crossterm", "forge-auth", + "forge-query", "forge-security", "forge-storage", "forge-types", @@ -1045,7 +1102,7 @@ dependencies = [ [[package]] name = "forge-protocol" -version = "0.2.0" +version = "0.3.0" dependencies = [ "forge-security", "forge-types", @@ -1059,7 +1116,7 @@ dependencies = [ [[package]] name = "forge-query" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cedar-policy", "forge-types", @@ -1069,7 +1126,7 @@ dependencies = [ [[package]] name = "forge-security" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aws-lc-rs", "base64", @@ -1083,7 +1140,7 @@ dependencies = [ [[package]] name = "forge-server" -version = "0.2.0" +version = "0.3.0" dependencies = [ "aws-lc-rs", "axum", @@ -1113,7 +1170,7 @@ dependencies = [ [[package]] name = "forge-storage" -version = "0.2.0" +version = "0.3.0" dependencies = [ "bytes", "forge-types", @@ -1130,7 +1187,7 @@ dependencies = [ [[package]] name = "forge-types" -version = "0.2.0" +version = "0.3.0" dependencies = [ "redbx", "serde", @@ -2000,6 +2057,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -2804,6 +2871,27 @@ dependencies = [ "serde", ] +[[package]] +name = "rpassword" +version = "7.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66d4c8b64f049c6721ec8ccec37ddfc3d641c4a7fca57e8f2a89de509c73df39" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.59.0", +] + +[[package]] +name = "rtoolbox" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7cc970b249fbe527d6e02e0a227762c9108b2f49d81094fe357ffc6d14d7f6f" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3186,6 +3274,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "siphasher" version = "1.0.2" @@ -3672,13 +3766,17 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http", "http-body", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", diff --git a/crates/auth/Cargo.toml b/crates/auth/Cargo.toml index 5e1fcc2..18a34a7 100644 --- a/crates/auth/Cargo.toml +++ b/crates/auth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-auth" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/bin/Cargo.toml b/crates/bin/Cargo.toml index 7afccea..c6a2be0 100644 --- a/crates/bin/Cargo.toml +++ b/crates/bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-bin" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -24,8 +24,10 @@ tokio = { workspace = true } aws-lc-rs = { workspace = true } pasetors = { workspace = true } toml = { workspace = true } +serde_json = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +rpassword = "7" [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/bin/src/main.rs b/crates/bin/src/main.rs index 1ea5b5d..4854a9a 100644 --- a/crates/bin/src/main.rs +++ b/crates/bin/src/main.rs @@ -1,4 +1,8 @@ -//! ForgeDB — secure-by-default embedded document database. +//! ForgeDB — the entrypoint binary that wires every crate together. +//! +//! Handles CLI parsing, config loading, TLS setup, storage opening, key loading, +//! and launching the Axum server + optional TUI. Designed to be boring here so +//! the actual interesting logic lives in the right crate. use std::path::PathBuf; @@ -142,8 +146,17 @@ fn cmd_serve(config_path: PathBuf, with_tui: bool) -> forge_types::Result<()> { policy_path.display() )) })?; - let policy_engine = forge_query::policy::PolicyEngine::new(&policy_src)?; - let policy_engine = std::sync::Arc::new(policy_engine); + + let schema_path = config.data_dir.join("schema.json"); + let schema_src = std::fs::read_to_string(&schema_path).unwrap_or_else(|_| { + tracing::warn!("schema.json not found, falling back to built-in default schema"); + serde_json::to_string(&forge_query::schema::forge_schema_json()).unwrap() + }); + let schema_json: serde_json::Value = serde_json::from_str(&schema_src) + .map_err(|e| ForgeError::Config(format!("failed to parse schema.json: {e}")))?; + + let policy_engine = forge_query::policy::PolicyEngine::new(&policy_src, schema_json)?; + let policy_engine = std::sync::Arc::new(tokio::sync::RwLock::new(policy_engine)); tracing::info!("Cedar enforcement policies loaded successfully"); let rt = tokio::runtime::Runtime::new()?; @@ -159,11 +172,18 @@ fn cmd_serve(config_path: PathBuf, with_tui: bool) -> forge_types::Result<()> { config.bind_address ); - // Derive a 32-byte cursor signing key from the master password. - let cursor_key_hash = - aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, password.as_bytes()); + // Derive a 32-byte cursor signing key using HKDF-SHA256 with a labeled salt. + // Using raw SHA256(password) as a key would have been fine functionally, but HKDF + // gives us proper domain separation — the cursor key is demonstrably distinct from + // any other key material derived from the same password. Easier to reason about. + let hkdf_salt = + aws_lc_rs::hkdf::Salt::new(aws_lc_rs::hkdf::HKDF_SHA256, b"forgedb-cursor-v1"); + let prk = hkdf_salt.extract(password.as_bytes()); let mut cursor_key = [0u8; 32]; - cursor_key.copy_from_slice(cursor_key_hash.as_ref()); + prk.expand(&[b"cursor-hmac"], aws_lc_rs::hkdf::HKDF_SHA256) + .expect("HKDF expand is infallible for valid output length") + .fill(&mut cursor_key) + .expect("32 bytes always fits HKDF_SHA256 OKM"); let cursor_signer = std::sync::Arc::new(forge_security::CursorSigner::new(&cursor_key)); let app_state = forge_server::AppState { @@ -173,6 +193,8 @@ fn cmd_serve(config_path: PathBuf, with_tui: bool) -> forge_types::Result<()> { secret_key: secret_key.clone(), policy_engine: policy_engine.clone(), cursor_signer, + schema_path: config.data_dir.join("schema.json"), + policy_path: config.data_dir.join("policy.cedar"), }; let app = forge_server::app(app_state); @@ -213,18 +235,22 @@ fn cmd_serve(config_path: PathBuf, with_tui: bool) -> forge_types::Result<()> { }) } -/// Prompt for a password on stderr so it works even when stdout is piped. -/// Falls back to reading from `FORGEDB_PASSWORD` env var for non-interactive use. +/// Prompt for a password on stderr, with terminal echo suppressed. +/// +/// Falls back to `FORGEDB_PASSWORD` env var first — useful for CI pipelines, +/// Docker containers, or anyone who's typed the password twenty times today. +/// +/// # Errors +/// +/// Returns [`ForgeError::Config`] if the terminal I/O fails outright. fn prompt_password(prompt: &str) -> forge_types::Result { // Check env var first for CI / non-interactive scenarios if let Ok(pw) = std::env::var("FORGEDB_PASSWORD") { return Ok(pw); } - eprint!("\x1b[1;36m{prompt}\x1b[0m"); - let mut input = String::new(); - std::io::stdin() - .read_line(&mut input) - .map_err(|e| ForgeError::Config(format!("failed to read password: {e}")))?; - Ok(input.trim().to_string()) + // `rpassword::prompt_password` writes to the terminal and suppresses echo. + // No more accidentally leaking your DB password in a screen recording. + rpassword::prompt_password(format!("\x1b[1;36m{prompt}\x1b[0m")) + .map_err(|e| ForgeError::Config(format!("failed to read password: {e}"))) } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 52ac766..7ec5552 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-cli" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -11,6 +11,7 @@ forge-types = { workspace = true } forge-security = { workspace = true } forge-storage = { workspace = true } forge-auth = { workspace = true } +forge-query = { workspace = true } tracing = { workspace = true } toml = { workspace = true } crossterm = "0.29.0" diff --git a/crates/cli/src/init.rs b/crates/cli/src/init.rs index 935c6b3..9940cbd 100644 --- a/crates/cli/src/init.rs +++ b/crates/cli/src/init.rs @@ -67,8 +67,9 @@ pub fn run_init(opts: InitOptions) -> Result<()> { let _engine = StorageEngine::create(&db_path, &opts.password)?; tracing::info!("initialized encrypted database"); - // 5. Write a strictly default Cedar policy file so we have a secure baseline + // 5. Write a strictly default Cedar policy file and schema so we have a secure baseline let policy_path = opts.data_dir.join("policy.cedar"); + let schema_path = opts.data_dir.join("schema.json"); if !policy_path.exists() { let default_policy = r#"// ForgeDB root access policy // This serves as the default blanket administrator policy. @@ -83,6 +84,14 @@ permit( tracing::info!("wrote default policy.cedar"); } + if !schema_path.exists() { + let default_schema = forge_query::schema::forge_schema_json(); + let schema_str = serde_json::to_string_pretty(&default_schema) + .map_err(|e| ForgeError::Config(format!("failed to serialize default schema: {e}")))?; + std::fs::write(&schema_path, schema_str)?; + tracing::info!("wrote default schema.json"); + } + // 6. Write config file let toml_str = toml::to_string_pretty(&config) .map_err(|e| ForgeError::Config(format!("failed to serialize config: {e}")))?; diff --git a/crates/cli/src/tui.rs b/crates/cli/src/tui.rs index 76caba3..2810d12 100644 --- a/crates/cli/src/tui.rs +++ b/crates/cli/src/tui.rs @@ -98,12 +98,11 @@ impl App { fn new(url: String, token: Option, cert_path: Option) -> App { let mut builder = Client::builder(); - if let Some(path) = cert_path { - if let Ok(cert_bytes) = std::fs::read(&path) { - if let Ok(cert) = reqwest::Certificate::from_pem(&cert_bytes) { - builder = builder.add_root_certificate(cert); - } - } + if let Some(path) = cert_path + && let Ok(cert_bytes) = std::fs::read(&path) + && let Ok(cert) = reqwest::Certificate::from_pem(&cert_bytes) + { + builder = builder.add_root_certificate(cert); } let client = builder.build().unwrap_or_else(|_| Client::new()); @@ -267,7 +266,7 @@ impl App { if resp.status().is_success() { let json: Value = resp.json().await.unwrap_or_default(); self.collections.clear(); - if let Some(colls) = json.get("collections").and_then(|c| c.as_array()) { + if let Some(colls) = json.get("entity_types").and_then(|c| c.as_array()) { for c in colls { if let Some(name) = c.get("name").and_then(|n| n.as_str()) { self.collections.push(name.to_string()); @@ -296,7 +295,11 @@ impl App { async fn fetch_collection(&mut self) { if let Some(i) = self.collections_state.selected() { let col = &self.collections[i]; - let req = self.client.get(format!("{}/v1/{}?limit=50", self.url, col)); + // Explicitly ask for JSON — without this header, the server defaults + // to application/msgpack, which reqwest's .json() can't decode. + let req = self.client + .get(format!("{}/v1/{}?limit=50", self.url, col)) + .header(reqwest::header::ACCEPT, "application/json"); let req = if let Some(t) = &self.bearer_token { req.bearer_auth(t) } else { @@ -307,10 +310,8 @@ impl App { let json: Value = resp.json().await.unwrap_or_default(); if let Some(data) = json.get("data").and_then(|d| d.as_array()) { self.docs = data.clone(); - // Reset doc selection if we switched collections - if self.docs_state.selected().is_none() - || self.docs_state.selected().unwrap() >= self.docs.len() - { + // Reset doc selection if we switched collections or the index is now out of range. + if self.docs_state.selected().is_none_or(|s| s >= self.docs.len()) { self.docs_state.select(if !self.docs.is_empty() { Some(0) } else { @@ -367,6 +368,74 @@ impl App { } }; + if !self.collections.contains(&editor.collection) { + // New collection — patch the Cedar schema dynamically so the server + // accepts documents for it. We fetch the raw schema, inject the new + // entity type + resource type entries, then PUT it back. + let schema_req = self.client.get(format!("{}/_/schema?raw=true", self.url)); + let schema_req = if let Some(t) = &self.bearer_token { + schema_req.bearer_auth(t) + } else { + schema_req + }; + + if let Ok(resp) = schema_req.send().await + && resp.status().is_success() + && let Ok(mut schema) = resp.json::().await + { + let coll_name_cap = { + let mut c = editor.collection.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }; + + let mut modified = false; + + if let Some(ns) = schema.get_mut("ForgeDB").and_then(|n| n.as_object_mut()) { + if let Some(et) = ns.get_mut("entityTypes").and_then(|e| e.as_object_mut()) + && !et.contains_key(&coll_name_cap) + { + et.insert( + coll_name_cap.clone(), + serde_json::json!({ + "shape": { "type": "Record", "attributes": {} } + }), + ); + modified = true; + } + + if let Some(actions) = + ns.get_mut("actions").and_then(|a| a.as_object_mut()) + { + for action in ["Read", "Write", "Delete"] { + if let Some(act) = actions.get_mut(action).and_then(|a| a.as_object_mut()) + && let Some(applies) = act.get_mut("appliesTo").and_then(|ap| ap.as_object_mut()) + && let Some(rt) = applies.get_mut("resourceTypes").and_then(|r| r.as_array_mut()) + { + let val = serde_json::Value::String(coll_name_cap.clone()); + if !rt.contains(&val) { + rt.push(val); + modified = true; + } + } + } + } + } + + if modified { + let put_req = self.client.put(format!("{}/_/schema", self.url)); + let put_req = if let Some(t) = &self.bearer_token { + put_req.bearer_auth(t) + } else { + put_req + }; + let _ = put_req.json(&schema).send().await; + } + } + } + let res = if let Some(id) = &editor.doc_id { // PATCH let req = self @@ -545,10 +614,8 @@ impl App { /// /// ```no_run /// use forge_cli::tui; -/// -/// // Assuming the server is up at localhost:5826 /// # fn main() -> Result<(), Box> { -/// tui::run("https://localhost:5826".to_string(), None)?; +/// tui::run("https://localhost:5826".to_string(), None, None)?; /// # Ok(()) /// # } /// ``` @@ -630,21 +697,33 @@ async fn run_app(terminal: &mut Terminal, app: &mut App) -> io::R } AppScreen::Setup => { if key.code == KeyCode::Enter { - app.submit_setup().await; + if app.auth_form.focused < 2 { + app.auth_form.focused += 1; + } else { + app.submit_setup().await; + } } else { app.handle_input(key.code); } } AppScreen::Login => { if key.code == KeyCode::Enter { - app.submit_login().await; + if app.auth_form.focused < 1 { + app.auth_form.focused += 1; + } else { + app.submit_login().await; + } } else { app.handle_input(key.code); } } AppScreen::NewUser => { if key.code == KeyCode::Enter { - app.submit_new_user().await; + if app.auth_form.focused < 2 { + app.auth_form.focused += 1; + } else { + app.submit_new_user().await; + } } else { app.handle_input(key.code); } @@ -910,10 +989,8 @@ fn draw_initializing(f: &mut Frame, app: &mut App, area: Rect) { } fn draw_auth_form(f: &mut Frame, app: &mut App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(12), Constraint::Min(0)]) - .split(center_rect(60, 40, area)); + // Just use the center rect directly. Nested splits can collapse the height entirely on smaller terminals. + let form_area = center_rect(60, 80, area); let title = match app.screen { AppScreen::Setup => " SETUP ", @@ -926,121 +1003,86 @@ fn draw_auth_form(f: &mut Frame, app: &mut App, area: Rect) { .title(title) .borders(Borders::ALL) .border_style(Style::default().fg(Color::Rgb(56, 189, 248))) - .style(Style::default()); // No background, as requested + .style(Style::default()); - f.render_widget(Clear, chunks[0]); - f.render_widget(block.clone(), chunks[0]); + f.render_widget(block.clone(), form_area); let inputs = Layout::default() .direction(Direction::Vertical) .margin(2) + .spacing(2) .constraints([ - Constraint::Length(2), - Constraint::Length(2), - Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), Constraint::Min(0), ]) - .split(block.inner(chunks[0])); + .split(block.inner(form_area)); - let active = Style::default() - .fg(Color::Rgb(2, 132, 199)) - .add_modifier(Modifier::BOLD); - let inactive = Style::default().fg(Color::Rgb(148, 163, 184)); + let render_input = |f: &mut Frame, text: String, is_active: bool, area: Rect| { + let active = Style::default() + .fg(Color::Black) + .add_modifier(Modifier::BOLD); + let inactive = Style::default().fg(Color::DarkGray); + + f.render_widget( + Paragraph::new(text).style(if is_active { active } else { inactive }), + area, + ); + }; match app.screen { AppScreen::Setup => { - f.render_widget( - Paragraph::new(format!("PASETO: {}", app.auth_form.token)).style( - if app.auth_form.focused == 0 { - active - } else { - inactive - }, - ), + render_input( + f, + format!("PASETO: {}", app.auth_form.token), + app.auth_form.focused == 0, inputs[0], ); - f.render_widget( - Paragraph::new(format!( - "Password: {}", - "*".repeat(app.auth_form.password.len()) - )) - .style(if app.auth_form.focused == 1 { - active - } else { - inactive - }), + render_input( + f, + format!("Password: {}", "*".repeat(app.auth_form.password.len())), + app.auth_form.focused == 1, inputs[1], ); - f.render_widget( - Paragraph::new(format!( - "Confirm: {}", - "*".repeat(app.auth_form.confirm.len()) - )) - .style(if app.auth_form.focused == 2 { - active - } else { - inactive - }), + render_input( + f, + format!("Confirm: {}", "*".repeat(app.auth_form.confirm.len())), + app.auth_form.focused == 2, inputs[2], ); } AppScreen::Login => { - f.render_widget( - Paragraph::new(format!("Username: {}", app.auth_form.username)).style( - if app.auth_form.focused == 0 { - active - } else { - inactive - }, - ), + render_input( + f, + format!("Username: {}", app.auth_form.username), + app.auth_form.focused == 0, inputs[0], ); - f.render_widget( - Paragraph::new(format!( - "Password: {}", - "*".repeat(app.auth_form.password.len()) - )) - .style(if app.auth_form.focused == 1 { - active - } else { - inactive - }), + render_input( + f, + format!("Password: {}", "*".repeat(app.auth_form.password.len())), + app.auth_form.focused == 1, inputs[1], ); } AppScreen::NewUser => { - f.render_widget( - Paragraph::new(format!("Username: {}", app.auth_form.username)).style( - if app.auth_form.focused == 0 { - active - } else { - inactive - }, - ), + render_input( + f, + format!("Username: {}", app.auth_form.username), + app.auth_form.focused == 0, inputs[0], ); - f.render_widget( - Paragraph::new(format!( - "Password: {}", - "*".repeat(app.auth_form.password.len()) - )) - .style(if app.auth_form.focused == 1 { - active - } else { - inactive - }), + render_input( + f, + format!("Password: {}", "*".repeat(app.auth_form.password.len())), + app.auth_form.focused == 1, inputs[1], ); - f.render_widget( - Paragraph::new(format!( - "Confirm: {}", - "*".repeat(app.auth_form.confirm.len()) - )) - .style(if app.auth_form.focused == 2 { - active - } else { - inactive - }), + render_input( + f, + format!("Confirm: {}", "*".repeat(app.auth_form.confirm.len())), + app.auth_form.focused == 2, inputs[2], ); } diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml index 1e8166c..c428545 100644 --- a/crates/protocol/Cargo.toml +++ b/crates/protocol/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-protocol" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/query/Cargo.toml b/crates/query/Cargo.toml index 556bcbd..17fbe02 100644 --- a/crates/query/Cargo.toml +++ b/crates/query/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-query" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/query/src/context.rs b/crates/query/src/context.rs index 788d17d..dcff0be 100644 --- a/crates/query/src/context.rs +++ b/crates/query/src/context.rs @@ -57,10 +57,34 @@ impl AuthContext { /// Returns [`ForgeError::Policy`] if the principal, action, or resource /// strings can't be wrangled into valid Cedar `EntityUid`s. For instance, /// if some client sends over malicious characters that Cedar outright rejects in entity IDs. - pub fn to_cedar_request(&self) -> Result { + pub fn to_cedar_request(&self, schema: Option<&cedar_policy::Schema>) -> Result { + let parts: Vec<&str> = self.resource.splitn(2, '/').collect(); + let entity_type = if parts.len() == 2 { + // "users/123" -> entity_type = "users", entity_id = "123" + parts[0] + } else { + // "users" -> entity_type = "users", entity_id = "*" + parts[0] + }; + let entity_type_cap = match entity_type { + "_" => "Document".to_string(), // fallback + other => { + // Capitalize first letter to match Cedar conventions usually + let mut c = other.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + } + }; + let principal_eid = format!(r#"ForgeDB::User::"{}""#, cedar_escape(&self.principal)); let action_eid = format!(r#"ForgeDB::Action::"{}""#, cedar_escape(&self.action)); - let resource_eid = format!(r#"ForgeDB::Document::"{}""#, cedar_escape(&self.resource)); + let resource_eid = format!( + r#"ForgeDB::{}::"{}""#, + entity_type_cap, + cedar_escape(&self.resource) + ); let p_uid: EntityUid = principal_eid .parse() @@ -74,14 +98,8 @@ impl AuthContext { let context = Context::empty(); - Request::new( - p_uid, - a_uid, - r_uid, - context, - Some(&crate::schema::forge_schema()?), - ) - .map_err(|e| ForgeError::Policy(format!("failed to construct request: {e}"))) + Request::new(p_uid, a_uid, r_uid, context, schema) + .map_err(|e| ForgeError::Policy(format!("failed to construct request: {e}"))) } } @@ -91,8 +109,10 @@ mod tests { #[test] fn context_builds_valid_request() { - let ctx = AuthContext::new("alice", "Read", "invoices/123"); - let req = ctx.to_cedar_request().expect("should build valid request"); + let ctx = AuthContext::new("alice", "Read", "document/123"); + let req = ctx + .to_cedar_request(None) + .expect("should build valid request"); assert_eq!( req.principal().unwrap().to_string(), @@ -104,14 +124,14 @@ mod tests { ); assert_eq!( req.resource().unwrap().to_string(), - r#"ForgeDB::Document::"invoices/123""# + r#"ForgeDB::Document::"document/123""# ); } #[test] fn context_handles_complex_strings() { let ctx = AuthContext::new("user_name@domain.com", "Write", "a/b/c/d"); - let req = ctx.to_cedar_request().unwrap(); + let req = ctx.to_cedar_request(None).unwrap(); assert!(req.principal().is_some()); } @@ -119,9 +139,9 @@ mod tests { fn cedar_injection_attempt_is_escaped() { // Attempting to inject a bypass: "principal,action,resource); //" let malicious = r#"alice", action, resource); //"#; - let ctx = AuthContext::new(malicious, "Read", "docs/1"); + let ctx = AuthContext::new(malicious, "Read", "document/1"); let req = ctx - .to_cedar_request() + .to_cedar_request(None) .expect("Escaping should make this a valid, if weird, ID"); // The key check: Is the principal still exactly what we passed, but quoted? diff --git a/crates/query/src/introspect.rs b/crates/query/src/introspect.rs index 1128d5a..b41cffd 100644 --- a/crates/query/src/introspect.rs +++ b/crates/query/src/introspect.rs @@ -10,8 +10,6 @@ use serde_json::Value; use forge_types::Result; -use crate::schema::forge_schema_json; - /// The overall shape of the ForgeDB Cedar namespace. #[derive(Debug, Serialize, PartialEq, Eq)] pub struct SchemaInfo { @@ -60,9 +58,7 @@ pub struct ActionInfo { /// /// # Errors /// Returns [`ForgeError::Policy`] if the schema introspection fails. -pub fn introspect_schema() -> Result { - let schema_json = forge_schema_json(); - +pub fn introspect_schema(schema_json: &serde_json::Value) -> Result { let ns = schema_json.get("ForgeDB").ok_or_else(|| { forge_types::ForgeError::Policy("ForgeDB namespace missing from schema".into()) })?; @@ -152,7 +148,8 @@ mod tests { #[test] fn introspection_yields_expected_shape() { - let info = introspect_schema().expect("introspection must succeed"); + let schema_json = crate::schema::forge_schema_json(); + let info = introspect_schema(&schema_json).expect("introspection must succeed"); assert_eq!(info.entity_types.len(), 2, "Expected User and Document"); diff --git a/crates/query/src/lib.rs b/crates/query/src/lib.rs index 7c14bf5..a79cefa 100644 --- a/crates/query/src/lib.rs +++ b/crates/query/src/lib.rs @@ -1,8 +1,8 @@ -//! Query engine for ForgeDB (planned for full release in v0.3). +//! Query engine for ForgeDB (Cedar RLS + join query, fully shipped in v0.3). //! -//! Right now (v0.2.0), this crate is exclusively housing the Cedar RLS (Row-Level Security) policy -//! engine. I want every single document access request routed through `PolicyEngine::check_permit` -//! before it even thinks about touching the storage layer. No exceptions. +//! This crate houses the Cedar RLS (Row-Level Security) policy engine and the +//! join query planner. Every document access is routed through `PolicyEngine::check_permit` +//! before it touches the storage layer. No exceptions, no bypasses. //! //! # Architecture //! diff --git a/crates/query/src/policy.rs b/crates/query/src/policy.rs index 3954502..adf0470 100644 --- a/crates/query/src/policy.rs +++ b/crates/query/src/policy.rs @@ -18,7 +18,7 @@ use cedar_policy::{Authorizer, Decision, Entities, PolicySet, Schema}; use forge_types::{ForgeError, Result}; use crate::context::AuthContext; -use crate::schema::forge_schema; +use crate::schema::parse_schema; /// The uncompromising authorization layer for document access. /// @@ -30,6 +30,8 @@ pub struct PolicyEngine { policies: PolicySet, #[allow(dead_code)] schema: Schema, + pub raw_schema: serde_json::Value, + pub cedar_src: String, } impl std::fmt::Debug for PolicyEngine { @@ -50,12 +52,12 @@ impl PolicyEngine { /// # Errors /// /// Returns [`ForgeError::Policy`] if parsing or schema validation goes sideways. - pub fn new(cedar_src: &str) -> Result { + pub fn new(cedar_src: &str, schema_json: serde_json::Value) -> Result { let policies: PolicySet = cedar_src .parse() .map_err(|e| ForgeError::Policy(format!("syntax error in policy: {e}")))?; - let schema = forge_schema()?; + let schema = parse_schema(schema_json.clone())?; // Mandatory Validation: catch bugs like typos and type mismatches early. let validator = cedar_policy::Validator::new(schema.clone()); @@ -72,6 +74,8 @@ impl PolicyEngine { authorizer: Authorizer::new(), policies, schema, + raw_schema: schema_json, + cedar_src: cedar_src.to_string(), }) } @@ -93,7 +97,7 @@ impl PolicyEngine { /// Returns [`ForgeError::Policy`] when access is explicitly denied, or if /// the given context simply fails to build into a structurally valid Cedar request. pub fn check_permit(&self, ctx: &AuthContext) -> Result<()> { - let request = ctx.to_cedar_request()?; + let request = ctx.to_cedar_request(Some(&self.schema))?; // We aren't fully materializing massive entity trees (e.g., User -> Group -> Organization) // just yet. So, we pass a totally empty Entities array. The principal/action/resource @@ -129,12 +133,12 @@ mod tests { permit( principal == ForgeDB::User::"alice", action == ForgeDB::Action::"Read", - resource == ForgeDB::Document::"docs/1" + resource == ForgeDB::Document::"document/1" ); "#; - let engine = PolicyEngine::new(src).unwrap(); - let ctx = AuthContext::new("alice", "Read", "docs/1"); + let engine = PolicyEngine::new(src, crate::schema::forge_schema_json()).unwrap(); + let ctx = AuthContext::new("alice", "Read", "document/1"); assert!( engine.check_permit(&ctx).is_ok(), @@ -158,22 +162,22 @@ mod tests { ); "#; - let engine = PolicyEngine::new(src).unwrap(); + let engine = PolicyEngine::new(src, crate::schema::forge_schema_json()).unwrap(); // Alice easily matches the blanket permit policy, and importantly, she isn't forbidden. - let ctx_alice = AuthContext::new("alice", "Read", "docs/1"); + let ctx_alice = AuthContext::new("alice", "Read", "document/1"); assert!(engine.check_permit(&ctx_alice).is_ok()); // Eve happens to match the permit too, but ALSO matches the forbid policy. Forbid always wins. Always. - let ctx_eve = AuthContext::new("eve", "Read", "docs/1"); + let ctx_eve = AuthContext::new("eve", "Read", "document/1"); let err = engine.check_permit(&ctx_eve).unwrap_err(); assert!(format!("{err}").contains("denied")); } #[test] fn empty_policy_denies_by_default() { - let engine = PolicyEngine::new("").unwrap(); - let ctx = AuthContext::new("alice", "Read", "docs/1"); + let engine = PolicyEngine::new("", crate::schema::forge_schema_json()).unwrap(); + let ctx = AuthContext::new("alice", "Read", "document/1"); let err = engine.check_permit(&ctx).unwrap_err(); assert!(format!("{err}").contains("denied")); } @@ -181,7 +185,7 @@ mod tests { #[test] fn invalid_syntax_caught_at_construction() { let src = r#"permit( principal = "whoops" )"#; // = instead of == - assert!(PolicyEngine::new(src).is_err()); + assert!(PolicyEngine::new(src, crate::schema::forge_schema_json()).is_err()); } #[test] @@ -191,7 +195,7 @@ mod tests { permit(principal, action, resource) when { resource.ownerrr == "alice" }; "#; - let err = PolicyEngine::new(src).unwrap_err(); + let err = PolicyEngine::new(src, crate::schema::forge_schema_json()).unwrap_err(); assert!(err.to_string().contains("validation failed")); assert!(err.to_string().contains("ownerrr")); } diff --git a/crates/query/src/schema.rs b/crates/query/src/schema.rs index 7a8777c..64ca7d8 100644 --- a/crates/query/src/schema.rs +++ b/crates/query/src/schema.rs @@ -68,7 +68,7 @@ pub fn forge_schema_json() -> serde_json::Value { }) } -/// Spits out the compiled Cedar schema for ForgeDB. +/// Spits out a compiled Cedar schema for ForgeDB from a parsed JSON value. /// /// This dictates exactly what entity types, actions, and context shapes are actually legal /// in the system. Any user policy that tries to invent an action (e.g., `permit(principal, action == Action::"Hack", resource)`) @@ -76,11 +76,10 @@ pub fn forge_schema_json() -> serde_json::Value { /// /// # Errors /// -/// Returns [`ForgeError::Policy`] if our hardcoded JSON schema somehow -/// turns out to be invalid. Honestly, that should only ever happen during dev when someone typos a brace. -pub fn forge_schema() -> Result { - Schema::from_json_value(forge_schema_json()) - .map_err(|e| ForgeError::Policy(format!("failed to parse built-in Cedar schema: {e}"))) +/// Returns [`ForgeError::Policy`] if the provided JSON schema is invalid. +pub fn parse_schema(json_val: serde_json::Value) -> Result { + Schema::from_json_value(json_val) + .map_err(|e| ForgeError::Policy(format!("failed to parse Cedar schema: {e}"))) } #[cfg(test)] @@ -89,7 +88,8 @@ mod tests { #[test] fn schema_compiles_successfully() { - let schema = forge_schema().expect("hardcoded schema must be entirely valid"); + let schema = + parse_schema(forge_schema_json()).expect("hardcoded schema must be entirely valid"); let ns = schema .action_entities() diff --git a/crates/security/Cargo.toml b/crates/security/Cargo.toml index f1c9bb2..2d57128 100644 --- a/crates/security/Cargo.toml +++ b/crates/security/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-security" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 790e77a..a1eef34 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-server" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true @@ -23,7 +23,7 @@ rmp-serde = { workspace = true } uuid = { workspace = true } tokio = { workspace = true } tokio-rustls = { workspace = true } -tower-http = { workspace = true } +tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"] } json-patch = "4.1.0" redbx.workspace = true serde.workspace = true diff --git a/crates/server/src/auth_api.rs b/crates/server/src/auth_api.rs index 98a3f21..604278c 100644 --- a/crates/server/src/auth_api.rs +++ b/crates/server/src/auth_api.rs @@ -1,14 +1,37 @@ +//! Authentication API handlers — setup, login, and user management. +//! +//! These are the "before you get a token" endpoints. The happy path is dead simple: +//! POST a username/password, get a PASETO token back. Everything else here is +//! mostly defense — PBKDF2 derivation, constant-time verification, role checks. +//! +//! # Security model +//! +//! Passwords are stored as `PBKDF2-HMAC-SHA256` derived bytes (100k iterations), +//! with a fresh 16-byte random salt per user. Verification uses `aws_lc_rs::pbkdf2::verify()` +//! which does the comparison in constant time — no timing leaks, even across the hex boundary. +//! +//! The setup endpoint requires the operator's PASETO admin token to bootstrap. +//! This prevents drive-by setup attacks on a freshly initialized DB. + use aws_lc_rs::rand::SecureRandom; use axum::{Json, extract::State, http::StatusCode, response::IntoResponse}; use serde::{Deserialize, Serialize}; use crate::AppState; +/// Response shape for `GET /_/auth/status`. #[derive(Serialize)] pub struct AuthStatus { + /// `true` if no admin user has been set up yet — the TUI/client should + /// redirect to the setup flow in this case. pub setup_required: bool, } +/// `GET /_/auth/status` — tells callers if first-time setup is needed. +/// +/// Intentionally unauthenticated. The TUI calls this on startup to decide +/// whether to show the setup screen or the login screen. No sensitive data +/// leaks here — we only return a boolean. pub async fn auth_status(State(state): State) -> impl IntoResponse { let setup_required = state .engine @@ -18,9 +41,17 @@ pub async fn auth_status(State(state): State) -> impl IntoResponse { (StatusCode::OK, Json(AuthStatus { setup_required })) } +/// `POST /_/auth/setup` — one-time admin bootstrap. +/// +/// Can only succeed once — if an admin record already exists, this returns `400`. +/// Requires the server-issued initial PASETO token (printed on startup) to prevent +/// anyone from just walking in and setting their own admin password. #[derive(Deserialize)] pub struct SetupReq { + /// The initial admin token printed by `forgedb serve`. This proves the person + /// setting up the DB has access to the server console. pub token: String, + /// The new admin password. Hashed with PBKDF2 before storage — never stored raw. pub password: String, } @@ -28,7 +59,7 @@ pub async fn setup( State(state): State, Json(payload): Json, ) -> Result { - // verify token + // Validate the bootstrap token first — if this fails, nothing else runs. forge_auth::validate_token(&payload.token, &state.public_key) .map_err(|_| StatusCode::UNAUTHORIZED)?; @@ -70,17 +101,29 @@ pub async fn setup( Ok(StatusCode::OK) } +/// Request body for `POST /_/auth/login`. #[derive(Deserialize)] pub struct LoginReq { pub username: String, pub password: String, } +/// Successful login response — just the token, nothing else. #[derive(Serialize)] pub struct LoginRes { pub token: String, } +/// `POST /_/auth/login` — password verification and token issuance. +/// +/// Looks up the user record, decodes the stored salt, then calls +/// `aws_lc_rs::pbkdf2::verify()` which re-derives the key and compares it +/// in constant time. This avoids the classic timing-side-channel that comes +/// from naively comparing hex strings with `!=`. +/// +/// Returns a 30-day PASETO v4.public token on success. The 30-day window is +/// intentional for developer ergonomics — production deployments can tighten +/// this by tweaking `exp_seconds` in the claims. pub async fn login( State(state): State, Json(payload): Json, @@ -91,43 +134,61 @@ pub async fn login( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? .ok_or(StatusCode::UNAUTHORIZED)?; - // Fallback to json decoding let doc: serde_json::Value = forge_storage::document::deserialize_doc(&doc_bytes) .unwrap_or_else(|_| serde_json::json!({})); - let stored_hash = doc.get("hash").and_then(|h| h.as_str()).unwrap_or(""); + + let stored_hash_hex = doc.get("hash").and_then(|h| h.as_str()).unwrap_or(""); let stored_salt_hex = doc.get("salt").and_then(|s| s.as_str()).unwrap_or(""); - let salt = hex::decode(stored_salt_hex).unwrap_or_default(); - let mut attempt_hash_bytes = [0u8; 32]; - if !salt.is_empty() { - aws_lc_rs::pbkdf2::derive( - aws_lc_rs::pbkdf2::PBKDF2_HMAC_SHA256, - std::num::NonZeroU32::new(100_000).unwrap(), - &salt, - payload.password.as_bytes(), - &mut attempt_hash_bytes, + // Bail out early if either hex field is missing — these records are corrupt + // or somehow pre-date the proper setup flow. Don't let them in. + if stored_hash_hex.is_empty() || stored_salt_hex.is_empty() { + tracing::warn!( + username = %payload.username, + "login rejected: malformed user record (missing hash or salt)" ); + return Err(StatusCode::UNAUTHORIZED); } - let attempt_hash = hex::encode(attempt_hash_bytes); - if stored_hash != attempt_hash || stored_hash.is_empty() { + let salt = hex::decode(stored_salt_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; + let stored_hash_bytes = hex::decode(stored_hash_hex).map_err(|_| StatusCode::UNAUTHORIZED)?; + + // `pbkdf2::verify` re-derives the key from the given password + salt and + // compares it to `stored_hash_bytes` in constant time. This is the correct + // way to do this — string comparison on hex would leak timing info. + let verify_result = aws_lc_rs::pbkdf2::verify( + aws_lc_rs::pbkdf2::PBKDF2_HMAC_SHA256, + std::num::NonZeroU32::new(100_000).unwrap(), + &salt, + payload.password.as_bytes(), + &stored_hash_bytes, + ); + + if verify_result.is_err() { return Err(StatusCode::UNAUTHORIZED); } let role = doc.get("role").and_then(|r| r.as_str()).unwrap_or("user"); - let claims = forge_auth::TokenClaims::new(&payload.username, 30 * 24 * 3600, Some(role.into())); + let claims = + forge_auth::TokenClaims::new(&payload.username, 30 * 24 * 3600, Some(role.into())); let token = forge_auth::issue_token(&claims, &state.secret_key) .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok((StatusCode::OK, Json(LoginRes { token }))) } +/// Request body for `POST /_/auth/users`. #[derive(Deserialize)] pub struct CreateUserReq { pub username: String, pub password: String, } +/// `POST /_/auth/users` — admin-only user creation. +/// +/// Requires the caller to have `role: "admin"` in their PASETO token. +/// New users get `role: "user"` — there's no way to self-promote through +/// this endpoint. pub async fn create_user( State(state): State, axum::extract::Extension(claims): axum::extract::Extension, @@ -137,6 +198,11 @@ pub async fn create_user( return Err(StatusCode::FORBIDDEN); } + // Reject blank usernames — the storage key would be empty, which is ugly. + if payload.username.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let mut salt = [0u8; 16]; aws_lc_rs::rand::SystemRandom::new() .fill(&mut salt) diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 3d597e3..5d03b14 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -29,7 +29,7 @@ use axum::{ routing::get, }; use forge_storage::StorageEngine; -use tower_http::trace::TraceLayer; +use tower_http::{compression::CompressionLayer, trace::TraceLayer}; /// Maps HTTP verbs to ForgeDB action names for Cedar and audit logging. /// @@ -69,8 +69,10 @@ pub struct AppState { pub writer: WriteSender, pub public_key: Arc>, pub secret_key: Arc>, - pub policy_engine: Arc, + pub policy_engine: Arc>, pub cursor_signer: Arc, + pub schema_path: std::path::PathBuf, + pub policy_path: std::path::PathBuf, } /// Builds the master Axum router containing all ForgeDB v1 endpoints. @@ -84,7 +86,7 @@ pub fn app(state: AppState) -> Router { // Authenticated API routes let api = Router::new() - .route("/_/schema", get(schema_info)) + .route("/_/schema", get(schema_info).put(update_schema)) .route("/_/auth/users", axum::routing::post(auth_api::create_user)) .route("/v1/{collection}", get(list_docs).post(insert_doc)) .route( @@ -117,6 +119,9 @@ pub fn app(state: AppState) -> Router { .merge(public) .merge(api) .layer(TraceLayer::new_for_http()) + // Gzip compress responses for clients that advertise Accept-Encoding: gzip. + // Especially helps paginated list responses at 50+ docs — can cut wire bytes by 60-70%. + .layer(CompressionLayer::new()) .layer(axum::extract::DefaultBodyLimit::max(5 * 1024 * 1024)) .with_state(state) } @@ -129,13 +134,30 @@ async fn health() -> impl IntoResponse { (StatusCode::OK, "ok") } +#[derive(serde::Deserialize)] +pub struct SchemaQuery { + pub raw: Option, +} + /// `GET /_/schema` — Cedar namespace introspection. /// /// Unauthenticated endpoint that returns the structured `SchemaInfo` so the /// Leptos dashboard can provide auto-completion for policies. -async fn schema_info() -> Result { - match forge_query::introspect_schema() { - Ok(info) => Ok(Json(info)), +async fn schema_info( + State(state): State, + axum::extract::Query(query): axum::extract::Query, +) -> Result { + let schema_json = { + let pe_guard = state.policy_engine.read().await; + pe_guard.raw_schema.clone() + }; + + if query.raw.unwrap_or(false) { + return Ok(Json(schema_json).into_response()); + } + + match forge_query::introspect_schema(&schema_json) { + Ok(info) => Ok(Json(info).into_response()), Err(e) => { tracing::error!("schema introspection failed: {e}"); Err(StatusCode::INTERNAL_SERVER_ERROR) @@ -143,6 +165,53 @@ async fn schema_info() -> Result { } } +/// `PUT /_/schema` — Hot-reload dynamic Cedar schema. +/// +/// Updates the schema JSON, validates it against the active Cedar policy, +/// saves it to disk, and hot-swaps the PolicyEngine. +async fn update_schema( + State(state): State, + axum::extract::Extension(claims): axum::extract::Extension, + axum::Json(new_schema): axum::Json, +) -> Result { + let principal = &claims.sub; + + let pe_guard = state.policy_engine.read().await; + let auth_ctx = forge_query::context::AuthContext::new(principal, "Write", "_schema"); + if pe_guard.check_permit(&auth_ctx).is_err() { + tracing::warn!("Schema update denied for {}", principal); + return Err(StatusCode::FORBIDDEN); + } + + let cedar_src = pe_guard.cedar_src.clone(); + drop(pe_guard); + + let new_engine = match PolicyEngine::new(&cedar_src, new_schema.clone()) { + Ok(engine) => engine, + Err(e) => { + tracing::warn!("Invalid schema update attempted: {e}"); + return Err(StatusCode::BAD_REQUEST); + } + }; + + let schema_str = serde_json::to_string_pretty(&new_schema).map_err(|e| { + tracing::error!("Failed to serialize new schema: {e}"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if let Err(e) = std::fs::write(&state.schema_path, schema_str) { + tracing::error!("Failed to write schema.json to disk: {e}"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + + let mut write_guard = state.policy_engine.write().await; + *write_guard = new_engine; + + tracing::info!("Schema successfully hot-reloaded"); + + Ok(StatusCode::OK) +} + /// GET /v1/:collection /// Lists documents in a collection, chunked via cursor-based pagination. /// @@ -187,6 +256,7 @@ async fn list_docs( let action = "Read"; let mut last_scanned_id = None; + let pe_guard = state.policy_engine.read().await; const MAX_SCAN_LIMIT: usize = 1000; @@ -228,7 +298,7 @@ async fn list_docs( let resource = format!("{}/{}", collection, id); let auth_ctx = forge_query::context::AuthContext::new(principal, action, &resource); - if state.policy_engine.check_permit(&auth_ctx).is_ok() { + if pe_guard.check_permit(&auth_ctx).is_ok() { valid_docs.push((id.clone(), bytes)); last_scanned_id = Some(id); @@ -564,8 +634,10 @@ async fn query_docs( let principal = &claims.sub; let action = "Read"; + let pe_guard = state.policy_engine.read().await; + let root_ctx = forge_query::context::AuthContext::new(principal, action, &query.collection); - if state.policy_engine.check_permit(&root_ctx).is_err() { + if pe_guard.check_permit(&root_ctx).is_err() { tracing::warn!("Query denied at root collection: {}", query.collection); return Err(StatusCode::FORBIDDEN); } @@ -615,7 +687,7 @@ async fn query_docs( let resource = format!("{}/{}", query.collection, id); let auth_ctx = forge_query::context::AuthContext::new(principal, action, &resource); - if state.policy_engine.check_permit(&auth_ctx).is_ok() { + if pe_guard.check_permit(&auth_ctx).is_ok() { valid_docs.push((id.clone(), bytes)); if valid_docs.len() == limit { last_scanned_id = Some(id); @@ -652,7 +724,7 @@ async fn query_docs( process_joins( &state.engine, - &state.policy_engine, + &pe_guard, principal, &mut root_docs, &query.joins, diff --git a/crates/server/src/policy.rs b/crates/server/src/policy.rs index df30b43..4f1fe11 100644 --- a/crates/server/src/policy.rs +++ b/crates/server/src/policy.rs @@ -54,11 +54,16 @@ pub async fn require_policy( let auth_ctx = AuthContext::new(principal, action, &resource); - match state.policy_engine.check_permit(&auth_ctx) { + let is_permitted = { + let pe_guard = state.policy_engine.read().await; + pe_guard.check_permit(&auth_ctx).map_err(|e| e.to_string()) + }; + + match is_permitted { Ok(_) => Ok(next.run(req).await), Err(e) => { - tracing::warn!("access denied by Cedar policy: {e}"); - Err((StatusCode::FORBIDDEN, "Access Denied").into_response()) + tracing::warn!("access denied: {}, principal: {}", e, principal); + Err((StatusCode::FORBIDDEN, "Forbidden").into_response()) } } } diff --git a/crates/server/tests/api_pipeline.rs b/crates/server/tests/api_pipeline.rs index 7a46cec..98380b4 100644 --- a/crates/server/tests/api_pipeline.rs +++ b/crates/server/tests/api_pipeline.rs @@ -18,6 +18,37 @@ use forge_server::AppState; use forge_storage::{StorageEngine, spawn_writer}; use tempfile::TempDir; +fn get_test_schema() -> serde_json::Value { + let mut schema = forge_query::schema::forge_schema_json(); + if let Some(ns) = schema.get_mut("ForgeDB").and_then(|ns| ns.as_object_mut()) { + if let Some(et) = ns.get_mut("entityTypes").and_then(|e| e.as_object_mut()) { + for c in ["Users", "Secrets", "Items", "_schema"] { + et.insert( + c.to_string(), + serde_json::json!({"shape": {"type": "Record", "attributes": {}}}), + ); + } + } + if let Some(actions) = ns.get_mut("actions").and_then(|a| a.as_object_mut()) { + for action in ["Read", "Write", "Delete"] { + if let Some(rt) = actions + .get_mut(action) + .and_then(|a| a.as_object_mut()) + .and_then(|a| a.get_mut("appliesTo")) + .and_then(|ap| ap.as_object_mut()) + .and_then(|ap| ap.get_mut("resourceTypes")) + .and_then(|rt| rt.as_array_mut()) + { + for c in ["Users", "Secrets", "Items", "_schema"] { + rt.push(serde_json::Value::String(c.to_string())); + } + } + } + } + } + schema +} + /// Spins up an in-memory ForgeDB test harness with a blanket permit policy. fn test_harness() -> (axum::Router, String, TempDir) { let tmp = TempDir::new().expect("tempdir creation"); @@ -35,8 +66,12 @@ fn test_harness() -> (axum::Router, String, TempDir) { let secret_key = Arc::new(kp.secret.clone()); // Blanket permit — allows everything, which is what we want for happy-path tests. - let policy = PolicyEngine::new("permit(principal, action, resource);").expect("policy parse"); - let policy_engine = Arc::new(policy); + let policy = PolicyEngine::new( + "permit(principal, action, resource);", + get_test_schema(), + ) + .expect("policy parse"); + let policy_engine = Arc::new(tokio::sync::RwLock::new(policy)); let state = AppState { engine: engine.clone(), @@ -45,6 +80,8 @@ fn test_harness() -> (axum::Router, String, TempDir) { secret_key, policy_engine, cursor_signer: std::sync::Arc::new(forge_security::CursorSigner::new(&[0u8; 32])), + schema_path: db_path.with_extension("schema"), + policy_path: db_path.with_extension("policy"), }; // Issue a valid token for our test user @@ -67,9 +104,9 @@ fn deny_harness() -> (axum::Router, String, TempDir) { let public_key = Arc::new(kp.public); let secret_key = Arc::new(kp.secret.clone()); - // Empty policy set → deny by default. No permits, no access. - let policy = PolicyEngine::new("").expect("empty policy"); - let policy_engine = Arc::new(policy); + let policy = + PolicyEngine::new("", get_test_schema()).expect("empty policy"); + let policy_engine = Arc::new(tokio::sync::RwLock::new(policy)); let state = AppState { engine: engine.clone(), @@ -78,6 +115,8 @@ fn deny_harness() -> (axum::Router, String, TempDir) { secret_key, policy_engine, cursor_signer: std::sync::Arc::new(forge_security::CursorSigner::new(&[0u8; 32])), + schema_path: db_path.with_extension("schema"), + policy_path: db_path.with_extension("policy"), }; let claims = TokenClaims::new("denied-user", 3600, None); @@ -329,3 +368,65 @@ async fn patch_document() { "name should be deleted (null merge semantics)" ); } + +#[tokio::test] +async fn test_dynamic_schema_patching() { + let (app, token, _tmp) = test_harness(); + + // 1. Try to POST to `invoices` - should fail with 403 because it's unregistered + let post_req = Request::builder() + .method("POST") + .uri("/v1/invoices") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"amount": 100}"#)) + .unwrap(); + let resp = app.clone().oneshot(post_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 2. Fetch the current schema + let get_schema_req = Request::builder() + .uri("/_/schema?raw=true") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(get_schema_req).await.unwrap(); + let schema_bytes = axum::body::to_bytes(resp.into_body(), 8192).await.unwrap(); + let mut schema: serde_json::Value = serde_json::from_slice(&schema_bytes).unwrap(); + + // 3. Patch the schema to add `Invoices` + schema["ForgeDB"]["entityTypes"]["Invoices"] = serde_json::json!({ + "shape": { "type": "Record", "attributes": {} } + }); + for action in ["Read", "Write", "Delete"] { + schema["ForgeDB"]["actions"][action]["appliesTo"]["resourceTypes"] + .as_array_mut() + .unwrap() + .push(serde_json::Value::String("Invoices".into())); + } + + // 4. PUT the updated schema + let put_schema_req = Request::builder() + .method("PUT") + .uri("/_/schema") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_string(&schema).unwrap())) + .unwrap(); + let resp = app.clone().oneshot(put_schema_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 5. POST to `invoices` again - should now succeed + let post_req = Request::builder() + .method("POST") + .uri("/v1/invoices") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .header(header::CONTENT_TYPE, "application/json") + .header(header::ACCEPT, "application/json") // force json + .body(Body::from(r#"{"amount": 100}"#)) + .unwrap(); + + let resp = app.oneshot(post_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index fd17f5e..aad7a4c 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-storage" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true diff --git a/crates/storage/src/writer.rs b/crates/storage/src/writer.rs index d04a29e..0c1f9a8 100644 --- a/crates/storage/src/writer.rs +++ b/crates/storage/src/writer.rs @@ -168,10 +168,10 @@ fn commit_batch(engine: &StorageEngine, batch: &[WriteRequest]) -> Result<()> { .push((&req.id, &req.payload)); } - // Since we're bridging across multiple collections potentially, and `insert_batch` - // runs ONE collection per transaction right now, we technically commit N transactions - // (where N = number of distinct collections hit in this 2ms window). - // In practice for a REST API, N is almost always 1. + // Currently: one transaction per distinct collection in the batch window. + // For a typical REST API this is N=1 almost always, so the cost is negligible. + // If you're hammering multiple collections simultaneously on a v0.4 cluster node, + // revisit this with a multi-table single-transaction path — flagged for v0.4. for (collection, docs) in by_collection { engine.insert_batch(collection, &docs, true)?; } diff --git a/crates/types/Cargo.toml b/crates/types/Cargo.toml index 7ebd33c..281b55c 100644 --- a/crates/types/Cargo.toml +++ b/crates/types/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "forge-types" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository.workspace = true