diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a00e546..6927063ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ List of changes for this repo, including `atomic-cli`, `atomic-server` and `atomic-lib`. By far most changes relate to `atomic-server`, so if not specified, assume the changes are relevant only for the server. +## v0.29.0 + +- Add authentication to restrict read access. Works by signing requests with Private Keys. #13 +- Refactor internal error model, Use correct HTTP status codes #11 +- Add `public-mode` to server, to keep performance maximum if you don't want authentication. + ## v0.28.2 - Full-text search endpoint, powered by Tantify #40 diff --git a/Cargo.lock b/Cargo.lock index 3bd59b38a..8503df841 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -462,7 +462,7 @@ dependencies = [ [[package]] name = "atomic-cli" -version = "0.28.1" +version = "0.29.0" dependencies = [ "assert_cmd", "atomic_lib", @@ -476,7 +476,7 @@ dependencies = [ [[package]] name = "atomic-server" -version = "0.28.2" +version = "0.29.0" dependencies = [ "acme-lib", "actix", @@ -488,6 +488,7 @@ dependencies = [ "atomic_lib", "chrono", "clap 3.0.0-beta.5", + "clap_generate", "dirs 3.0.2", "dotenv", "env_logger 0.9.0", @@ -509,7 +510,7 @@ dependencies = [ [[package]] name = "atomic_lib" -version = "0.28.2" +version = "0.29.0" dependencies = [ "base64 0.13.0", "bincode", @@ -860,6 +861,15 @@ dependencies = [ "syn", ] +[[package]] +name = "clap_generate" +version = "3.0.0-beta.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "097ab5db1c3417442270cd57c8dd39f6c3114d3ce09d595f9efddbb1fcfaa799" +dependencies = [ + "clap 3.0.0-beta.5", +] + [[package]] name = "cocoa" version = "0.20.2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 78945b430..fa84c9d11 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -6,10 +6,10 @@ license = "MIT" name = "atomic-cli" readme = "README.md" repository = "https://github.com/joepio/atomic-data-rust" -version = "0.28.1" +version = "0.29.0" [dependencies] -atomic_lib = {version = "0.28.1", path = "../lib", features = ["config", "rdf"]} +atomic_lib = {version = "0.29.0", path = "../lib", features = ["config", "rdf"]} clap = "2.33.3" colored = "2.0.0" dirs = "3.0.1" diff --git a/cli/src/main.rs b/cli/src/main.rs index 6a8109e03..42d5c9392 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -46,7 +46,7 @@ impl Context<'_> { } /// Reads config files for writing data, or promps the user if they don't yet exist -fn set_agent_config() -> AtomicResult { +fn set_agent_config() -> CLIResult { let agent_config_path = atomic_lib::config::default_config_file_path()?; match atomic_lib::config::read_config(&agent_config_path) { Ok(found) => Ok(found), @@ -328,3 +328,5 @@ fn validate(context: &mut Context) { let reportstring = context.store.validate().to_string(); println!("{}", reportstring); } + +pub type CLIResult = std::result::Result>; diff --git a/cli/src/new.rs b/cli/src/new.rs index 3cb2daeb5..531d93d1f 100644 --- a/cli/src/new.rs +++ b/cli/src/new.rs @@ -1,5 +1,5 @@ //! Creating a new resource. Provides prompting logic -use crate::Context; +use crate::{CLIResult, Context}; use atomic_lib::mapping; use atomic_lib::{ datatype::DataType, @@ -44,7 +44,7 @@ fn prompt_instance<'a>( context: &'a Context, class: &Class, preffered_shortname: Option, -) -> AtomicResult<(Resource, Option)> { +) -> CLIResult<(Resource, Option)> { // Not sure about the best way t // The Path is the thing at the end of the URL, from the domain // Here I set some (kind of) random numbers. @@ -123,7 +123,7 @@ fn prompt_field( property: &Property, optional: bool, context: &Context, -) -> AtomicResult> { +) -> CLIResult> { let mut input: Option = None; let msg_appendix: &str = if optional { " (optional)" diff --git a/cli/src/path.rs b/cli/src/path.rs index 1bf3075ac..358e556fb 100644 --- a/cli/src/path.rs +++ b/cli/src/path.rs @@ -17,10 +17,10 @@ pub fn get_path(context: &mut Context) -> AtomicResult<()> { // Returns a URL or Value let store = &mut context.store; - let path = store.get_path(&path_string, Some(&context.mapping.lock().unwrap()))?; + let path = store.get_path(&path_string, Some(&context.mapping.lock().unwrap()), None)?; let out = match path { storelike::PathReturn::Subject(subject) => { - let resource = store.get_resource_extended(&subject, false)?; + let resource = store.get_resource_extended(&subject, false, None)?; print_resource(context, &resource, subcommand_matches)?; return Ok(()); } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index b2fff778b..863103ed7 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" name = "atomic_lib" readme = "README.md" repository = "https://github.com/joepio/atomic-data-rust" -version = "0.28.2" +version = "0.29.0" [dependencies] base64 = "0.13.0" diff --git a/lib/src/authentication.rs b/lib/src/authentication.rs new file mode 100644 index 000000000..392d6770e --- /dev/null +++ b/lib/src/authentication.rs @@ -0,0 +1,79 @@ +//! Check signatures in authentication headers, find the correct agent. Authorization is done in Hierarchies + +use crate::{commit::check_timestamp, errors::AtomicResult, Storelike}; + +/// Set of values extracted from the request. +/// Most are coming from headers. +pub struct AuthValues { + // x-atomic-public-key + pub public_key: String, + // x-atomic-timestamp + pub timestamp: i64, + // x-atomic-signature + // Base64 encoded public key from `subject_url timestamp` + pub signature: String, + pub requested_subject: String, + pub agent_subject: String, +} + +/// Checks if the signature is valid for this timestamp. +/// Does not check if the agent has rights to access the subject. +pub fn check_auth_signature(subject: &str, auth_header: &AuthValues) -> AtomicResult<()> { + let agent_pubkey = base64::decode(&auth_header.public_key)?; + let message = format!("{} {}", subject, &auth_header.timestamp); + let peer_public_key = + ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, agent_pubkey); + let signature_bytes = base64::decode(&auth_header.signature)?; + peer_public_key + .verify(message.as_bytes(), &signature_bytes) + .map_err(|_e| { + format!( + "Incorrect signature for auth headers. This could be due to an error during signing or serialization of the commit. Compare this to the serialized message in the client: {}", + message, + ) + })?; + Ok(()) +} + +/// Get the Agent's subject from headers +/// Checks if the auth headers are correct, whether signature matches the public key, whether the timestamp is valid. +/// by default, returns the public agent +pub fn get_agent_from_headers_and_check( + auth_header_values: Option, + store: &impl Storelike, +) -> AtomicResult { + let mut for_agent = crate::urls::PUBLIC_AGENT.to_string(); + if let Some(auth_vals) = auth_header_values { + // If there are auth headers, check 'em, make sure they are valid. + check_auth_signature(&auth_vals.requested_subject, &auth_vals)?; + // check if the timestamp is valid + check_timestamp(auth_vals.timestamp)?; + // check if the public key belongs to the agent + let agent = store.get_resource(&auth_vals.agent_subject)?; + let found_public_key = agent.get(crate::urls::PUBLIC_KEY)?; + if found_public_key.to_string() != auth_vals.public_key { + return Err( + "The public key in the auth headers does not match the public key in the agent" + .to_string() + .into(), + ); + } else { + for_agent = auth_vals.agent_subject; + } + }; + Ok(for_agent) +} + +// fn get_agent_from_value_index() { +// let map = store.get_prop_subject_map(&auth_vals.public_key)?; +// let agents = map.get(crate::urls::PUBLIC_KEY).ok_or(format!( +// "No agents for this public key: {}", +// &auth_vals.public_key +// ))?; +// // TODO: This is unreliable, as this will break if multiple atoms with the same public key exist. +// if agents.len() > 1 { +// return Err("Multiple agents for this public key".into()); +// } else if let Some(found) = agents.iter().next() { +// for_agent = Some(found.to_string()); +// } +// } diff --git a/lib/src/client.rs b/lib/src/client.rs index 6208044d2..3c747ed3c 100644 --- a/lib/src/client.rs +++ b/lib/src/client.rs @@ -1,34 +1,70 @@ //! Functions for interacting with an Atomic Server use url::Url; -use crate::{errors::AtomicResult, parse::parse_json_ad_resource, Resource, Storelike}; +use crate::{ + agents::Agent, commit::sign_message, errors::AtomicResult, parse::parse_json_ad_resource, + Resource, Storelike, +}; /// Fetches a resource, makes sure its subject matches. /// Checks the datatypes for the Values. /// Ignores all atoms where the subject is different. /// WARNING: Calls store methods, and is called by store methods, might get stuck in a loop! -pub fn fetch_resource(subject: &str, store: &impl Storelike) -> AtomicResult { - let body = fetch_body(subject, crate::parse::JSON_AD_MIME)?; +pub fn fetch_resource( + subject: &str, + store: &impl Storelike, + for_agent: Option, +) -> AtomicResult { + let body = fetch_body(subject, crate::parse::JSON_AD_MIME, for_agent)?; let resource = parse_json_ad_resource(&body, store) .map_err(|e| format!("Error parsing body of {}. {}", subject, e))?; Ok(resource) } -/// Fetches a URL, returns its body -pub fn fetch_body(url: &str, content_type: &str) -> AtomicResult { +/// Returns the various x-atomic authentication headers, includign agent signature +pub fn get_authentication_headers(url: &str, agent: &Agent) -> AtomicResult> { + let mut headers = Vec::new(); + let now = crate::datetime_helpers::now().to_string(); + let message = format!("{} {}", url, now); + let signature = sign_message( + &message, + agent + .private_key + .as_ref() + .ok_or("No private key in agent")?, + &agent.public_key, + )?; + headers.push(("x-atomic-public-key".into(), agent.public_key.to_string())); + headers.push(("x-atomic-signature".into(), signature)); + headers.push(("x-atomic-timestamp".into(), now)); + headers.push(("x-atomic-agent".into(), agent.subject.to_string())); + Ok(headers) +} + +/// Fetches a URL, returns its body. +/// Uses the store's Agent agent (if set) to sign the request. +pub fn fetch_body(url: &str, content_type: &str, for_agent: Option) -> AtomicResult { if !url.starts_with("http") { return Err(format!("Could not fetch url '{}', must start with http.", url).into()); } + if let Some(agent) = for_agent { + get_authentication_headers(url, &agent)?; + } let resp = ureq::get(url) .set("Accept", content_type) .timeout_read(2000) .call(); - if resp.status() != 200 { - return Err(format!("Could not fetch url '{}'. Status: {}", url, resp.status()).into()); - }; + let status = resp.status(); let body = resp .into_string() - .map_err(|e| format!("Could not parse response {}: {}", url, e))?; + .map_err(|e| format!("Could not parse HTTP response for {}: {}", url, e))?; + if status != 200 { + return Err(format!( + "Could not fetch url '{}'. Status: {}. Body: {}", + url, status, body + ) + .into()); + }; Ok(body) } @@ -50,7 +86,11 @@ pub fn fetch_tpf( if let Some(val) = q_value { url.query_pairs_mut().append_pair("value", val); } - let body = fetch_body(url.as_str(), "application/ad+json")?; + let body = fetch_body( + url.as_str(), + "application/ad+json", + store.get_default_agent().ok(), + )?; crate::parse::parse_json_ad_array(&body, store, false) } @@ -97,7 +137,7 @@ mod test { #[ignore] fn fetch_resource_basic() { let store = crate::Store::init().unwrap(); - let resource = fetch_resource(crate::urls::SHORTNAME, &store).unwrap(); + let resource = fetch_resource(crate::urls::SHORTNAME, &store, None).unwrap(); let shortname = resource.get(crate::urls::SHORTNAME).unwrap(); assert!(shortname.to_string() == "shortname"); } diff --git a/lib/src/collections.rs b/lib/src/collections.rs index f9c39943a..64c8f0edd 100644 --- a/lib/src/collections.rs +++ b/lib/src/collections.rs @@ -108,8 +108,12 @@ impl CollectionBuilder { } /// Converts the CollectionBuilder into a collection, with Members - pub fn into_collection(self, store: &impl Storelike) -> AtomicResult { - Collection::new_with_members(store, self) + pub fn into_collection( + self, + store: &impl Storelike, + for_agent: Option<&str>, + ) -> AtomicResult { + Collection::new_with_members(store, self, for_agent) } } @@ -182,6 +186,7 @@ impl Collection { pub fn new_with_members( store: &impl Storelike, collection_builder: crate::collections::CollectionBuilder, + for_agent: Option<&str>, ) -> AtomicResult { if collection_builder.page_size < 1 { return Err("Page size must be greater than 0".into()); @@ -206,7 +211,7 @@ impl Collection { if collection_builder.sort_by.is_some() || collection_builder.include_nested { for subject in subjects.iter() { // These nested resources are not fully calculated - they will be presented as -is - let resource = store.get_resource_extended(subject, true)?; + let resource = store.get_resource_extended(subject, true, for_agent)?; resources.push(resource) } if let Some(sort) = &collection_builder.sort_by { @@ -415,7 +420,7 @@ pub fn construct_collection( include_nested, include_external, }; - let collection = Collection::new_with_members(store, collection_builder)?; + let collection = Collection::new_with_members(store, collection_builder, None)?; collection.add_to_resource(resource, store) } @@ -441,7 +446,7 @@ mod test { include_nested: false, include_external: false, }; - let collection = Collection::new_with_members(&store, collection_builder).unwrap(); + let collection = Collection::new_with_members(&store, collection_builder, None).unwrap(); assert!(collection.members.contains(&urls::PROPERTY.into())); } @@ -461,7 +466,7 @@ mod test { include_nested: false, include_external: false, }; - let collection = Collection::new_with_members(&store, collection_builder).unwrap(); + let collection = Collection::new_with_members(&store, collection_builder, None).unwrap(); assert!(collection.members.contains(&urls::PROPERTY.into())); let resource_collection = &collection.to_resource(&store).unwrap(); @@ -487,7 +492,7 @@ mod test { include_nested: true, include_external: false, }; - let collection = Collection::new_with_members(&store, collection_builder).unwrap(); + let collection = Collection::new_with_members(&store, collection_builder, None).unwrap(); let first_resource = &collection.members_nested.clone().unwrap()[0]; assert!(first_resource.get_subject().contains("Agent")); @@ -511,7 +516,11 @@ mod test { .collect(); println!("{:?}", subjects); let collections_collection = store - .get_resource_extended(&format!("{}/collections", store.get_base_url()), false) + .get_resource_extended( + &format!("{}/collections", store.get_base_url()), + false, + None, + ) .unwrap(); assert!( collections_collection @@ -536,7 +545,7 @@ mod test { store.populate().unwrap(); let collection_page_size = store - .get_resource_extended("https://atomicdata.dev/classes?page_size=1", false) + .get_resource_extended("https://atomicdata.dev/classes?page_size=1", false, None) .unwrap(); assert!( collection_page_size @@ -549,6 +558,7 @@ mod test { .get_resource_extended( "https://atomicdata.dev/classes?current_page=2&page_size=1", false, + None, ) .unwrap(); assert!( diff --git a/lib/src/commit.rs b/lib/src/commit.rs index 272684ec7..da14c8e5d 100644 --- a/lib/src/commit.rs +++ b/lib/src/commit.rs @@ -5,8 +5,8 @@ use std::collections::{HashMap, HashSet}; use urls::{SET, SIGNER}; use crate::{ - datatype::DataType, errors::AtomicResult, resources::PropVals, urls, Atom, Resource, Storelike, - Value, + datatype::DataType, datetime_helpers, errors::AtomicResult, resources::PropVals, urls, Atom, + AtomicError, Resource, Storelike, Value, }; /// Contains two resources. The first is the Resource representation of the applied Commits. @@ -74,8 +74,8 @@ impl Commit { validate_rights: bool, update_index: bool, ) -> AtomicResult { - let subject_url = - url::Url::parse(&self.subject).map_err(|e| format!("Subject is not a URL. {}", e))?; + let subject_url = url::Url::parse(&self.subject) + .map_err(|e| format!("Subject '{}' is not a URL. {}", &self.subject, e))?; if subject_url.query().is_some() { return Err("Subject URL cannot have query parameters".into()); } @@ -106,19 +106,7 @@ impl Commit { } // Check if the created_at lies in the past if validate_timestamp { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("Time went backwards") - .as_millis() as i64; - let acceptable_ms_difference = 10000; - if self.created_at > now + acceptable_ms_difference { - return Err(format!( - "Commit CreatedAt timestamp must lie in the past. Check your clock. Timestamp now: {} CreatedAt is: {}", - now, self.created_at - ) - .into()); - // TODO: also check that no younger commits exist - } + check_timestamp(self.created_at)?; } let commit_resource: Resource = self.clone().into_resource(store)?; let mut is_new = false; @@ -135,9 +123,10 @@ impl Commit { if validate_rights { if is_new { - if !crate::hierarchy::check_write(store, &resource_new, self.signer.clone())? { - return Err(format!("Agent {} is not permitted to create {}. There should be a write right referring to this Agent in this Resource or its parent.", - &self.signer, self.subject).into()); + if !crate::hierarchy::check_write(store, &resource_new, &self.signer)? { + return Err(AtomicError::unauthorized( + format!("Agent {} is not permitted to create {}. There should be a write right referring to this Agent in this Resource or its parent.", + &self.signer, self.subject))); } } else { // Set a parent only if the rights checks are to be validated. @@ -152,9 +141,9 @@ impl Commit { )?; } // This should use the _old_ resource, no the new one, as the new one might maliciously give itself write rights. - if !crate::hierarchy::check_write(store, &resource_old, self.signer.clone())? { - return Err(format!("Agent {} is not permitted to edit {}. There should be a write right referring to this Agent in this Resource or its parent.", - &self.signer, self.subject).into()); + if !crate::hierarchy::check_write(store, &resource_old, &self.signer)? { + return Err(AtomicError::unauthorized(format!("Agent {} is not permitted to edit {}. There should be a write right referring to this Agent in this Resource or its parent.", + &self.signer, self.subject))); } } }; @@ -438,7 +427,7 @@ fn sign_at( } /// Signs a string using a base64 encoded ed25519 private key. Outputs a base64 encoded ed25519 signature. -fn sign_message(message: &str, private_key: &str, public_key: &str) -> AtomicResult { +pub fn sign_message(message: &str, private_key: &str, public_key: &str) -> AtomicResult { let private_key_bytes = base64::decode(private_key.to_string()).map_err(|e| { format!( "Failed decoding private key {}: {}", @@ -465,6 +454,22 @@ fn sign_message(message: &str, private_key: &str, public_key: &str) -> AtomicRes Ok(signatureb64) } +/// The amount of milliseconds that a Commit signature is valid for. +const ACCEPTABLE_TIME_DIFFERENCE: i64 = 10000; + +pub fn check_timestamp(timestamp: i64) -> AtomicResult<()> { + let now = datetime_helpers::now(); + if timestamp > now + ACCEPTABLE_TIME_DIFFERENCE { + return Err(format!( + "Commit CreatedAt timestamp must lie in the past. Check your clock. Timestamp now: {} CreatedAt is: {}", + now, timestamp + ) + .into()); + // TODO: also check that no younger commits exist + } + Ok(()) +} + #[cfg(test)] mod test { use super::*; diff --git a/lib/src/datetime_helpers.rs b/lib/src/datetime_helpers.rs index 3fa913736..a50586d2b 100644 --- a/lib/src/datetime_helpers.rs +++ b/lib/src/datetime_helpers.rs @@ -1,4 +1,4 @@ -/// Returns the current UNIX timestamp in milliseconds +/// Returns the current timestamp in milliseconds since UNIX epoch pub fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) diff --git a/lib/src/db.rs b/lib/src/db.rs index fa4e522e6..947ccf3b6 100644 --- a/lib/src/db.rs +++ b/lib/src/db.rs @@ -8,7 +8,7 @@ use std::{ use crate::{ datatype::DataType, - errors::AtomicResult, + errors::{AtomicError, AtomicResult}, resources::PropVals, storelike::{ResourceCollection, Storelike}, Atom, Resource, Value, @@ -83,12 +83,15 @@ impl Db { })?; Ok(propval) } - None => Err(format!("Resource {} not found", subject).into()), + None => Err(AtomicError::not_found(format!( + "Resource {} not found", + subject + ))), } } /// Search for a value, get a PropSubjectMap. If it does not exist, create a new one. - fn get_prop_subject_map(&self, string_val: &str) -> AtomicResult { + pub fn get_prop_subject_map(&self, string_val: &str) -> AtomicResult { let prop_sub_map = self .index_vals .get(string_val) @@ -272,7 +275,12 @@ impl Storelike for Db { } } - fn get_resource_extended(&self, subject: &str, skip_dynamic: bool) -> AtomicResult { + fn get_resource_extended( + &self, + subject: &str, + skip_dynamic: bool, + for_agent: Option<&str>, + ) -> AtomicResult { // This might add a trailing slash let mut url = url::Url::parse(subject)?; let clone = url.clone(); @@ -291,7 +299,7 @@ impl Storelike for Db { let mut endpoint_resource = None; endpoints.into_iter().for_each(|endpoint| { if url.path().starts_with(&endpoint.path) { - endpoint_resource = Some((endpoint.handle)(clone.clone(), self)) + endpoint_resource = Some((endpoint.handle)(clone.clone(), self, for_agent)) } }); @@ -307,6 +315,15 @@ impl Storelike for Db { // make sure the actual subject matches the one requested resource.set_subject(subject.into()); + if let Some(agent) = for_agent { + if !crate::hierarchy::check_read(self, &resource, agent)? { + return Err(AtomicError::unauthorized(format!( + "Agent '{}' is not authorized to read '{}'. There should be a `read` right in this resource or one of its parents.", + agent, subject + ))); + } + } + // Whether the resource has dynamic properties let mut has_dynamic = false; // If a certain class needs to be extended, add it to this match statement @@ -629,7 +646,7 @@ pub mod test { println!("{:?}", subjects); let collections_collection_url = format!("{}/collections", store.get_base_url()); let collections_resource = store - .get_resource_extended(&collections_collection_url, false) + .get_resource_extended(&collections_collection_url, false, None) .unwrap(); let member_count = collections_resource .get(crate::urls::COLLECTION_MEMBER_COUNT) @@ -674,7 +691,9 @@ pub mod test { fn destroy_resource_and_check_collection_and_commits() { let store = init("counter"); let agents_url = format!("{}/agents", store.get_base_url()); - let agents_collection_1 = store.get_resource_extended(&agents_url, false).unwrap(); + let agents_collection_1 = store + .get_resource_extended(&agents_url, false, None) + .unwrap(); let agents_collection_count_1 = agents_collection_1 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -687,7 +706,9 @@ pub mod test { // We will count the commits, and check if they've incremented later on. let commits_url = format!("{}/commits", store.get_base_url()); - let commits_collection_1 = store.get_resource_extended(&commits_url, false).unwrap(); + let commits_collection_1 = store + .get_resource_extended(&commits_url, false, None) + .unwrap(); let commits_collection_count_1 = commits_collection_1 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -700,7 +721,9 @@ pub mod test { .to_resource(&store) .unwrap(); resource.save_locally(&store).unwrap(); - let agents_collection_2 = store.get_resource_extended(&agents_url, false).unwrap(); + let agents_collection_2 = store + .get_resource_extended(&agents_url, false, None) + .unwrap(); let agents_collection_count_2 = agents_collection_2 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -711,7 +734,9 @@ pub mod test { "The Resource was not found in the collection." ); - let commits_collection_2 = store.get_resource_extended(&commits_url, false).unwrap(); + let commits_collection_2 = store + .get_resource_extended(&commits_url, false, None) + .unwrap(); let commits_collection_count_2 = commits_collection_2 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -725,7 +750,9 @@ pub mod test { ); resource.destroy(&store).unwrap(); - let agents_collection_3 = store.get_resource_extended(&agents_url, false).unwrap(); + let agents_collection_3 = store + .get_resource_extended(&agents_url, false, None) + .unwrap(); let agents_collection_count_3 = agents_collection_3 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -736,7 +763,9 @@ pub mod test { "The collection count did not decrease after destroying the resource." ); - let commits_collection_3 = store.get_resource_extended(&commits_url, false).unwrap(); + let commits_collection_3 = store + .get_resource_extended(&commits_url, false, None) + .unwrap(); let commits_collection_count_3 = commits_collection_3 .get(crate::urls::COLLECTION_MEMBER_COUNT) .unwrap() @@ -755,11 +784,13 @@ pub mod test { let store = DB.lock().unwrap().clone(); let subject = format!("{}/commits?current_page=2", store.get_base_url()); // Should throw, because page 2 is out of bounds for default page size - let _wrong_resource = store.get_resource_extended(&subject, false).unwrap_err(); + let _wrong_resource = store + .get_resource_extended(&subject, false, None) + .unwrap_err(); // let subject = "https://atomicdata.dev/classes?current_page=2&page_size=1"; let subject_with_page_size = format!("{}&page_size=1", subject); let resource = store - .get_resource_extended(&subject_with_page_size, false) + .get_resource_extended(&subject_with_page_size, false, None) .unwrap(); let cur_page = resource .get(urls::COLLECTION_CURRENT_PAGE) diff --git a/lib/src/endpoints.rs b/lib/src/endpoints.rs index dbcd846ca..0dee55d47 100644 --- a/lib/src/endpoints.rs +++ b/lib/src/endpoints.rs @@ -16,7 +16,8 @@ pub struct Endpoint { /// The part behind the server domain, e.g. '/versions' or '/collections'. Include the slash. pub path: String, /// The function that is called when the request matches the path - pub handle: fn(subject: url::Url, store: &Db) -> AtomicResult, + pub handle: + fn(subject: url::Url, store: &Db, for_agent: Option<&str>) -> AtomicResult, /// The list of properties that can be passed to the Endpoint as Query parameters pub params: Vec, pub description: String, diff --git a/lib/src/errors.rs b/lib/src/errors.rs index 881e69f77..7f2ee2227 100644 --- a/lib/src/errors.rs +++ b/lib/src/errors.rs @@ -1,20 +1,221 @@ //! The Error type that you can expect when using this library -use std::error::Error; -use std::fmt; +use std::{ + convert::Infallible, + num::{ParseFloatError, ParseIntError}, + str::ParseBoolError, +}; + +use base64::DecodeError; /// The default Error type for all Atomic Lib Errors. -// TODO: specify & limit error types -// https://github.com/joepio/atomic/issues/11 -pub type AtomicResult = std::result::Result>; +pub type AtomicResult = std::result::Result; + +#[derive(Debug)] +pub struct AtomicError { + pub message: String, + pub error_type: AtomicErrorType, +} #[derive(Debug)] -struct AtomicError(String); +pub enum AtomicErrorType { + NotFoundError, + UnauthorizedError, + OtherError, +} + +impl std::error::Error for AtomicError { + // fn description(&self) -> &str { + // // Both underlying errors already impl `Error`, so we defer to their + // // implementations. + // match *self { + // CliError::Io(ref err) => err.description(), + // // Normally we can just write `err.description()`, but the error + // // type has a concrete method called `description`, which conflicts + // // with the trait method. For now, we must explicitly call + // // `description` through the `Error` trait. + // CliError::Parse(ref err) => error::Error::description(err), + // } + // } + + // fn cause(&self) -> Option<&dyn std::error::Error> { + // match *self { + // // N.B. Both of these implicitly cast `err` from their concrete + // // types (either `&io::Error` or `&num::ParseIntError`) + // // to a trait object `&Error`. This works because both error types + // // implement `Error`. + // CliError::Io(ref err) => Some(err), + // CliError::Parse(ref err) => Some(err), + // } + // } +} + +impl AtomicError { + #[allow(dead_code)] + pub fn not_found(message: String) -> AtomicError { + AtomicError { + message: format!("Resource not found. {}", message), + error_type: AtomicErrorType::NotFoundError, + } + } + + pub fn unauthorized(message: String) -> AtomicError { + AtomicError { + message: format!("Unauthorized. {}", message), + error_type: AtomicErrorType::UnauthorizedError, + } + } + + pub fn other_error(message: String) -> AtomicError { + AtomicError { + message, + error_type: AtomicErrorType::OtherError, + } + } +} + +impl std::fmt::Display for AtomicError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", &self.message) + } +} + +// Error conversions +impl From<&str> for AtomicError { + fn from(message: &str) -> Self { + AtomicError { + message: message.into(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(message: String) -> Self { + AtomicError { + message, + error_type: AtomicErrorType::OtherError, + } + } +} + +// The following feel very redundant. Can this be simplified? + +impl From> for AtomicError { + fn from(error: std::boxed::Box) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From> for AtomicError { + fn from(error: std::sync::PoisonError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: std::io::Error) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: url::ParseError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: serde_json::Error) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: std::string::FromUtf8Error) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: ParseFloatError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: ParseIntError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} -impl fmt::Display for AtomicError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "There is an error: {}", self.0) +impl From for AtomicError { + fn from(error: DecodeError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } } } -impl Error for AtomicError {} +impl From for AtomicError { + fn from(error: ParseBoolError) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +impl From for AtomicError { + fn from(error: Infallible) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +#[cfg(feature = "db")] +impl From for AtomicError { + fn from(error: sled::Error) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} + +#[cfg(feature = "db")] +impl From> for AtomicError { + fn from(error: Box) -> Self { + AtomicError { + message: error.to_string(), + error_type: AtomicErrorType::OtherError, + } + } +} diff --git a/lib/src/hierarchy.rs b/lib/src/hierarchy.rs index 3d4724473..afacf8bb7 100644 --- a/lib/src/hierarchy.rs +++ b/lib/src/hierarchy.rs @@ -1,8 +1,25 @@ //! The Hierarchy model describes how Resources are structed in a tree-like shape. //! It dealt with authorization (read / write grants) +use core::fmt; + use crate::{errors::AtomicResult, urls, Resource, Storelike}; +pub enum Right { + Read, + Write, +} + +impl fmt::Display for Right { + fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result { + let str = match self { + Right::Read => urls::READ, + Right::Write => urls::WRITE, + }; + fmt.write_str(str) + } +} + /// Looks for children relations, adds to the resource. Performs a TPF query, might be expensive. pub fn add_children(store: &impl Storelike, resource: &mut Resource) -> AtomicResult { let atoms = store.tpf( @@ -20,15 +37,32 @@ pub fn add_children(store: &impl Storelike, resource: &mut Resource) -> AtomicRe Ok(resource.to_owned()) } -/// Recursively checks a Resource and its Parents for write. pub fn check_write( store: &impl Storelike, resource: &Resource, - agent: String, + for_agent: &str, +) -> AtomicResult { + check_rights(store, resource, for_agent, Right::Write) +} + +pub fn check_read( + store: &impl Storelike, + resource: &Resource, + for_agent: &str, +) -> AtomicResult { + check_rights(store, resource, for_agent, Right::Read) +} + +/// Recursively checks a Resource and its Parents for rights. +pub fn check_rights( + store: &impl Storelike, + resource: &Resource, + for_agent: &str, + right: Right, ) -> AtomicResult { // Check if the resource's write rights explicitly refers to the agent - if let Ok(arr_val) = resource.get(urls::WRITE) { - if arr_val.to_subjects(None)?.contains(&agent) { + if let Ok(arr_val) = resource.get(&right.to_string()) { + if arr_val.to_subjects(None)?.iter().any(|s| s == for_agent) { return Ok(true); }; } @@ -39,7 +73,7 @@ pub fn check_write( // return Err(format!("Parent ({}) is the same as the current resource - there is a circular parent relationship.", val).into()); return Ok(false); } - check_write(store, &parent, agent) + check_rights(store, &parent, for_agent, right) } else { // resource has no parent and agent is not in Write array - check fails Ok(false) @@ -76,4 +110,12 @@ mod test { // let resource = store.get_resource(&subject).unwrap(); // assert!(resource.get(property).unwrap().to_string() == value.to_string()); } + + #[test] + fn display_right() { + let read = super::Right::Read; + assert_eq!(read.to_string(), super::urls::READ); + let write = super::Right::Write; + assert_eq!(write.to_string(), super::urls::WRITE); + } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index ccc0ce056..360b226de 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -42,6 +42,7 @@ assert!(fetched_new_resource.get_shortname("description", &store).unwrap().to_st pub mod agents; pub mod atoms; +pub mod authentication; pub mod client; pub mod collections; pub mod commit; @@ -76,6 +77,8 @@ pub use atoms::Atom; pub use commit::Commit; #[cfg(feature = "db")] pub use db::Db; +pub use errors::AtomicError; +pub use errors::AtomicErrorType; pub use resources::Resource; pub use store::Store; pub use storelike::Storelike; diff --git a/lib/src/plugins/path.rs b/lib/src/plugins/path.rs index 4646d494b..77c2687d4 100644 --- a/lib/src/plugins/path.rs +++ b/lib/src/plugins/path.rs @@ -10,7 +10,11 @@ pub fn path_endpoint() -> Endpoint { } } -fn handle_path_request(url: url::Url, store: &impl Storelike) -> AtomicResult { +fn handle_path_request( + url: url::Url, + store: &impl Storelike, + for_agent: Option<&str>, +) -> AtomicResult { let params = url.query_pairs(); let mut path = None; for (k, v) in params { @@ -21,9 +25,11 @@ fn handle_path_request(url: url::Url, store: &impl Storelike) -> AtomicResult store.get_resource(&subject), + crate::storelike::PathReturn::Subject(subject) => { + store.get_resource_extended(&subject, false, for_agent) + } crate::storelike::PathReturn::Atom(atom) => { let mut resource = Resource::new(url.to_string()); resource.set_propval_string(urls::ATOM_SUBJECT.into(), &atom.subject, store)?; diff --git a/lib/src/plugins/versioning.rs b/lib/src/plugins/versioning.rs index 3d4b596da..b91056b83 100644 --- a/lib/src/plugins/versioning.rs +++ b/lib/src/plugins/versioning.rs @@ -1,6 +1,6 @@ use crate::{ - collections::CollectionBuilder, endpoints::Endpoint, errors::AtomicResult, urls, Commit, - Resource, Storelike, + collections::CollectionBuilder, endpoints::Endpoint, errors::AtomicResult, urls, AtomicError, + Commit, Resource, Storelike, }; pub fn version_endpoint() -> Endpoint { @@ -24,7 +24,12 @@ pub fn all_versions_endpoint() -> Endpoint { } } -fn handle_version_request(url: url::Url, store: &impl Storelike) -> AtomicResult { +fn handle_version_request( + url: url::Url, + store: &impl Storelike, + // TODO: Implement auth + for_agent: Option<&str>, +) -> AtomicResult { let params = url.query_pairs(); let mut commit_url = None; for (k, v) in params { @@ -35,12 +40,17 @@ fn handle_version_request(url: url::Url, store: &impl Storelike) -> AtomicResult if commit_url.is_none() { return version_endpoint().to_resource(store); } - let mut resource = construct_version(&commit_url.unwrap(), store)?; + let mut resource = construct_version(&commit_url.unwrap(), store, for_agent)?; resource.set_subject(url.to_string()); Ok(resource) } -fn handle_all_versions_request(url: url::Url, store: &impl Storelike) -> AtomicResult { +fn handle_all_versions_request( + url: url::Url, + store: &impl Storelike, + // TODO: implement auth + for_agent: Option<&str>, +) -> AtomicResult { let params = url.query_pairs(); let mut target_subject = None; for (k, v) in params { @@ -64,7 +74,7 @@ fn handle_all_versions_request(url: url::Url, store: &impl Storelike) -> AtomicR include_nested: false, include_external: false, }; - let mut collection = collection_builder.into_collection(store)?; + let mut collection = collection_builder.into_collection(store, for_agent)?; let new_members = collection .members .iter_mut() @@ -87,10 +97,23 @@ fn get_commits_for_resource(subject: &str, store: &impl Storelike) -> AtomicResu /// Constructs a Resource version for a specific Commit /// Only works if the current store has the required Commits -pub fn construct_version(commit_url: &str, store: &impl Storelike) -> AtomicResult { +pub fn construct_version( + commit_url: &str, + store: &impl Storelike, + for_agent: Option<&str>, +) -> AtomicResult { let commit = store.get_resource(commit_url)?; // Get all the commits for the subject of that Commit let subject = &commit.get(urls::SUBJECT)?.to_string(); + if let Some(agent) = &for_agent { + let current_resource = store.get_resource(subject)?; + let can_open = crate::hierarchy::check_read(store, ¤t_resource, agent)?; + if !can_open { + return Err(AtomicError::unauthorized( + "You do not have permission to construct this resource".to_string(), + )); + } + } let mut commits = get_commits_for_resource(subject, store)?; // Sort all commits by date commits.sort_by(|a, b| a.created_at.cmp(&b.created_at)); @@ -119,12 +142,16 @@ fn construct_version_endpoint_url(store: &impl Storelike, commit_url: &str) -> S /// Gets a version of a Resource by Commit. /// Tries cached version, constructs one if there is no cached version. -pub fn get_version(commit_url: &str, store: &impl Storelike) -> AtomicResult { +pub fn get_version( + commit_url: &str, + store: &impl Storelike, + for_agent: Option<&str>, +) -> AtomicResult { let version_url = construct_version_endpoint_url(store, commit_url); match store.get_resource(&version_url) { Ok(cached) => Ok(cached), Err(_not_cached) => { - let version = construct_version(commit_url, store)?; + let version = construct_version(commit_url, store, for_agent)?; // Store constructed version for caching store.add_resource(&version)?; Ok(version) @@ -160,7 +187,7 @@ mod test { let commits = get_commits_for_resource(subject, &store).unwrap(); assert_eq!(commits.len(), 2); - let first_version = construct_version(first_commit.get_subject(), &store).unwrap(); + let first_version = construct_version(first_commit.get_subject(), &store, None).unwrap(); assert_eq!( first_version .get_shortname("description", &store) @@ -169,7 +196,7 @@ mod test { first_val ); - let second_version = construct_version(second_commit.get_subject(), &store).unwrap(); + let second_version = construct_version(second_commit.get_subject(), &store, None).unwrap(); assert_eq!( second_version .get_shortname("description", &store) diff --git a/lib/src/populate.rs b/lib/src/populate.rs index 19c5e9765..8dc861e7d 100644 --- a/lib/src/populate.rs +++ b/lib/src/populate.rs @@ -172,6 +172,19 @@ pub fn populate_hierarchy(store: &impl Storelike) -> AtomicResult<()> { Ok(()) } +/// Get the Drive resource (base URL), set agent as the Root user, provide write and read access +pub fn set_up_drive(store: &impl Storelike) -> AtomicResult<()> { + // Now let's add the agent as the Root user and provide write access + let mut drive = store.get_resource(store.get_base_url())?; + let agents = vec![store.get_default_agent()?.subject]; + // TODO: add read rights to public, maybe + drive.set_propval(urls::WRITE.into(), agents.clone().into(), store)?; + drive.set_propval(urls::READ.into(), agents.into(), store)?; + drive.set_propval_string(urls::DESCRIPTION.into(), &format!("Welcome to your Atomic-Server! Register your User by visiting [`/setup`]({}/setup). After that, edit this page by pressing `edit` in the navigation bar menu.", store.get_base_url()), store)?; + drive.save_locally(store)?; + Ok(()) +} + /// Imports the Atomic Data Core items (the entire atomicdata.dev Ontology / Vocabulary) from default_store.jsonld pub fn populate_default_store(store: &impl Storelike) -> AtomicResult<()> { let json = include_str!("../defaults/default_store.json"); diff --git a/lib/src/store.rs b/lib/src/store.rs index 2be95df79..9ea4ef79d 100644 --- a/lib/src/store.rs +++ b/lib/src/store.rs @@ -201,7 +201,7 @@ mod test { fn path() { let store = init_store(); let res = store - .get_path("https://atomicdata.dev/classes/Class shortname", None) + .get_path("https://atomicdata.dev/classes/Class shortname", None, None) .unwrap(); match res { crate::storelike::PathReturn::Subject(_) => panic!("Should be an Atom"), @@ -210,7 +210,11 @@ mod test { } } let res = store - .get_path("https://atomicdata.dev/classes/Class requires 0", None) + .get_path( + "https://atomicdata.dev/classes/Class requires 0", + None, + None, + ) .unwrap(); match res { crate::storelike::PathReturn::Subject(sub) => { @@ -236,6 +240,7 @@ mod test { .get_path( "https://atomicdata.dev/classes/Class requires isa description", None, + None, ) .unwrap(); } @@ -248,6 +253,7 @@ mod test { .get_path( "https://atomicdata.dev/classes/Class requires requires", None, + None, ) .unwrap(); } diff --git a/lib/src/storelike.rs b/lib/src/storelike.rs index a89443722..4c581bd32 100644 --- a/lib/src/storelike.rs +++ b/lib/src/storelike.rs @@ -2,6 +2,8 @@ use crate::{ agents::Agent, + errors::AtomicError, + hierarchy, schema::{Class, Property}, }; use crate::{errors::AtomicResult, parse::parse_json_ad_array}; @@ -120,9 +122,11 @@ pub trait Storelike: Sized { } /// Fetches a resource, makes sure its subject matches. + /// Uses the default agent to sign the request. /// Save to the store. fn fetch_resource(&self, subject: &str) -> AtomicResult { - let resource: Resource = crate::client::fetch_resource(subject, self)?; + let resource: Resource = + crate::client::fetch_resource(subject, self, self.get_default_agent().ok())?; self.add_resource_opts(&resource, true, true, true)?; Ok(resource) } @@ -157,17 +161,27 @@ pub trait Storelike: Sized { /// Get's the resource, parses the Query parameters and calculates dynamic properties. /// Defaults to get_resource if store doesn't support extended resources + /// If `for_agent` is None, no authorization checks will be done, and all resources will return. + /// If you want public only resurces, pass `Some(crate::authentication::public_agent)` as the agent. /// - *skip_dynamic* Does not calculte dynamic properties. Adds an `incomplete=true` property if the resource should have been dynamic. - fn get_resource_extended(&self, subject: &str, skip_dynamic: bool) -> AtomicResult { - let _ignore = skip_dynamic; - self.get_resource(subject) - } - - fn handle_not_found( + fn get_resource_extended( &self, subject: &str, - error: Box, + skip_dynamic: bool, + for_agent: Option<&str>, ) -> AtomicResult { + let _ignore = skip_dynamic; + let resource = self.get_resource(subject)?; + if let Some(agent) = for_agent { + if hierarchy::check_read(self, &resource, agent)? { + return Ok(resource); + } + return Err(AtomicError::unauthorized("No rights".into())); + } + Ok(resource) + } + + fn handle_not_found(&self, subject: &str, error: AtomicError) -> AtomicResult { if let Some(self_url) = self.get_self_url() { if subject.starts_with(&self_url) { return Err(format!("Failed to retrieve locally: '{}'. {}", subject, error).into()); @@ -295,8 +309,16 @@ pub trait Storelike: Sized { /// Accepts an Atomic Path string, returns the result value (resource or property value) /// E.g. `https://example.com description` or `thing isa 0` /// https://docs.atomicdata.dev/core/paths.html + /// The `for_agent` argument is used to check if the user has rights to the resource. + /// You can pass `None` if you don't care about the rights (e.g. in client side apps) + /// If you want to perform read rights checks, pass Some `for_agent` subject // Todo: return something more useful, give more context. - fn get_path(&self, atomic_path: &str, mapping: Option<&Mapping>) -> AtomicResult { + fn get_path( + &self, + atomic_path: &str, + mapping: Option<&Mapping>, + for_agent: Option<&str>, + ) -> AtomicResult { // The first item of the path represents the starting Resource, the following ones are traversing the graph / selecting properties. let path_items: Vec<&str> = atomic_path.split(' ').collect(); let first_item = String::from(path_items[0]); @@ -314,7 +336,7 @@ pub trait Storelike: Sized { // The URL of the next resource let mut subject = id_url; // Set the currently selectred resource parent, which starts as the root of the search - let mut resource = self.get_resource_extended(&subject, false)?; + let mut resource = self.get_resource_extended(&subject, false, for_agent)?; // During each of the iterations of the loop, the scope changes. // Try using pathreturn... let mut current: PathReturn = PathReturn::Subject(subject.clone()); @@ -348,7 +370,7 @@ pub trait Storelike: Sized { ))? .to_string(); subject = url; - resource = self.get_resource_extended(&subject, false)?; + resource = self.get_resource_extended(&subject, false, for_agent)?; current = PathReturn::Subject(subject.clone()); continue; } diff --git a/lib/src/urls.rs b/lib/src/urls.rs index 1ce2a6dd0..6970a76eb 100644 --- a/lib/src/urls.rs +++ b/lib/src/urls.rs @@ -96,3 +96,6 @@ pub const TIMESTAMP: &str = "https://atomicdata.dev/datatypes/timestamp"; // Methods pub const INSERT: &str = "https://atomicdata.dev/methods/insert"; pub const DELETE: &str = "https://atomicdata.dev/methods/delete"; + +// Instances +pub const PUBLIC_AGENT: &str = "https://atomicdata.dev/agents/publicAgent"; diff --git a/lib/src/validate.rs b/lib/src/validate.rs index 6709aba57..06271b42b 100644 --- a/lib/src/validate.rs +++ b/lib/src/validate.rs @@ -33,7 +33,7 @@ pub fn validate_store( resource_count += 1; if fetch_items { - match crate::client::fetch_resource(subject, store) { + match crate::client::fetch_resource(subject, store, store.get_default_agent().ok()) { Ok(_) => {} Err(e) => unfetchable.push((subject.clone(), e.to_string())), } diff --git a/server/.gitignore b/server/.gitignore index 8ca662531..fbd4aaabb 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -3,3 +3,4 @@ .ssl/* .https/* /static/well-known/acme-challenge/* +.temp diff --git a/server/Cargo.toml b/server/Cargo.toml index 1b8684861..db5432283 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -1,11 +1,23 @@ [package] authors = ["Joep Meindertsma "] +build = "build.rs" description = "Create, share and model Atomic Data with this graph database server." edition = "2021" license = "MIT" name = "atomic-server" repository = "https://github.com/joepio/atomic-data-rust" -version = "0.28.2" +version = "0.29.0" + +[build-dependencies] +clap = "3.0.0-beta.5" +clap_generate = "3.0.0-beta.5" +dotenv = "0.15.0" + +[build-dependencies.atomic_lib] +features = ["config", "db", "rdf"] +path = "../lib" +version = "0.29.0" + [dependencies] actix = "0.10.0" actix-cors = "0.5.4" @@ -37,7 +49,7 @@ version = "3.3.2" [dependencies.atomic_lib] features = ["config", "db", "rdf"] path = "../lib" -version = "0.28.2" +version = "0.29.0" [dependencies.open] optional = true @@ -60,7 +72,6 @@ optional = true version = "0.4.0-alpha" [dev-dependencies] -# Currently only used for testing, the other actix is included in the web crate actix-rt = "1.1.1" [features] diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 000000000..8402ef000 --- /dev/null +++ b/server/build.rs @@ -0,0 +1,16 @@ +use clap::IntoApp; +use clap_generate::{generate_to, generators}; + +include!("src/cli.rs"); + +fn main() -> Result<(), std::io::Error> { + let outdir = match std::env::var_os("OUT_DIR") { + None => return Ok(()), + Some(outdir) => outdir, + }; + let mut app = crate::Opts::into_app(); + let path = generate_to(generators::Bash, &mut app, "atomic-server", &outdir)?; + let path = generate_to(generators::Fish, &mut app, "atomic-server", &outdir)?; + println!("cargo:warning=completion file is generated: {:?}", path); + Ok(()) +} diff --git a/server/src/actor_messages.rs b/server/src/actor_messages.rs index 52e855611..c628fe2a7 100644 --- a/server/src/actor_messages.rs +++ b/server/src/actor_messages.rs @@ -14,6 +14,7 @@ pub struct WsMessage(pub String); pub struct Subscribe { pub addr: Addr, pub subject: String, + pub agent: String, } /// A message containing a Resource, which should be sent to subscribers diff --git a/server/src/appstate.rs b/server/src/appstate.rs index c5d739834..3e9216707 100644 --- a/server/src/appstate.rs +++ b/server/src/appstate.rs @@ -1,13 +1,14 @@ //! App state, which is accessible from handlers use crate::{ - commit_monitor::CommitMonitor, config::Config, errors::BetterResult, search::SearchState, + commit_monitor::CommitMonitor, config::Config, errors::AtomicServerResult, search::SearchState, }; use atomic_lib::{ agents::{generate_public_key, Agent}, Storelike, }; -/// Context for the server (not an individual request). +/// Data object available to handlers and actors. +/// Contains the store, configuration and addresses for Actix Actors. // This struct is cloned accross all threads, so make sure the fields are thread safe. // A good option here is to use Actors for things that can change (e.g. commit_monitor) #[derive(Clone)] @@ -24,9 +25,9 @@ pub struct AppState { /// Creates the server context. /// Initializes a store on disk. /// Creates a new agent, if neccessary. -pub fn init(config: Config) -> BetterResult { - // Enable all logging - std::env::set_var("RUST_LOG", "info"); +pub fn init(config: Config) -> AtomicServerResult { + // Enable logging, but hide most tantivy logs + std::env::set_var("RUST_LOG", "info,tantivy=warn"); env_logger::init(); // Check if atomic-server is already running somwehere, and try to stop it. It's not a problem if things go wrong here, so errors are simply logged. let _ = crate::process::terminate_existing_processes(&config) @@ -59,7 +60,8 @@ pub fn init(config: Config) -> BetterResult { .map_err(|e| format!("Failed to populate endpoints. {}", e))?; set_up_initial_invite(&store)?; // This means that editing the .env does _not_ grant you the rights to edit the Drive. - set_up_drive(&store)?; + log::info!("Setting rights to Drive {}", store.get_base_url()); + atomic_lib::populate::set_up_drive(&store)?; } // Initialize search constructs @@ -83,7 +85,7 @@ pub fn init(config: Config) -> BetterResult { } /// Create a new agent if it does not yet exist. -fn set_default_agent(config: &Config, store: &impl Storelike) -> BetterResult<()> { +fn set_default_agent(config: &Config, store: &impl Storelike) -> AtomicServerResult<()> { let ag_cfg: atomic_lib::config::Config = match atomic_lib::config::read_config( &config.config_file_path, ) { @@ -141,7 +143,7 @@ fn set_default_agent(config: &Config, store: &impl Storelike) -> BetterResult<() } /// Creates the first Invitation that is opened by the user on the Home page. -fn set_up_initial_invite(store: &impl Storelike) -> BetterResult<()> { +fn set_up_initial_invite(store: &impl Storelike) -> AtomicServerResult<()> { let subject = format!("{}/setup", store.get_base_url()); log::info!("Creating initial Invite at {}", subject); let mut invite = atomic_lib::Resource::new_instance(atomic_lib::urls::INVITE, store)?; @@ -180,17 +182,3 @@ fn set_up_initial_invite(store: &impl Storelike) -> BetterResult<()> { invite.save_locally(store)?; Ok(()) } - -/// Get the Drive resource (base URL), set agent as the Root user, provide write access -fn set_up_drive(store: &impl Storelike) -> BetterResult<()> { - log::info!("Setting rights to Drive {}", store.get_base_url()); - // Now let's add the agent as the Root user and provide write access - let mut drive = store.get_resource(store.get_base_url())?; - let agents = vec![store.get_default_agent()?.subject]; - // TODO: add read rights to public, maybe - drive.set_propval(atomic_lib::urls::WRITE.into(), agents.clone().into(), store)?; - drive.set_propval(atomic_lib::urls::READ.into(), agents.into(), store)?; - drive.set_propval_string(atomic_lib::urls::DESCRIPTION.into(), &format!("Welcome to your Atomic-Server! Register your User by visiting [`/setup`]({}/setup). After that, edit this page by pressing `edit` in the navigation bar menu.", store.get_base_url()), store)?; - drive.save_locally(store)?; - Ok(()) -} diff --git a/server/src/cli.rs b/server/src/cli.rs new file mode 100644 index 000000000..498dacef1 --- /dev/null +++ b/server/src/cli.rs @@ -0,0 +1,92 @@ +use clap::Parser; +use std::net::IpAddr; +use std::path::PathBuf; + +/// Store and share Atomic Data! Visit https://atomicdata.dev for more info. Pass no subcommands to launch the server. The `.env` of your current directory will be read. +#[derive(Clone, Parser, Debug)] +#[clap(author = "Joep Meindertsma (joep@ontola.io)")] +pub struct Opts { + /// The subcommand being run + #[clap(subcommand)] + pub command: Option, + /// Recreates the `/setup` Invite for creating a new Root User. Also re-runs various populate commands, and re-builds the index + #[clap(long, env = "ATOMIC_INITIALIZE")] + pub initialize: bool, + /// Re-creates the value index. Parses all the resources. Do this if your collections have issues. + #[clap(long, env = "ATOMIC_REBUILD_INDEX")] + pub rebuild_index: bool, + /// Use staging environments for services like LetsEncrypt + #[clap(long, env = "ATOMIC_DEVELOPMENT")] + pub development: bool, + /// The origin domain where the app is hosted, without the port and schema values. + #[clap(long, default_value = "localhost", env = "ATOMIC_DOMAIN")] + pub domain: String, + /// The contact mail address for Let's Encrypt HTTPS setup + #[clap(long, env = "ATOMIC_EMAIL")] + pub email: Option, + /// The port where the HTTP app is available + #[clap(short, long, default_value = "80", env = "ATOMIC_PORT")] + pub port: u32, + /// The port where the HTTPS app is available + #[clap(long, default_value = "443", env = "ATOMIC_PORT")] + pub port_https: u32, + /// The IP address of the server + #[clap(long, default_value = "0.0.0.0", env = "ATOMIC_IP")] + pub ip: IpAddr, + /// Use HTTPS instead of HTTP. + /// Will get certificates from LetsEncrypt. + #[clap(long, env = "ATOMIC_HTTPS")] + pub https: bool, + /// Endpoint where the front-end assets are hosted + #[clap( + long, + default_value = "https://joepio.github.io/atomic-data-browser", + env = "ATOMIC_ASSET_URL" + )] + pub asset_url: String, + /// Custom JS script to include in the body of the HTML template + #[clap(long, default_value = "", env = "ATOMIC_SCRIPT")] + pub script: String, + /// Path for atomic data config directory. Defaults to "~/.config/atomic/"" + #[clap(long, env = "ATOMIC_CONFIG_DIR")] + pub config_dir: Option, + /// CAUTION: Makes data public on the `/search` endpoint. When enabled, it allows POSTing to the /search endpoint and returns search results as single triples, without performing authentication checks. See https://github.com/joepio/atomic-data-rust/blob/master/server/rdf-search.md + #[clap(long, env = "ATOMIC_RDF_SEARCH")] + pub rdf_search: bool, + /// By default, Atomic-Server keeps previous verions of resources indexed in Search. When enabling this flag, previous versions of resources are removed from the search index when their values are updated. + #[clap(long, env = "ATOMIC_REMOVE_PREVIOUS_SEARCH")] + pub remove_previous_search: bool, + /// CAUTION: Skip authentication checks, making all data public. Improves performance. + #[clap(long, env = "ATOMIC_PUBLIC_MODE")] + pub public_mode: bool, +} + +#[derive(Parser, Clone, Debug)] +pub enum Command { + /// Create and save a JSON-AD backup of the store. + #[clap(name = "export")] + Export(ExportOpts), + /// Import a JSON-AD backup to the store. Overwrites existing Resources with same @id. + #[clap(name = "import")] + Import(ImportOpts), + /// Creates a `.env` file in your current directory that shows various options that you can set. + #[clap(name = "setup-env")] + SetupEnv, +} + +#[derive(Parser, Clone, Debug)] +pub struct ExportOpts { + /// Where the exported file should be saved "~/.config/atomic/backups/{date}.json" + #[clap(short)] + pub path: Option, + /// Do not export resources that are externally defined, which are cached by this Server. + #[clap(long)] + pub only_internal: bool, +} + +#[derive(Parser, Clone, Debug)] +pub struct ImportOpts { + /// Where the file that should be imported is. + #[clap(short)] + pub path: PathBuf, +} diff --git a/server/src/commit_monitor.rs b/server/src/commit_monitor.rs index 4aa428d45..f679aa7f4 100644 --- a/server/src/commit_monitor.rs +++ b/server/src/commit_monitor.rs @@ -12,11 +12,12 @@ use actix::{ prelude::{Actor, Context, Handler}, Addr, }; -use atomic_lib::Db; +use atomic_lib::{Db, Storelike}; use chrono::Local; use std::collections::{HashMap, HashSet}; /// The Commit Monitor is an Actor that manages subscriptions for subjects and sends Commits to listeners. +/// It's also responsible for checking whether the rights are present pub struct CommitMonitor { /// Maintains a list of all the resources that are being subscribed to, and maps these to websocket connections. subscriptions: HashMap>>, @@ -34,15 +35,29 @@ impl Actor for CommitMonitor { impl Handler for CommitMonitor { type Result = (); + // A message comes in when a client subscribes to a subject. fn handle(&mut self, msg: Subscribe, _: &mut Context) { - let mut set = if let Some(set) = self.subscriptions.get(&msg.subject) { - set.clone() - } else { - HashSet::new() - }; - set.insert(msg.addr); - log::info!("handle subscribe {} ", msg.subject); - self.subscriptions.insert(msg.subject, set); + // check if the agent has the rights to subscribe to this resource + if let Ok(resource) = self.store.get_resource(&msg.subject) { + if let Ok(can) = atomic_lib::hierarchy::check_read(&self.store, &resource, &msg.agent) { + if can { + let mut set = if let Some(set) = self.subscriptions.get(&msg.subject) { + set.clone() + } else { + HashSet::new() + }; + set.insert(msg.addr); + log::info!("handle subscribe {} ", msg.subject); + self.subscriptions.insert(msg.subject.clone(), set); + } + log::info!( + "Not allowed {} to subscribe to {} ", + &msg.agent, + &msg.subject + ); + } + // TODO: Handle errors + } } } diff --git a/server/src/config.rs b/server/src/config.rs index 829a2ab99..046d0eb98 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -1,68 +1,10 @@ //! Parse CLI options, setup on boot, read .env values -use crate::errors::BetterResult; use clap::Parser; use dotenv::dotenv; use std::env; -use std::net::IpAddr; use std::path::PathBuf; -/// Store and share Atomic Data! Visit https://atomicdata.dev for more info. Pass no subcommands to launch the server. The `.env` of your current directory will be read. -#[derive(Clone, Parser, Debug)] -#[clap(author = "Joep Meindertsma (joep@ontola.io)")] -pub struct Opts { - /// The subcommand being run - #[clap(subcommand)] - pub command: Option, - /// Recreates the `/setup` Invite for creating a new Root User. Also re-runs various populate commands, and re-builds the index - #[clap(long, env = "ATOMIC_INITIALIZE")] - pub initialize: bool, - /// Re-creates the value index. Parses all the resources. Do this if your collections have issues. - #[clap(long, env = "ATOMIC_REBUILD_INDEX")] - pub rebuild_index: bool, - /// Use staging environments for services like LetsEncrypt - #[clap(long, env = "ATOMIC_DEVELOPMENT")] - pub development: bool, - /// The origin domain where the app is hosted, without the port and schema values. - #[clap(long, default_value = "localhost", env = "ATOMIC_DOMAIN")] - pub domain: String, - /// The contact mail address for Let's Encrypt HTTPS setup - #[clap(long, env = "ATOMIC_EMAIL")] - pub email: Option, - /// The port where the HTTP app is available - #[clap(short, long, default_value = "80", env = "ATOMIC_PORT")] - pub port: u32, - /// The port where the HTTPS app is available - #[clap(long, default_value = "443", env = "ATOMIC_PORT")] - pub port_https: u32, - /// The IP address of the server - #[clap(long, default_value = "0.0.0.0", env = "ATOMIC_IP")] - pub ip: IpAddr, - /// If we're using HTTPS or plaintext HTTP. - /// Is disabled when using cert_init - #[clap(long, env = "ATOMIC_HTTPS")] - pub https: bool, - /// Endpoint where the front-end assets are hosted - #[clap( - long, - default_value = "https://joepio.github.io/atomic-data-browser", - env = "ATOMIC_ASSET_URL" - )] - pub asset_url: String, - /// Custom JS script to include in the body of the HTML template - #[clap(long, default_value = "", env = "ATOMIC_SCRIPT")] - pub script: String, - /// Path for atomic data config directory. Defaults to "~/.config/atomic/"" - #[clap(long, env = "ATOMIC_CONFIG_DIR")] - pub config_dir: Option, - /// When enabled, it allows POSTing to the /search endpoint - #[clap(long, env = "ATOMIC_RDF_SEARCH")] - pub rdf_search: bool, - /// When enabled, previous versions of resources are removed from the search index when updated. - #[clap(long, env = "ATOMIC_REMOVE_PREVIOUS_SEARCH")] - pub remove_previous_search: bool, -} - #[derive(Parser, Clone, Debug)] pub enum Command { /// Create and save a JSON-AD backup of the store. @@ -104,7 +46,7 @@ pub struct Config { /// Full domain + schema pub local_base_url: String, /// CLI + ENV options - pub opts: Opts, + pub opts: crate::cli::Opts, // === PATHS === /// Path for atomic data config `~/.config/atomic/`. Used to construct most other paths. pub config_dir: PathBuf, @@ -127,19 +69,19 @@ pub struct Config { } /// Creates the server config, reads .env values and sets defaults -pub fn init() -> BetterResult { +pub fn init() -> crate::AtomicServerResult { // Parse .env file (do this before parsing the CLI opts) dotenv().ok(); // Parse CLI options, .env values, set defaults - let opts: Opts = Opts::parse(); + let opts: crate::cli::Opts = crate::cli::Opts::parse(); let config_dir = if let Some(dir) = &opts.config_dir { dir.clone() } else { atomic_lib::config::default_config_dir_path()? }; - let mut config_file_path = atomic_lib::config::default_config_file_path()?; + let mut config_file_path = config_dir.join("config.toml"); let mut store_path = config_dir.clone(); store_path.push("db"); let mut https_path = config_dir.clone(); diff --git a/server/src/errors.rs b/server/src/errors.rs index 57eb4b3c1..e9ed38e8f 100644 --- a/server/src/errors.rs +++ b/server/src/errors.rs @@ -3,52 +3,38 @@ use serde::Serialize; use std::error::Error; // More strict Result type -pub type BetterResult = std::result::Result; +pub type AtomicServerResult = std::result::Result; #[derive(Debug)] pub enum AppErrorType { - #[allow(dead_code)] - NotFoundError, - OtherError, + NotFound, + Unauthorized, + Other, } // More strict error type, supports HTTP responses // Needs a lot of work, though #[derive(Debug)] -pub struct AppError { +pub struct AtomicServerError { pub message: String, pub error_type: AppErrorType, } -impl AppError { - #[allow(dead_code)] - pub fn not_found(message: String) -> AppError { - AppError { - message: format!("Resource not found. {}", message), - error_type: AppErrorType::NotFoundError, - } - } - - pub fn other_error(message: String) -> AppError { - AppError { - message, - error_type: AppErrorType::OtherError, - } - } -} +impl AtomicServerError {} #[derive(Serialize)] pub struct AppErrorResponse { pub error: String, } -impl Error for AppError {} +impl Error for AtomicServerError {} -impl ResponseError for AppError { +impl ResponseError for AtomicServerError { fn status_code(&self) -> StatusCode { match self.error_type { - AppErrorType::NotFoundError => StatusCode::NOT_FOUND, - AppErrorType::OtherError => StatusCode::INTERNAL_SERVER_ERROR, + AppErrorType::NotFound => StatusCode::NOT_FOUND, + AppErrorType::Other => StatusCode::INTERNAL_SERVER_ERROR, + AppErrorType::Unauthorized => StatusCode::UNAUTHORIZED, } } fn error_response(&self) -> HttpResponse { @@ -58,72 +44,105 @@ impl ResponseError for AppError { } } -impl std::fmt::Display for AppError { +impl std::fmt::Display for AtomicServerError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", &self.message) } } // Error conversions -impl From<&str> for AppError { +impl From<&str> for AtomicServerError { fn from(message: &str) -> Self { - AppError { + AtomicServerError { message: message.into(), - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From for AppError { +impl From for AtomicServerError { fn from(message: String) -> Self { - AppError { + AtomicServerError { message, - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From> for AppError { +impl From> for AtomicServerError { fn from(error: std::boxed::Box) -> Self { - AppError { + AtomicServerError { message: error.to_string(), - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From> for AppError { +impl From> for AtomicServerError { fn from(error: std::sync::PoisonError) -> Self { - AppError { + AtomicServerError { message: error.to_string(), - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From for AppError { +impl From for AtomicServerError { fn from(error: std::io::Error) -> Self { - AppError { + AtomicServerError { message: error.to_string(), - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From for AppError { +impl From for AtomicServerError { fn from(error: tantivy::directory::error::OpenDirectoryError) -> Self { - AppError { + AtomicServerError { message: error.to_string(), - error_type: AppErrorType::OtherError, + error_type: AppErrorType::Other, } } } -impl From for AppError { +impl From for AtomicServerError { fn from(error: tantivy::TantivyError) -> Self { - AppError { + AtomicServerError { + message: error.to_string(), + error_type: AppErrorType::Other, + } + } +} + +impl From for AtomicServerError { + fn from(error: acme_lib::Error) -> Self { + AtomicServerError { + message: error.to_string(), + error_type: AppErrorType::Other, + } + } +} + +impl From for AtomicServerError { + fn from(error: actix_web::Error) -> Self { + AtomicServerError { + message: error.to_string(), + error_type: AppErrorType::Other, + } + } +} + +impl From for AtomicServerError { + fn from(error: atomic_lib::errors::AtomicError) -> Self { + println!("ERROR: {:?}", error); + let error_type = match error.error_type { + atomic_lib::errors::AtomicErrorType::NotFoundError => AppErrorType::NotFound, + atomic_lib::errors::AtomicErrorType::UnauthorizedError => AppErrorType::Unauthorized, + _ => AppErrorType::Other, + }; + AtomicServerError { message: error.to_string(), - error_type: AppErrorType::OtherError, + error_type, } } } diff --git a/server/src/handlers/commit.rs b/server/src/handlers/commit.rs index b19037e6b..fc0c87ac7 100644 --- a/server/src/handlers/commit.rs +++ b/server/src/handlers/commit.rs @@ -1,4 +1,4 @@ -use crate::{appstate::AppState, errors::BetterResult}; +use crate::{appstate::AppState, errors::AtomicServerResult}; use actix_web::{web, HttpResponse}; use atomic_lib::{parse::parse_json_ad_commit_resource, Commit, Storelike}; use std::sync::Mutex; @@ -8,7 +8,7 @@ use std::sync::Mutex; pub async fn post_commit( data: web::Data>, body: String, -) -> BetterResult { +) -> AtomicServerResult { let mut appstate = data .lock() .expect("Failed to lock mutexguard in post_commit"); diff --git a/server/src/handlers/resource.rs b/server/src/handlers/resource.rs index 6a162b0d7..1e049e7aa 100644 --- a/server/src/handlers/resource.rs +++ b/server/src/handlers/resource.rs @@ -1,8 +1,6 @@ use crate::{ - appstate::AppState, - content_types::get_accept, - content_types::ContentType, - errors::{AppError, BetterResult}, + appstate::AppState, content_types::get_accept, content_types::ContentType, + errors::AtomicServerResult, helpers::get_client_agent, }; use actix_web::{web, HttpResponse}; use atomic_lib::Storelike; @@ -14,11 +12,12 @@ pub async fn get_resource( path: Option>, data: web::Data>, req: actix_web::HttpRequest, -) -> BetterResult { - let context = data.lock().unwrap(); +) -> AtomicServerResult { + let appstate = data.lock().unwrap(); - let mut content_type = get_accept(req.headers()); - let base_url = &context.config.local_base_url; + let headers = req.headers(); + let mut content_type = get_accept(headers); + let base_url = &appstate.config.local_base_url; // Get the subject from the path, or return the home URL let subject = if let Some(subj_end) = path { let mut subj_end_string = subj_end.as_str(); @@ -40,7 +39,10 @@ pub async fn get_resource( } else { String::from(base_url) }; - let store = &context.store; + + let store = &appstate.store; + + let for_agent = get_client_agent(headers, &appstate, subject.clone())?; let mut builder = HttpResponse::Ok(); log::info!("get_resource: {} as {}", subject, content_type.to_mime()); builder.header("Content-Type", content_type.to_mime()); @@ -50,10 +52,7 @@ pub async fn get_resource( "Cache-Control", "no-store, no-cache, must-revalidate, private", ); - let resource = store - .get_resource_extended(&subject, false) - // TODO: Don't always return 404 - only when it's actually not found! - .map_err(|e| AppError::other_error(e.to_string()))?; + let resource = store.get_resource_extended(&subject, false, for_agent.as_deref())?; match content_type { ContentType::Json => { let body = resource.to_json(store)?; diff --git a/server/src/handlers/search.rs b/server/src/handlers/search.rs index 431f50627..a131c74f8 100644 --- a/server/src/handlers/search.rs +++ b/server/src/handlers/search.rs @@ -1,4 +1,4 @@ -use crate::{appstate::AppState, errors::BetterResult}; +use crate::{appstate::AppState, errors::AtomicServerResult}; use actix_web::{web, HttpResponse}; use atomic_lib::{urls, Resource, Storelike}; use serde::Deserialize; @@ -22,14 +22,14 @@ pub async fn search_query( data: web::Data>, params: web::Query, req: actix_web::HttpRequest, -) -> BetterResult { - let context = data +) -> AtomicServerResult { + let appstate = data .lock() .expect("Failed to lock mutexguard in search_query"); - let store = &context.store; - let searcher = context.search_state.reader.searcher(); - let fields = crate::search::get_schema_fields(&context.search_state)?; + let store = &appstate.store; + let searcher = appstate.search_state.reader.searcher(); + let fields = crate::search::get_schema_fields(&appstate.search_state)?; let default_limit = 30; let limit = if let Some(l) = params.limit { if l > 0 { @@ -46,7 +46,6 @@ pub async fn search_query( // Fuzzy searching is not possible when filtering by property should_fuzzy = false; } - let return_subjects = !params.include.unwrap_or(false); let mut subjects: Vec = Vec::new(); let mut atoms: Vec = Vec::new(); @@ -67,7 +66,7 @@ pub async fn search_query( } else { // construct the query let query_parser = QueryParser::for_index( - &context.search_state.index, + &appstate.search_state.index, vec![ fields.subject, // I don't think we need to search in the property @@ -133,7 +132,7 @@ pub async fn search_query( .ok_or("Add a query param")? .to_string() ); - let mut results_resource = Resource::new(subject); + let mut results_resource = Resource::new(subject.clone()); results_resource.set_propval(urls::IS_A.into(), vec![urls::ENDPOINT].into(), store)?; results_resource.set_propval(urls::DESCRIPTION.into(), atomic_lib::Value::Markdown("Full text-search endpoint. You can use the keyword `AND` and `OR`, or use `\"` for advanced searches. ".into()), store)?; results_resource.set_propval( @@ -147,20 +146,28 @@ pub async fn search_query( store, )?; - if return_subjects { + if appstate.config.opts.rdf_search { + // Always return all subjects, don't do authentication results_resource.set_propval(urls::ENDPOINT_RESULTS.into(), subjects.into(), store)?; } else { + // Default case: return full resources, do authentication let mut resources: Vec = Vec::new(); + + let for_agent = crate::helpers::get_client_agent(req.headers(), &appstate, subject)?; for s in subjects { - // If triples isn't set, return - resources.push(store.get_resource_extended(&s, true).map_err(|e| format!("Failed to construct search results, because one of the Subjects cannot be returned. Try again with the `&subjects=true` query parameter. Error: {}", e))?); + log::info!("Subject in search result: {}", s); + match store.get_resource_extended(&s, true, for_agent.as_deref()) { + Ok(r) => resources.push(r), + Err(_e) => { + log::info!("Skipping result: {} : {}", s, _e); + continue; + } + } } results_resource.set_propval(urls::ENDPOINT_RESULTS.into(), resources.into(), store)?; } - - // let json_ad = atomic_lib::serialize::resources_to_json_ad(&resources)?; let mut builder = HttpResponse::Ok(); - // log::info!("Search q: {} hits: {}", &query.q, resources.len()); + // TODO: support other serialization options Ok(builder.body(results_resource.to_json_ad()?)) } @@ -168,7 +175,7 @@ pub async fn search_query( pub async fn search_index_rdf( data: web::Data>, body: String, -) -> BetterResult { +) -> AtomicServerResult { let appstate = data .lock() .expect("Failed to lock mutexguard in search_query"); diff --git a/server/src/handlers/single_page_app.rs b/server/src/handlers/single_page_app.rs index d607e9992..35250ae6f 100644 --- a/server/src/handlers/single_page_app.rs +++ b/server/src/handlers/single_page_app.rs @@ -1,12 +1,12 @@ use std::sync::Mutex; -use crate::{appstate::AppState, errors::BetterResult}; +use crate::{appstate::AppState, errors::AtomicServerResult}; use actix_web::HttpResponse; /// Returns the atomic-data-browser single page application pub async fn single_page( data: actix_web::web::Data>, -) -> BetterResult { +) -> AtomicServerResult { let context = data .lock() .expect("Failed to lock mutexguard in single_page"); diff --git a/server/src/handlers/tpf.rs b/server/src/handlers/tpf.rs index 399bec815..9764e6979 100644 --- a/server/src/handlers/tpf.rs +++ b/server/src/handlers/tpf.rs @@ -1,5 +1,5 @@ use crate::{appstate::AppState, content_types::get_accept}; -use crate::{content_types::ContentType, errors::BetterResult, helpers::empty_to_nothing}; +use crate::{content_types::ContentType, errors::AtomicServerResult, helpers::empty_to_nothing}; use actix_web::{web, HttpResponse}; use atomic_lib::Storelike; use serde::Deserialize; @@ -19,9 +19,13 @@ pub async fn tpf( data: web::Data>, req: actix_web::HttpRequest, query: web::Query, -) -> BetterResult { - let mut context = data.lock().unwrap(); - let store = &mut context.store; +) -> AtomicServerResult { + let appstate = data.lock().unwrap(); + let store = &appstate.store; + + if !appstate.config.opts.public_mode { + return Err("/tpf endpoint is only available on public mode".into()); + } // This is how locally items are stored (which don't know their full subject URL) in Atomic Data let mut builder = HttpResponse::Ok(); let content_type = get_accept(req.headers()); diff --git a/server/src/handlers/web_sockets.rs b/server/src/handlers/web_sockets.rs index 90ac9b2e6..fc1352b19 100644 --- a/server/src/handlers/web_sockets.rs +++ b/server/src/handlers/web_sockets.rs @@ -1,26 +1,38 @@ use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler}; -use actix_web::{web, Error, HttpRequest, HttpResponse}; +use actix_web::{web, HttpRequest, HttpResponse}; use actix_web_actors::ws::{self}; use std::{ sync::Mutex, time::{Duration, Instant}, }; -use crate::{actor_messages::CommitMessage, appstate::AppState, commit_monitor::CommitMonitor}; +use crate::{ + actor_messages::CommitMessage, appstate::AppState, commit_monitor::CommitMonitor, + errors::AtomicServerResult, helpers::get_auth_headers, +}; /// Get an HTTP request, upgrade it to a Websocket connection pub async fn web_socket_handler( req: HttpRequest, stream: web::Payload, data: web::Data>, -) -> Result { +) -> AtomicServerResult { log::info!("Starting websocket"); let context = data.lock().unwrap(); - ws::start( - WebSocketConnection::new(context.commit_monitor.clone()), + + // Authentication check. If the user has no headers, continue with the Public Agent. + let auth_header_values = get_auth_headers(req.headers(), "ws".into())?; + let for_agent = atomic_lib::authentication::get_agent_from_headers_and_check( + auth_header_values, + &context.store, + )?; + + let result = ws::start( + WebSocketConnection::new(context.commit_monitor.clone(), for_agent), &req, stream, - ) + )?; + Ok(result) } const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); @@ -37,6 +49,9 @@ pub struct WebSocketConnection { subscribed: std::collections::HashSet, /// The CommitMonitor Actor that receives and sends messages for Commits commit_monitor_addr: Addr, + /// The Agent who is connected. + /// If it's not specified, it's the Public Agent. + agent: String, } impl Actor for WebSocketConnection { @@ -68,6 +83,7 @@ impl StreamHandler> for WebSocketConnecti .do_send(crate::actor_messages::Subscribe { addr: ctx.address(), subject: subject.to_string(), + agent: self.agent.clone(), }); self.subscribed.insert(subject.into()); } else { @@ -82,6 +98,12 @@ impl StreamHandler> for WebSocketConnecti ctx.text("ERROR: UNSUBSCRIBE without subject") } } + s if s.starts_with("GET ") => { + let mut parts = s.split("GET "); + if let Some(_subject) = parts.nth(1) { + ctx.text("GET not yet supported, see https://github.com/joepio/atomic-data-rust/issues/180") + } + } other => { log::warn!("Unmatched message: {}", other); ctx.text(format!("Server receieved unknown message: {}", other)); @@ -99,12 +121,13 @@ impl StreamHandler> for WebSocketConnecti } impl WebSocketConnection { - fn new(commit_monitor_addr: Addr) -> Self { + fn new(commit_monitor_addr: Addr, agent: String) -> Self { Self { hb: Instant::now(), // Maybe this should be stored only in the CommitMonitor, and not here. subscribed: std::collections::HashSet::new(), commit_monitor_addr, + agent, } } diff --git a/server/src/helpers.rs b/server/src/helpers.rs index 4baffa45d..caed8f796 100644 --- a/server/src/helpers.rs +++ b/server/src/helpers.rs @@ -1,5 +1,10 @@ //! Functions useful in the server +use actix_web::http::HeaderMap; +use atomic_lib::authentication::AuthValues; + +use crate::{appstate::AppState, errors::AtomicServerResult}; + // Returns None if the string is empty. // Useful for parsing form inputs. pub fn empty_to_nothing(string: Option) -> Option { @@ -14,3 +19,57 @@ pub fn empty_to_nothing(string: Option) -> Option { None => None, } } + +/// Returns the authentication headers from the request +pub fn get_auth_headers( + map: &HeaderMap, + requested_subject: String, +) -> AtomicServerResult> { + let public_key = map.get("x-atomic-public-key"); + let signature = map.get("x-atomic-signature"); + let timestamp = map.get("x-atomic-timestamp"); + let agent = map.get("x-atomic-agent"); + match (public_key, signature, timestamp, agent) { + (Some(pk), Some(sig), Some(ts), Some(a)) => Ok(Some(AuthValues { + public_key: pk + .to_str() + .map_err(|_e| "Only string headers allowed")? + .to_string(), + signature: sig + .to_str() + .map_err(|_e| "Only string headers allowed")? + .to_string(), + agent_subject: a + .to_str() + .map_err(|_e| "Only string headers allowed")? + .to_string(), + timestamp: ts + .to_str() + .map_err(|_e| "Only string headers allowed")? + .parse::() + .map_err(|_e| "Timestamp must be a number (milliseconds since unix epoch)")?, + requested_subject, + })), + (None, None, None, None) => Ok(None), + _missing => Err("Missing authentication headers. You need `x-atomic-public-key`, `x-atomic-signature`, `x-atomic-agent` and `x-atomic-timestamp` for authentication checks.".into()), + } +} + +/// Checks for authentication headers and returns the agent's subject if everything is well. +/// Skips these checks in public_mode. +pub fn get_client_agent( + headers: &HeaderMap, + appstate: &AppState, + requested_subject: String, +) -> AtomicServerResult> { + if appstate.config.opts.public_mode { + return Ok(None); + } + // Authentication check. If the user has no headers, continue with the Public Agent. + let auth_header_values = get_auth_headers(headers, requested_subject)?; + let for_agent = atomic_lib::authentication::get_agent_from_headers_and_check( + auth_header_values, + &appstate.store, + )?; + Ok(Some(for_agent)) +} diff --git a/server/src/main.rs b/server/src/main.rs index 986de807f..96bf88e6b 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -1,5 +1,6 @@ mod actor_messages; mod appstate; +mod cli; mod commit_monitor; mod config; mod content_types; @@ -19,17 +20,18 @@ mod tests; #[cfg(feature = "desktop")] mod tray_icon; -use atomic_lib::{errors::AtomicResult, Storelike}; +use atomic_lib::Storelike; +use errors::AtomicServerResult; use std::{fs::File, io::Write}; #[actix_web::main] -async fn main() -> AtomicResult<()> { +async fn main() -> AtomicServerResult<()> { // Parse CLI commands, env vars let config = config::init().map_err(|e| format!("Initialization failed: {}", e))?; // All subcommands (as of now) also require appstate, which is why we have this logic below initial CLI logic. match &config.opts.command { - Some(config::Command::Export(e)) => { + Some(cli::Command::Export(e)) => { let path = match e.path.clone() { Some(p) => std::path::Path::new(&p).to_path_buf(), None => { @@ -51,7 +53,7 @@ async fn main() -> AtomicResult<()> { println!("Succesfully exported data to {}", path.to_str().unwrap()); Ok(()) } - Some(config::Command::Import(o)) => { + Some(cli::Command::Import(o)) => { let path = std::path::Path::new(&o.path); let readstring = std::fs::read_to_string(path)?; let appstate = appstate::init(config.clone())?; @@ -60,7 +62,7 @@ async fn main() -> AtomicResult<()> { println!("Sucesfully imported {:?} to store.", o.path); Ok(()) } - Some(config::Command::SetupEnv) => { + Some(cli::Command::SetupEnv) => { let current_path = std::env::current_dir()?; let pathstr = format!( "{}/.env", diff --git a/server/src/process.rs b/server/src/process.rs index 06ba3d3d7..8de69674f 100644 --- a/server/src/process.rs +++ b/server/src/process.rs @@ -1,9 +1,9 @@ //! Checks if the process is running, kills a runnig process if it is. -use crate::{config::Config, errors::BetterResult}; +use crate::{config::Config, errors::AtomicServerResult}; /// Checks if the server is running. If it is, kill that process. Also creates creates a new PID. -pub fn terminate_existing_processes(config: &Config) -> BetterResult<()> { +pub fn terminate_existing_processes(config: &Config) -> AtomicServerResult<()> { let pid_maybe = match std::fs::read_to_string(pid_path(config)) { Ok(content) => str::parse::(&content).ok(), Err(_e) => None, @@ -12,7 +12,9 @@ pub fn terminate_existing_processes(config: &Config) -> BetterResult<()> { use sysinfo::{ProcessExt, SystemExt}; let mut s = sysinfo::System::new_all(); let retry_secs = 1; - let mut tries_left = 15; + let mut tries_left = 30; + // either friendly (Terminate) or not friendly (Kill) + let mut signal = sysinfo::Signal::Term; if let Some(process) = s.process(pid) { log::warn!( "Terminating existing running instance of atomic-server (process ID: {})...", @@ -32,8 +34,14 @@ pub fn terminate_existing_processes(config: &Config) -> BetterResult<()> { ); std::thread::sleep(std::time::Duration::from_secs(retry_secs)); } else { - log::error!("Could not terminate other atomic-server, exiting..."); - std::process::exit(1); + if signal == sysinfo::Signal::Kill { + log::error!("Could not terminate other atomic-server, exiting..."); + std::process::exit(1); + } + log::warn!("Terminate signal did not work, let's try again with Kill...",); + _process.kill(sysinfo::Signal::Kill); + tries_left = 15; + signal = sysinfo::Signal::Kill; } continue; }; @@ -46,7 +54,7 @@ pub fn terminate_existing_processes(config: &Config) -> BetterResult<()> { } /// Removes the process id file in the config directory meant for signaling this instance is running. -pub fn remove_pid(config: &Config) -> BetterResult<()> { +pub fn remove_pid(config: &Config) -> AtomicServerResult<()> { if std::fs::remove_file(pid_path(config)).is_err() { log::warn!( "Could not remove process file at {}", @@ -63,7 +71,7 @@ fn pid_path(config: &Config) -> std::path::PathBuf { } /// Writes a `pid` file in the config directory to signal which instance is running. -fn create_pid(config: &Config) -> BetterResult<()> { +fn create_pid(config: &Config) -> AtomicServerResult<()> { use std::io::Write; let pid = sysinfo::get_current_pid() .map_err(|_| "Failed to get process info required to create process ID")?; diff --git a/server/src/search.rs b/server/src/search.rs index 4119ca33c..b4844ea7f 100644 --- a/server/src/search.rs +++ b/server/src/search.rs @@ -11,7 +11,7 @@ use tantivy::IndexWriter; use tantivy::ReloadPolicy; use crate::config::Config; -use crate::errors::BetterResult; +use crate::errors::AtomicServerResult; /// The actual Schema used for search. /// It mimics a single Atom (or Triple). @@ -38,7 +38,7 @@ pub struct SearchState { impl SearchState { /// Create a new SearchState for the Server, which includes building the schema and index. - pub fn new(config: &Config) -> BetterResult { + pub fn new(config: &Config) -> AtomicServerResult { let schema = crate::search::build_schema()?; let (writer, index) = crate::search::get_index(config)?; let reader = crate::search::get_reader(&index)?; @@ -54,7 +54,7 @@ impl SearchState { } /// Returns the schema for the search index. -pub fn build_schema() -> BetterResult { +pub fn build_schema() -> AtomicServerResult { let mut schema_builder = Schema::builder(); // The STORED flag makes the index store the full values. Can be useful. schema_builder.add_text_field("subject", TEXT | STORED); @@ -65,7 +65,7 @@ pub fn build_schema() -> BetterResult { } /// Creates or reads the index from the `search_index_path` and allocates some heap size. -pub fn get_index(config: &Config) -> BetterResult<(IndexWriter, Index)> { +pub fn get_index(config: &Config) -> AtomicServerResult<(IndexWriter, Index)> { let schema = build_schema()?; std::fs::create_dir_all(&config.search_index_path)?; if config.opts.rebuild_index { @@ -85,7 +85,7 @@ pub fn get_index(config: &Config) -> BetterResult<(IndexWriter, Index)> { } /// Returns the schema for the search index. -pub fn get_schema_fields(appstate: &SearchState) -> BetterResult { +pub fn get_schema_fields(appstate: &SearchState) -> AtomicServerResult { let subject = appstate .schema .get_field("subject") @@ -108,7 +108,7 @@ pub fn get_schema_fields(appstate: &SearchState) -> BetterResult { /// Indexes all resources from the store to search. /// At this moment does not remove existing index. -pub fn add_all_resources(search_state: &SearchState, store: &Db) -> BetterResult<()> { +pub fn add_all_resources(search_state: &SearchState, store: &Db) -> AtomicServerResult<()> { for resource in store.all_resources(true) { // Skip commits // TODO: Better check, this might overfit @@ -124,7 +124,7 @@ pub fn add_all_resources(search_state: &SearchState, store: &Db) -> BetterResult /// Adds a single resource to the search index, but does _not_ commit! /// Does not index outgoing links, or resourcesArrays /// `appstate.search_index_writer.write()?.commit()?;` -pub fn add_resource(appstate: &SearchState, resource: &Resource) -> BetterResult<()> { +pub fn add_resource(appstate: &SearchState, resource: &Resource) -> AtomicServerResult<()> { let fields = get_schema_fields(appstate)?; let subject = resource.get_subject(); let writer = appstate.writer.read()?; @@ -148,7 +148,7 @@ pub fn add_resource(appstate: &SearchState, resource: &Resource) -> BetterResult // / Removes a single resource from the search index, but does _not_ commit! // / Does not index outgoing links, or resourcesArrays // / `appstate.search_index_writer.write()?.commit()?;` -pub fn remove_resource(search_state: &SearchState, subject: &str) -> BetterResult<()> { +pub fn remove_resource(search_state: &SearchState, subject: &str) -> AtomicServerResult<()> { let fields = get_schema_fields(search_state)?; let writer = search_state.writer.read()?; let term = tantivy::Term::from_field_text(fields.subject, subject); @@ -164,7 +164,7 @@ pub fn add_triple( property: String, value: String, fields: &Fields, -) -> BetterResult<()> { +) -> AtomicServerResult<()> { let mut doc = Document::default(); doc.add_text(fields.property, property); doc.add_text(fields.value, value); @@ -174,7 +174,7 @@ pub fn add_triple( } // For a search server you will typically create one reader for the entire lifetime of your program, and acquire a new searcher for every single request. -pub fn get_reader(index: &tantivy::Index) -> BetterResult { +pub fn get_reader(index: &tantivy::Index) -> AtomicServerResult { Ok(index .reader_builder() .reload_policy(ReloadPolicy::OnCommit) diff --git a/server/src/serve.rs b/server/src/serve.rs index e94a7f63a..58750957d 100644 --- a/server/src/serve.rs +++ b/server/src/serve.rs @@ -1,10 +1,12 @@ use actix_cors::Cors; use actix_web::{middleware, web, HttpServer}; -use atomic_lib::{errors::AtomicResult, Storelike}; +use atomic_lib::Storelike; use std::sync::Mutex; +use crate::errors::AtomicServerResult; + /// Start the server -pub async fn serve(config: crate::config::Config) -> AtomicResult<()> { +pub async fn serve(config: crate::config::Config) -> AtomicServerResult<()> { // Setup the database and more let appstate = crate::appstate::init(config.clone())?; diff --git a/server/src/tests.rs b/server/src/tests.rs index 2a8f1f19a..3df400377 100644 --- a/server/src/tests.rs +++ b/server/src/tests.rs @@ -2,10 +2,13 @@ //! Most of the more rigorous testing is done in the end-to-end tests: //! https://github.com/joepio/atomic-data-browser/tree/main/data-browser/tests +use crate::appstate::AppState; + use super::*; use actix_web::{ dev::{Body, ResponseBody}, - test, web, App, + test::{self, TestRequest}, + web, App, }; trait BodyTest { @@ -27,9 +30,27 @@ impl BodyTest for ResponseBody { } } +/// Returns the request with signed headers. Also adds a json-ad accept header - overwrite this if you need something else. +fn build_request_authenticated(path: &str, appstate: &AppState) -> TestRequest { + let url = format!("http://localhost{}", path); + let headers = atomic_lib::client::get_authentication_headers( + &url, + &appstate.store.get_default_agent().unwrap(), + ) + .expect("could not get auth headers"); + + let mut prereq = test::TestRequest::with_uri(path); + for (k, v) in headers { + prereq = prereq.header(k, v.clone()); + } + prereq.header("Accept", "application/ad+json") +} + #[actix_rt::test] async fn init_server() { - std::env::set_var("ATOMIC_REBUILD_INDEX", "true"); + std::env::set_var("ATOMIC_CONFIG_DIR", "./.temp"); + // We need tro run --initialize to make sure the agent has the correct rights / drive + std::env::set_var("ATOMIC_INITIALIZE", "true"); let config = config::init() .map_err(|e| format!("Initialization failed: {}", e)) .expect("failed init config"); @@ -42,57 +63,67 @@ async fn init_server() { ) .await; + // Does not work, unfortunately, because the server is not accessible. + // let fetched = + // atomic_lib::client::fetch_resource(&appstate.config.local_base_url, &appstate.store, None) + // .expect("could not fetch drive"); + // Get HTML page - let req = test::TestRequest::with_uri("/search?q=test").to_request(); - let mut resp = test::call_service(&mut app, req).await; + let req = build_request_authenticated("/", &appstate).header("Accept", "application/html"); + let mut resp = test::call_service(&mut app, req.to_request()).await; println!("response: {:?}", resp); assert!(resp.status().is_success()); let body = resp.take_body(); - assert!(body.as_str().contains("html")); + assert!(body.as_str().contains("html"), "no html in response"); - // Should 404 - let req = test::TestRequest::with_uri("/doesnotexist") - .header("Accept", "application/ld+json") - .to_request(); - let resp = test::call_service(&mut app, req).await; - // Note: This is currently 500, but should be 404 in the future! - assert!(resp.status().is_server_error()); + // Should 401 (Unauthorized) + let req = test::TestRequest::with_uri("/properties").header("Accept", "application/ad+json"); + let resp = test::call_service(&mut app, req.to_request()).await; + assert!( + resp.status().is_client_error(), + "resource should return 401 unauthorized" + ); // Get JSON-AD - let req = test::TestRequest::with_uri("/setup") - .header("Accept", "application/ad+json") - .to_request(); - let mut resp = test::call_service(&mut app, req).await; + let req = build_request_authenticated("/properties", &appstate); + let mut resp = test::call_service(&mut app, req.to_request()).await; assert!(resp.status().is_success(), "setup not returning JSON-AD"); let body = resp.take_body(); - assert!(body.as_str().contains("{\n \"@id\"")); + assert!( + body.as_str().contains("{\n \"@id\""), + "response should be json-ad" + ); // Get JSON-LD - let req = test::TestRequest::with_uri("/setup") - .header("Accept", "application/ld+json") - .to_request(); - let mut resp = test::call_service(&mut app, req).await; + let req = build_request_authenticated("/properties", &appstate) + .header("Accept", "application/ld+json"); + let mut resp = test::call_service(&mut app, req.to_request()).await; assert!(resp.status().is_success(), "setup not returning JSON-LD"); let body = resp.take_body(); - assert!(body.as_str().contains("@context")); + assert!( + body.as_str().contains("@context"), + "response should be json-ld" + ); // Get turtle - let req = test::TestRequest::with_uri("/setup") - .header("Accept", "text/turtle") - .to_request(); - let mut resp = test::call_service(&mut app, req).await; + let req = build_request_authenticated("/properties", &appstate).header("Accept", "text/turtle"); + let mut resp = test::call_service(&mut app, req.to_request()).await; assert!(resp.status().is_success()); let body = resp.take_body(); - assert!(body.as_str().starts_with("