diff --git a/README.md b/README.md index 7b6df57..d864c3d 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Glues offers various storage options to suit your needs: - Point Glues at an HTTP proxy that exposes the same set of operations as the local backend. - Run the bundled proxy server with `glues server memory` (replace `memory` with `file`, `redb`, `git`, or `mongo` as needed). The server listens on `127.0.0.1:4000` by default; use `--listen` to change the address. - Protect externally reachable servers with an auth token. Set `GLUES_SERVER_TOKEN` or pass `--auth-token ` when launching the server. The TUI's Proxy flow will prompt for the token and send it as a `Bearer` header. Leave the field empty to connect to a token-free server on your local machine. + - To expose only part of a notebook, pass `--allowed-directory `. (Gather the target ID first by running the server once without the flag and calling `RootId`/`FetchDirectories`, or by inspecting the storage backend.) The proxy will treat that directory as the virtual root: reads and writes outside the subtree are rejected, and clients receive the allowed directory ID when requesting the root. - In the TUI entry menu choose `Proxy` (shortcut `[p]`), enter the proxy URL (e.g. `http://127.0.0.1:4000`), provide the token if required, and Glues will talk to the remote backend just like it does locally. > **Web build:** The browser version of Glues persists configuration through GlueSQL WebStorage (LocalStorage) and currently exposes the **Instant**, **IndexedDB**, and **Proxy** backends. Use IndexedDB to keep notes in-browser across sessions, or point the Proxy option at a running Glues proxy server to keep data outside the browser sandbox. diff --git a/server/src/args.rs b/server/src/args.rs new file mode 100644 index 0000000..4114e80 --- /dev/null +++ b/server/src/args.rs @@ -0,0 +1,49 @@ +use { + clap::{Args, Parser, Subcommand}, + glues_core::types::DirectoryId, + std::net::SocketAddr, +}; + +#[derive(Clone, Args)] +pub struct ServerArgs { + #[arg(long, default_value = "127.0.0.1:4000")] + pub listen: SocketAddr, + + #[arg(long, env = "GLUES_SERVER_TOKEN")] + pub auth_token: Option, + + #[arg(long)] + pub allowed_directory: Option, + + #[command(subcommand)] + pub storage: StorageCommand, +} + +#[derive(Parser)] +#[command(author, version, about = "Glues proxy server")] +struct Cli { + #[command(flatten)] + args: ServerArgs, +} + +#[derive(Subcommand, Clone)] +pub enum StorageCommand { + /// In-memory storage (data resets on restart) + Memory, + /// File storage backend rooted at the given path + File { path: String }, + /// redb single-file storage backend + Redb { path: String }, + /// Git storage backend + Git { + path: String, + remote: String, + branch: String, + }, + /// MongoDB storage backend + Mongo { conn_str: String, db_name: String }, +} + +pub fn parse_args() -> ServerArgs { + Cli::parse().args +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 96a28dc..1627d4f 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -1,3 +1,9 @@ +mod args; +mod proxy_access; +pub mod state; + +pub use args::{ServerArgs, StorageCommand, parse_args}; + use { axum::{ Json, Router, @@ -8,60 +14,20 @@ use { response::Response, routing::{get, post}, }, - clap::{Args, Parser, Subcommand}, color_eyre::Result, glues_core::backend::{ CoreBackend, local::Db, proxy::{ProxyServer, request::ProxyRequest, response::ProxyResponse}, }, - std::{net::SocketAddr, sync::Arc}, - tokio::{net::TcpListener, signal, sync::Mutex as AsyncMutex}, + std::sync::Arc, + tokio::{net::TcpListener, signal}, tower_http::cors::{Any, CorsLayer}, tracing::{error, info, warn}, tracing_subscriber::EnvFilter, }; -#[derive(Clone, Args)] -pub struct ServerArgs { - #[arg(long, default_value = "127.0.0.1:4000")] - pub listen: SocketAddr, - - #[arg(long, env = "GLUES_SERVER_TOKEN")] - pub auth_token: Option, - - #[command(subcommand)] - pub storage: StorageCommand, -} - -#[derive(Parser)] -#[command(author, version, about = "Glues proxy server")] -struct Cli { - #[command(flatten)] - args: ServerArgs, -} - -#[derive(Subcommand, Clone)] -pub enum StorageCommand { - /// In-memory storage (data resets on restart) - Memory, - /// File storage backend rooted at the given path - File { path: String }, - /// redb single-file storage backend - Redb { path: String }, - /// Git storage backend - Git { - path: String, - remote: String, - branch: String, - }, - /// MongoDB storage backend - Mongo { conn_str: String, db_name: String }, -} - -pub fn parse_args() -> ServerArgs { - Cli::parse().args -} +use state::ServerState; pub async fn run(args: ServerArgs) -> Result<()> { color_eyre::install()?; @@ -73,11 +39,13 @@ pub async fn run(args: ServerArgs) -> Result<()> { let ServerArgs { listen, auth_token, + allowed_directory, storage, } = args; let backend = build_backend(storage).await?; - let server = Arc::new(AsyncMutex::new(ProxyServer::new(backend))); + let server = ProxyServer::new(backend); + let state = Arc::new(ServerState::new(server, allowed_directory).await?); let cors = CorsLayer::new() .allow_origin(Any) @@ -87,7 +55,7 @@ pub async fn run(args: ServerArgs) -> Result<()> { let mut app = Router::new() .route("/", post(handle_proxy)) .route("/health", get(health)) - .with_state(server.clone()) + .with_state(state.clone()) .layer(cors); if let Some(token) = auth_token.as_ref() { @@ -137,11 +105,10 @@ async fn build_backend(storage: StorageCommand) -> Result>>, + State(state): State>, Json(request): Json, ) -> (StatusCode, Json) { - let mut server = server.lock_owned().await; - let response = server.handle(request).await; + let response = state.handle(request).await; (StatusCode::OK, Json(response)) } diff --git a/server/src/proxy_access.rs b/server/src/proxy_access.rs new file mode 100644 index 0000000..f23f712 --- /dev/null +++ b/server/src/proxy_access.rs @@ -0,0 +1,286 @@ +use { + color_eyre::Result, + glues_core::{ + backend::proxy::{ + ProxyServer, + request::ProxyRequest, + response::{ProxyResponse, ResultPayload}, + }, + types::{DirectoryId, NoteId}, + }, + std::{ + collections::{HashSet, VecDeque}, + sync::Arc, + }, + tokio::sync::RwLock, +}; + +pub(crate) struct ProxyAccess { + pub(crate) allowed_root: DirectoryId, + pub(crate) directories: HashSet, + pub(crate) notes: HashSet, +} + +impl ProxyAccess { + pub(crate) fn new( + allowed_root: DirectoryId, + directories: HashSet, + notes: HashSet, + ) -> Self { + Self { + allowed_root, + directories, + notes, + } + } + + pub(crate) fn evaluate(&self, request: &ProxyRequest) -> GuardEvaluation { + use ProxyRequest::*; + + match request { + RootId => GuardEvaluation::ReturnRoot { + root: self.allowed_root.clone(), + }, + FetchDirectory { directory_id } => { + self.guard_directory(directory_id, GuardEvaluation::allow()) + } + FetchDirectories { parent_id } + | FetchNotes { + directory_id: parent_id, + } => self.guard_directory(parent_id, GuardEvaluation::allow()), + FetchNoteContent { note_id } => self.guard_note(note_id, GuardEvaluation::allow()), + AddDirectory { parent_id, .. } => { + self.guard_directory(parent_id, GuardEvaluation::allow()) + } + RemoveDirectory { directory_id } => self.guard_directory(directory_id, { + if directory_id == &self.allowed_root { + GuardEvaluation::Deny { + message: "proxy: modifying the allowed root directory is not permitted" + .to_owned(), + } + } else { + GuardEvaluation::Allow { + pending: Some(PendingMutation::CollectRemoval { + directory_id: directory_id.clone(), + }), + } + } + }), + MoveDirectory { + directory_id, + parent_id, + } => { + if directory_id == &self.allowed_root { + return GuardEvaluation::Deny { + message: "proxy: moving the allowed root directory is not permitted" + .to_owned(), + }; + } + match ( + self.directories.contains(directory_id), + self.directories.contains(parent_id), + ) { + (true, true) => GuardEvaluation::allow(), + (false, _) => GuardEvaluation::deny_directory(directory_id), + (_, false) => GuardEvaluation::deny_directory(parent_id), + } + } + RenameDirectory { directory_id, .. } => { + if directory_id == &self.allowed_root { + GuardEvaluation::Deny { + message: "proxy: renaming the allowed root directory is not permitted" + .to_owned(), + } + } else { + self.guard_directory(directory_id, GuardEvaluation::allow()) + } + } + AddNote { directory_id, .. } => { + self.guard_directory(directory_id, GuardEvaluation::allow()) + } + RemoveNote { note_id } => self.guard_note(note_id, GuardEvaluation::allow()), + RenameNote { note_id, .. } | UpdateNoteContent { note_id, .. } => { + self.guard_note(note_id, GuardEvaluation::allow()) + } + MoveNote { + note_id, + directory_id, + } => { + let note_allowed = self.notes.contains(note_id); + let dir_allowed = self.directories.contains(directory_id); + match (note_allowed, dir_allowed) { + (true, true) => GuardEvaluation::allow(), + (false, _) => GuardEvaluation::deny_note(note_id), + (_, false) => GuardEvaluation::deny_directory(directory_id), + } + } + Log { .. } | Sync => GuardEvaluation::allow(), + } + } + + fn guard_directory(&self, directory_id: &DirectoryId, ok: GuardEvaluation) -> GuardEvaluation { + if self.directories.contains(directory_id) { + ok + } else { + GuardEvaluation::deny_directory(directory_id) + } + } + + fn guard_note(&self, note_id: &NoteId, ok: GuardEvaluation) -> GuardEvaluation { + if self.notes.contains(note_id) { + ok + } else { + GuardEvaluation::deny_note(note_id) + } + } +} + +pub(crate) enum GuardEvaluation { + Allow { pending: Option }, + ReturnRoot { root: DirectoryId }, + Deny { message: String }, +} + +impl GuardEvaluation { + pub(crate) fn allow() -> GuardEvaluation { + GuardEvaluation::Allow { pending: None } + } + + pub(crate) fn deny_directory(id: &DirectoryId) -> GuardEvaluation { + GuardEvaluation::Deny { + message: format!("proxy: directory {id} is outside the allowed subtree"), + } + } + + pub(crate) fn deny_note(id: &NoteId) -> GuardEvaluation { + GuardEvaluation::Deny { + message: format!("proxy: note {id} is outside the allowed subtree"), + } + } +} + +pub(crate) enum PendingMutation { + CollectRemoval { directory_id: DirectoryId }, +} + +pub(crate) struct RemovalPlan { + pub(crate) directories: Vec, + pub(crate) notes: Vec, +} + +pub(crate) enum PostPlan { + None, + AddDirectory, + AddNote, + RemoveNote { note_id: NoteId }, + RemoveDirectory(RemovalPlan), +} + +impl PostPlan { + pub(crate) fn from_request(request: &ProxyRequest) -> Self { + match request { + ProxyRequest::AddDirectory { .. } => PostPlan::AddDirectory, + ProxyRequest::AddNote { .. } => PostPlan::AddNote, + ProxyRequest::RemoveNote { note_id } => PostPlan::RemoveNote { + note_id: note_id.clone(), + }, + _ => PostPlan::None, + } + } +} + +pub(crate) async fn load_proxy_access( + server: &mut ProxyServer, + root: DirectoryId, +) -> Result { + // Ensure the root exists before building the cache. + let _ = server.db.fetch_directory(root.clone()).await?; + + let mut directories = HashSet::new(); + let mut notes = HashSet::new(); + let mut queue = VecDeque::new(); + + directories.insert(root.clone()); + queue.push_back(root.clone()); + + while let Some(dir_id) = queue.pop_front() { + let children = server.db.fetch_directories(dir_id.clone()).await?; + for child in children { + if directories.insert(child.id.clone()) { + queue.push_back(child.id.clone()); + } + } + + let dir_notes = server.db.fetch_notes(dir_id).await?; + for note in dir_notes { + notes.insert(note.id); + } + } + + Ok(ProxyAccess::new(root, directories, notes)) +} + +pub(crate) async fn collect_removal_plan( + server: &mut ProxyServer, + directory_id: DirectoryId, +) -> Result { + let mut directories = Vec::new(); + let mut notes = Vec::new(); + let mut queue = VecDeque::new(); + + queue.push_back(directory_id.clone()); + + while let Some(dir_id) = queue.pop_front() { + directories.push(dir_id.clone()); + + let children = server.db.fetch_directories(dir_id.clone()).await?; + for child in children { + queue.push_back(child.id.clone()); + } + + let dir_notes = server.db.fetch_notes(dir_id).await?; + for note in dir_notes { + notes.push(note.id); + } + } + + Ok(RemovalPlan { directories, notes }) +} +pub(crate) async fn apply_post_plan( + access: &Arc>, + plan: PostPlan, + response: &ProxyResponse, +) { + match plan { + PostPlan::None => {} + PostPlan::AddDirectory => { + if let ProxyResponse::Ok(ResultPayload::Directory(directory)) = response { + let mut guard = access.write().await; + guard.directories.insert(directory.id.clone()); + } + } + PostPlan::AddNote => { + if let ProxyResponse::Ok(ResultPayload::Note(note)) = response { + let mut guard = access.write().await; + guard.notes.insert(note.id.clone()); + } + } + PostPlan::RemoveNote { note_id } => { + if matches!(response, ProxyResponse::Ok(ResultPayload::Unit)) { + let mut guard = access.write().await; + guard.notes.remove(¬e_id); + } + } + PostPlan::RemoveDirectory(RemovalPlan { directories, notes }) => { + if matches!(response, ProxyResponse::Ok(ResultPayload::Unit)) { + let mut guard = access.write().await; + for directory_id in directories { + guard.directories.remove(&directory_id); + } + for note_id in notes { + guard.notes.remove(¬e_id); + } + } + } + } +} diff --git a/server/src/state.rs b/server/src/state.rs new file mode 100644 index 0000000..b05f7c8 --- /dev/null +++ b/server/src/state.rs @@ -0,0 +1,99 @@ +use { + crate::proxy_access::{ + GuardEvaluation, PendingMutation, PostPlan, ProxyAccess, apply_post_plan, + collect_removal_plan, load_proxy_access, + }, + color_eyre::Result, + glues_core::backend::proxy::{ + ProxyServer, + request::ProxyRequest, + response::{ProxyResponse, ResultPayload}, + }, + glues_core::types::DirectoryId, + std::sync::Arc, + tokio::sync::{Mutex as AsyncMutex, RwLock}, +}; + +pub struct ServerState { + server: AsyncMutex, + access: Option>>, +} + +impl ServerState { + pub async fn new( + mut server: ProxyServer, + allowed_directory: Option, + ) -> Result { + let access = initialize_proxy_access(allowed_directory, &mut server).await?; + Ok(Self { + server: AsyncMutex::new(server), + access, + }) + } + + pub async fn handle(&self, request: ProxyRequest) -> ProxyResponse { + let mut post_plan = if self.access.is_some() { + PostPlan::from_request(&request) + } else { + PostPlan::None + }; + + let mut pending = None; + let access_arc = if let Some(access) = &self.access { + let evaluation = { + let guard = access.read().await; + guard.evaluate(&request) + }; + match evaluation { + GuardEvaluation::ReturnRoot { root } => { + return ProxyResponse::Ok(ResultPayload::Id(root)); + } + GuardEvaluation::Deny { message } => { + return ProxyResponse::Err(message); + } + GuardEvaluation::Allow { pending: p } => { + pending = p; + Some(Arc::clone(access)) + } + } + } else { + None + }; + + let mut server = self.server.lock().await; + + if let Some(PendingMutation::CollectRemoval { directory_id }) = pending { + match collect_removal_plan(&mut server, directory_id).await { + Ok(plan) => { + post_plan = PostPlan::RemoveDirectory(plan); + } + Err(err) => { + return ProxyResponse::Err(err.to_string()); + } + } + } + + let response = server.handle(request).await; + + drop(server); + + if let Some(access) = access_arc.as_ref() { + apply_post_plan(access, post_plan, &response).await; + } + + response + } +} + +async fn initialize_proxy_access( + allowed_directory: Option, + server: &mut ProxyServer, +) -> Result>>> { + match allowed_directory { + Some(root) => { + let access = load_proxy_access(server, root).await?; + Ok(Some(Arc::new(RwLock::new(access)))) + } + None => Ok(None), + } +} diff --git a/server/tests/proxy_access.rs b/server/tests/proxy_access.rs new file mode 100644 index 0000000..5ecc784 --- /dev/null +++ b/server/tests/proxy_access.rs @@ -0,0 +1,181 @@ +use { + glues_core::backend::{ + local::Db, + proxy::{ + request::ProxyRequest, + response::{ProxyResponse, ResultPayload}, + }, + }, + glues_server::state::ServerState, +}; + +fn expect_directory(response: ProxyResponse) -> glues_core::data::Directory { + match response { + ProxyResponse::Ok(ResultPayload::Directory(dir)) => dir, + other => panic!("expected directory response, got {other:?}"), + } +} + +fn expect_note(response: ProxyResponse) -> glues_core::data::Note { + match response { + ProxyResponse::Ok(ResultPayload::Note(note)) => note, + other => panic!("expected note response, got {other:?}"), + } +} + +fn expect_id(response: ProxyResponse) -> glues_core::types::DirectoryId { + match response { + ProxyResponse::Ok(ResultPayload::Id(id)) => id, + other => panic!("expected id response, got {other:?}"), + } +} + +fn expect_unit(response: ProxyResponse) { + match response { + ProxyResponse::Ok(ResultPayload::Unit) => {} + other => panic!("expected unit response, got {other:?}"), + } +} + +fn expect_err_contains(response: ProxyResponse, needle: &str) { + match response { + ProxyResponse::Err(message) if message.contains(needle) => {} + ProxyResponse::Err(message) => panic!("unexpected error message: {message}"), + other => panic!("expected error response, got {other:?}"), + } +} + +#[tokio::test] +async fn proxy_guard_enforces_allowed_subtree() { + let db = Db::memory().await.expect("memory backend"); + let mut proxy = glues_core::backend::proxy::ProxyServer::new(Box::new(db)); + let root_id = proxy.db.root_id(); + + let allowed_dir = expect_directory( + proxy + .handle(ProxyRequest::AddDirectory { + parent_id: root_id.clone(), + name: "allowed".into(), + }) + .await, + ); + + let outside_dir = expect_directory( + proxy + .handle(ProxyRequest::AddDirectory { + parent_id: root_id.clone(), + name: "outside".into(), + }) + .await, + ); + + let nested_dir = expect_directory( + proxy + .handle(ProxyRequest::AddDirectory { + parent_id: allowed_dir.id.clone(), + name: "nested".into(), + }) + .await, + ); + + let note = expect_note( + proxy + .handle(ProxyRequest::AddNote { + directory_id: nested_dir.id.clone(), + name: "note".into(), + }) + .await, + ); + + let state = ServerState::new(proxy, Some(allowed_dir.id.clone())) + .await + .expect("create server state"); + + let root_response = state.handle(ProxyRequest::RootId).await; + let seen_root = expect_id(root_response); + assert_eq!(seen_root, allowed_dir.id); + + expect_err_contains( + state + .handle(ProxyRequest::FetchDirectory { + directory_id: outside_dir.id.clone(), + }) + .await, + "outside the allowed subtree", + ); + + expect_err_contains( + state + .handle(ProxyRequest::AddDirectory { + parent_id: root_id.clone(), + name: "blocked".into(), + }) + .await, + "outside the allowed subtree", + ); + + let new_child = expect_directory( + state + .handle(ProxyRequest::AddDirectory { + parent_id: allowed_dir.id.clone(), + name: "child".into(), + }) + .await, + ); + + expect_unit( + state + .handle(ProxyRequest::RenameDirectory { + directory_id: new_child.id.clone(), + name: "renamed".into(), + }) + .await, + ); + + expect_err_contains( + state + .handle(ProxyRequest::MoveNote { + note_id: note.id.clone(), + directory_id: outside_dir.id.clone(), + }) + .await, + "outside the allowed subtree", + ); + + expect_unit( + state + .handle(ProxyRequest::RemoveDirectory { + directory_id: nested_dir.id.clone(), + }) + .await, + ); + + expect_err_contains( + state + .handle(ProxyRequest::FetchNoteContent { + note_id: note.id.clone(), + }) + .await, + "outside the allowed subtree", + ); + + expect_err_contains( + state + .handle(ProxyRequest::RenameDirectory { + directory_id: nested_dir.id.clone(), + name: "should-fail".into(), + }) + .await, + "outside the allowed subtree", + ); + + expect_err_contains( + state + .handle(ProxyRequest::RenameDirectory { + directory_id: allowed_dir.id.clone(), + name: "forbidden".into(), + }) + .await, + "not permitted", + ); +}