use core::fmt;
use crate::{agents::ForAgent, errors::AtomicResult, urls, Resource, Storelike};
#[cfg(target_arch = "wasm32")]
type AsyncResult<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + 'a>>;
#[cfg(not(target_arch = "wasm32"))]
type AsyncResult<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
#[derive(Debug, Clone, Copy)]
pub enum Right {
Read,
Write,
Append,
}
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,
Right::Append => urls::APPEND,
};
fmt.write_str(str)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AuthImpact {
pub genesis: bool,
pub read: bool,
pub write: bool,
pub append: bool,
pub parent: bool,
pub destroy: bool,
}
impl AuthImpact {
pub fn is_critical(&self) -> bool {
self.genesis || self.read || self.write || self.append || self.parent || self.destroy
}
}
pub fn classify_auth_impact(
changed_props: &std::collections::HashSet<String>,
is_genesis: bool,
is_destroy: bool,
) -> AuthImpact {
AuthImpact {
genesis: is_genesis,
read: changed_props.contains(urls::READ),
write: changed_props.contains(urls::WRITE),
append: changed_props.contains(urls::APPEND),
parent: changed_props.contains(urls::PARENT),
destroy: is_destroy,
}
}
pub fn check_write<'a>(
store: &'a impl Storelike,
resource: &'a Resource,
for_agent: &'a ForAgent,
) -> AsyncResult<'a, AtomicResult<String>> {
Box::pin(check_rights(store, resource, for_agent, Right::Write))
}
pub fn check_read<'a>(
store: &'a impl Storelike,
resource: &'a Resource,
for_agent: &'a ForAgent,
) -> AsyncResult<'a, AtomicResult<String>> {
Box::pin(check_rights(store, resource, for_agent, Right::Read))
}
#[derive(Default)]
pub struct RightsCache {
outcomes: std::collections::HashMap<(u8, String), bool>,
}
fn right_discriminant(right: Right) -> u8 {
match right {
Right::Read => 0,
Right::Write => 1,
Right::Append => 2,
}
}
pub fn check_rights_cached<'a, S: Storelike>(
store: &'a S,
resource: &'a Resource,
for_agent_enum: &'a ForAgent,
right: Right,
cache: Option<&'a std::sync::Mutex<RightsCache>>,
) -> AsyncResult<'a, AtomicResult<String>> {
Box::pin(async move {
let key = (right_discriminant(right), resource.get_subject().pure_id());
if let Some(cache) = cache {
let hit = cache
.lock()
.ok()
.and_then(|guard| guard.outcomes.get(&key).copied());
match hit {
Some(true) => return Ok("Allowed (cached for this request)".into()),
Some(false) => {
return Err(crate::errors::AtomicError::unauthorized(format!(
"No {} right found for {} (cached for this request)",
right, for_agent_enum
)))
}
None => {}
}
}
let result = check_rights_impl(store, resource, for_agent_enum, right, cache).await;
if let Some(cache) = cache {
if let Ok(mut guard) = cache.lock() {
guard.outcomes.insert(key, result.is_ok());
}
}
result
})
}
#[tracing::instrument(skip_all)]
pub async fn check_append(
store: &impl Storelike,
resource: &Resource,
for_agent: &ForAgent,
) -> AtomicResult<String> {
match resource.get_parent(store).await {
Ok(parent) => {
if let Ok(msg) = check_rights(store, &parent, for_agent, Right::Append).await {
Ok(msg)
} else {
check_rights(store, resource, for_agent, Right::Write).await
}
}
Err(e) => {
if resource
.get_classes(store)
.await?
.iter()
.any(|c| c.subject == urls::DRIVE)
|| resource.get_subject().to_string().starts_with("did:")
{
Ok(String::from("Drive or DID without a parent can be created"))
} else {
Err(e)
}
}
}
}
#[tracing::instrument(skip_all, fields(subject = %resource.get_subject(), agent = %for_agent_enum, right = ?right))]
pub fn check_rights<'a>(
store: &'a impl Storelike,
resource: &'a Resource,
for_agent_enum: &'a ForAgent,
right: Right,
) -> AsyncResult<'a, AtomicResult<String>> {
check_rights_impl(store, resource, for_agent_enum, right, None)
}
fn check_rights_impl<'a, S: Storelike>(
store: &'a S,
resource: &'a Resource,
for_agent_enum: &'a ForAgent,
right: Right,
cache: Option<&'a std::sync::Mutex<RightsCache>>,
) -> AsyncResult<'a, AtomicResult<String>> {
Box::pin(async move {
if for_agent_enum == &ForAgent::Sudo {
return Ok("Sudo has root access, and can edit anything.".into());
}
let for_agent = for_agent_enum.to_string();
let normalized_for_agent = store.normalize_subject(
&crate::agents::migrate_legacy_agent_subject(&for_agent)
.as_str()
.into(),
);
if resource.get_subject() == &normalized_for_agent {
return Ok("Agents can always edit themselves or their children.".into());
}
if let Ok(server_agent) = store.get_default_agent() {
let normalized_server_agent = store.normalize_subject(&server_agent.subject);
if normalized_server_agent == normalized_for_agent {
return Ok("Server agent has root access, and can edit anything.".into());
}
}
if let Ok(commit_subject) = resource.get(urls::SUBJECT) {
return match right {
Right::Read => {
let target = store
.get_resource(&commit_subject.to_string().as_str().into())
.await?;
check_rights_cached(store, &target, for_agent_enum, right, cache).await
}
Right::Write => Err("Commits cannot be edited.".into()),
Right::Append => {
Err("Commits cannot have children, you cannot Append to them.".into())
}
};
}
let mut properties_to_check = vec![right.to_string()];
if matches!(right, Right::Read | Right::Append) {
properties_to_check.push(urls::WRITE.to_string());
}
for prop in properties_to_check {
if let Ok(arr_val) = resource.get(&prop) {
for s in arr_val.to_subjects(None)? {
match s.as_str() {
urls::PUBLIC_AGENT => {
return Ok(format!(
"PublicAgent has been granted rights in {}",
resource.get_subject()
))
}
agent => {
let migrated = crate::agents::migrate_legacy_agent_subject(agent);
let normalized_agent =
store.normalize_subject(&migrated.as_str().into());
if normalized_agent == normalized_for_agent {
return Ok(format!(
"Right has been explicitly set in {}",
resource.get_subject()
));
}
}
};
}
}
}
if let Ok(drive_val) = resource.get(urls::DRIVE_PROP) {
let drive_subject = crate::Subject::from(drive_val.to_string());
if &drive_subject != resource.get_subject() {
let cached_deny = cache.and_then(|c| {
let key = (right_discriminant(right), drive_subject.pure_id());
c.lock().ok().and_then(|g| g.outcomes.get(&key).copied())
}) == Some(false);
if !cached_deny {
if let Ok(drive_res) = store.get_resource(&drive_subject).await {
if let Ok(reason) =
check_rights_cached(store, &drive_res, for_agent_enum, right, cache)
.await
{
return Ok(reason);
}
}
}
}
}
tracing::debug!(
subject = %resource.get_subject(),
"rights walk: no explicit grant here, ascending to parent"
);
match resource.get_parent(store).await {
Ok(parent) => {
tracing::debug!(
subject = %resource.get_subject(),
parent = %parent.get_subject(),
"rights walk: ascending"
);
return check_rights_cached(store, &parent, for_agent_enum, right, cache).await;
}
Err(parent_err) => {
tracing::warn!(
subject = %resource.get_subject(),
agent = %for_agent,
?right,
parent_err = %parent_err,
"rights walk TERMINATED: get_parent failed (this is where the 401 originates)"
);
}
}
{
if for_agent_enum == &ForAgent::Public {
let action = match right {
Right::Read => "readable",
Right::Write => "editable",
Right::Append => "appendable",
};
return Err(crate::errors::AtomicError::unauthorized(format!(
"This resource is not publicly {}. Try signing in",
action,
)));
}
Err(crate::errors::AtomicError::unauthorized(format!(
"No {} right has been found for {} in this resource or its parents",
right, for_agent
)))
}
})
}
#[cfg(test)]
mod test {
use crate::{datatype::DataType, Storelike, Value};
#[tokio::test]
async fn legacy_internal_agent_grant_still_authorizes_its_did() {
use crate::agents::ForAgent;
use crate::hierarchy::{check_rights, Right};
let store = crate::db::Db::init_temp("legacy_agent_grant_rights")
.await
.unwrap();
crate::test_utils::setup_test_env(&store).await.unwrap();
let pubkey = "+/UHiCrMCWr7O5waaKRPJ5Pq90T8ncocNkH0kYihCFM=";
let mut resource = crate::Resource::new_instance(crate::urls::TAG, &store)
.await
.unwrap();
resource
.set(
crate::urls::SHORTNAME.into(),
Value::Slug("owned".into()),
&store,
)
.await
.unwrap();
resource
.push(
crate::urls::WRITE,
format!("internal:/agents/{pubkey}").as_str().into(),
true,
)
.unwrap();
resource.save_locally(&store).await.unwrap();
let signed_in = ForAgent::AgentSubject(format!("did:ad:agent:{pubkey}").as_str().into());
check_rights(&store, &resource, &signed_in, Right::Write)
.await
.expect("the legacy grant names this very key — its owner must keep write access");
}
mod auth_impact {
use super::super::classify_auth_impact;
use crate::urls;
use std::collections::HashSet;
fn props(items: &[&str]) -> HashSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn ordinary_content_change_is_not_critical() {
let impact =
classify_auth_impact(&props(&[urls::DESCRIPTION, urls::NAME]), false, false);
assert!(!impact.is_critical());
assert_eq!(impact, Default::default());
}
#[test]
fn a_read_grant_is_a_read_impact() {
let impact = classify_auth_impact(&props(&[urls::READ]), false, false);
assert!(impact.read);
assert!(impact.is_critical());
assert!(!impact.write && !impact.append && !impact.parent);
}
#[test]
fn a_write_grant_is_a_write_impact() {
let impact = classify_auth_impact(&props(&[urls::WRITE]), false, false);
assert!(impact.write && impact.is_critical());
}
#[test]
fn an_append_grant_is_an_append_impact() {
let impact = classify_auth_impact(&props(&[urls::APPEND]), false, false);
assert!(impact.append && impact.is_critical());
}
#[test]
fn a_reparent_changes_inherited_rights_and_is_critical() {
let impact = classify_auth_impact(&props(&[urls::PARENT]), false, false);
assert!(impact.parent && impact.is_critical());
}
#[test]
fn genesis_is_critical_from_the_flag_regardless_of_props() {
let impact = classify_auth_impact(&props(&[urls::NAME]), true, false);
assert!(impact.genesis && impact.is_critical());
}
#[test]
fn destroy_is_critical_from_the_flag() {
let impact = classify_auth_impact(&props(&[]), false, true);
assert!(impact.destroy && impact.is_critical());
}
#[test]
fn a_commit_can_carry_several_impacts_at_once() {
let impact = classify_auth_impact(&props(&[urls::READ, urls::WRITE]), true, false);
assert!(impact.genesis && impact.read && impact.write);
assert!(!impact.parent && !impact.destroy);
assert!(impact.is_critical());
}
}
#[tokio::test]
async fn authorization() {
let store = crate::Store::init().await.unwrap();
store.populate().await.unwrap();
let subject = "https://localhost/new_thing";
let mut commitbuilder_1 = crate::commit::CommitBuilder::new(subject.into());
let property = crate::urls::DESCRIPTION;
let value = Value::new("Some value", &DataType::Markdown).unwrap();
commitbuilder_1.set(property.into(), value);
}
#[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);
}
#[tokio::test]
async fn create_did_agent() {
let store = crate::Store::init().await.unwrap();
store.populate().await.unwrap();
let agent = store.create_agent(Some("test_actor")).await.unwrap();
store.set_default_agent(agent.clone());
let drive_did = crate::test_utils::create_test_drive(&store).await.unwrap();
let subject = "https://localhost/test-did-agent";
let mut commitbuilder = crate::commit::CommitBuilder::new(subject.into());
commitbuilder.set(
crate::urls::DESCRIPTION.into(),
Value::new("Some value", &DataType::Markdown).unwrap(),
);
commitbuilder.set(crate::urls::PARENT.into(), Value::AtomicUrl(drive_did));
let resource = crate::Resource::new(subject.into());
let commit = commitbuilder.sign(&agent, &store, &resource).await.unwrap();
let opts = crate::commit::CommitOpts {
validate_schema: false,
validate_signature: false,
validate_timestamp: true,
validate_rights: true,
validate_previous_commit: false,
validate_loro_causality: false,
update_index: true,
validate_for_agent: Some(agent.subject.to_string()),
source_id: None,
};
store.apply_commit(commit, &opts).await.unwrap();
}
#[tokio::test]
async fn a_real_read_grant_commit_is_labelled_read_critical() {
let store = crate::Store::init().await.unwrap();
store.populate().await.unwrap();
let agent = store.create_agent(Some("granter")).await.unwrap();
store.set_default_agent(agent.clone());
let drive_did = crate::test_utils::create_test_drive(&store).await.unwrap();
let opts = crate::commit::CommitOpts {
validate_schema: false,
validate_signature: false,
validate_timestamp: true,
validate_rights: true,
validate_previous_commit: false,
validate_loro_causality: false,
update_index: true,
validate_for_agent: Some(agent.subject.to_string()),
source_id: None,
};
let subject = "https://localhost/granted-thing";
let mut genesis = crate::commit::CommitBuilder::new(subject.into());
genesis.set(
crate::urls::PARENT.into(),
Value::AtomicUrl(drive_did.clone()),
);
genesis.set(
crate::urls::DESCRIPTION.into(),
Value::new("x", &DataType::Markdown).unwrap(),
);
let commit = genesis
.sign(&agent, &store, &crate::Resource::new(subject.into()))
.await
.unwrap();
store.apply_commit(commit, &opts).await.unwrap();
let current = store.get_resource(&subject.into()).await.unwrap();
let mut grant = crate::commit::CommitBuilder::new(subject.into());
grant.set(
crate::urls::READ.into(),
Value::ResourceArray(vec![crate::urls::PUBLIC_AGENT.into()]),
);
let commit = grant.sign(&agent, &store, ¤t).await.unwrap();
let response = store.apply_commit(commit, &opts).await.unwrap();
let impact = response.auth_impact();
assert!(
impact.read,
"editing the read ACL must be labelled a read impact; changed_props={:?}",
response.changed_props
);
assert!(impact.is_critical());
assert!(!impact.genesis, "a later edit is not genesis");
}
#[tokio::test]
async fn moving_a_resource_to_a_private_drive_revokes_public_read() {
let store = crate::Store::init().await.unwrap();
store.populate().await.unwrap();
let agent = store.create_agent(Some("mover")).await.unwrap();
store.set_default_agent(agent.clone());
let opts = crate::commit::CommitOpts {
validate_schema: false,
validate_signature: false,
validate_timestamp: true,
validate_rights: true,
validate_previous_commit: false,
validate_loro_causality: false,
update_index: true,
validate_for_agent: Some(agent.subject.to_string()),
source_id: None,
};
let public_drive = crate::test_utils::create_test_drive(&store).await.unwrap();
let mut drive_res = store.get_resource(&public_drive).await.unwrap();
drive_res
.push(crate::urls::READ, crate::urls::PUBLIC_AGENT.into(), true)
.unwrap();
drive_res.save_locally(&store).await.unwrap();
let private_drive = crate::test_utils::create_test_drive(&store).await.unwrap();
let mut post = crate::Resource::new("did:ad:placeholder".into());
post.set(
crate::urls::PARENT.into(),
Value::AtomicUrl(public_drive.clone()),
&store,
)
.await
.unwrap();
post.set(
crate::urls::DESCRIPTION.into(),
Value::new("a draft", &DataType::Markdown).unwrap(),
&store,
)
.await
.unwrap();
post.set(
crate::urls::DRIVE_PROP.into(),
Value::AtomicUrl(public_drive.clone()),
&store,
)
.await
.unwrap();
let subject = post
.save_as_genesis(&store)
.await
.unwrap()
.resource_new
.unwrap()
.get_subject()
.clone();
let resource = store.get_resource(&subject).await.unwrap();
assert_eq!(
resource.get(crate::urls::DRIVE_PROP).unwrap().to_string(),
public_drive.to_string(),
"sanity: genesis stamps the drive"
);
assert!(
super::check_read(&store, &resource, &crate::agents::ForAgent::Public)
.await
.is_ok(),
"sanity: a resource on a public drive is publicly readable"
);
let current = store.get_resource(&subject).await.unwrap();
let mut move_commit = crate::commit::CommitBuilder::new(subject.clone());
move_commit.set(
crate::urls::PARENT.into(),
Value::AtomicUrl(private_drive.clone()),
);
let commit = move_commit.sign(&agent, &store, ¤t).await.unwrap();
store.apply_commit(commit, &opts).await.unwrap();
let moved = store.get_resource(&subject).await.unwrap();
assert_eq!(
moved.get(crate::urls::PARENT).unwrap().to_string(),
private_drive.to_string(),
"sanity: the move landed"
);
let public_read = super::check_read(&store, &moved, &crate::agents::ForAgent::Public).await;
assert!(
public_read.is_err(),
"a resource moved to a private drive must not stay publicly readable. \
drive stamp is still {:?}; check_read said: {:?}",
moved.get(crate::urls::DRIVE_PROP).map(|v| v.to_string()),
public_read
);
}
#[tokio::test]
async fn a_public_folder_in_a_private_drive_publishes_only_its_own_children() {
let store = crate::Store::init().await.unwrap();
store.populate().await.unwrap();
let agent = store.create_agent(Some("editor")).await.unwrap();
store.set_default_agent(agent.clone());
let opts = crate::commit::CommitOpts {
validate_schema: false,
validate_signature: false,
validate_timestamp: true,
validate_rights: true,
validate_previous_commit: false,
validate_loro_causality: false,
update_index: true,
validate_for_agent: Some(agent.subject.to_string()),
source_id: None,
};
let drive = crate::test_utils::create_test_drive(&store).await.unwrap();
let make_child = |parent: crate::Subject, public: bool| {
let store = &store;
async move {
let mut res = crate::Resource::new("did:ad:placeholder".into());
res.set(crate::urls::PARENT.into(), Value::AtomicUrl(parent), store)
.await
.unwrap();
if public {
res.set(
crate::urls::READ.into(),
Value::ResourceArray(vec![crate::urls::PUBLIC_AGENT.into()]),
store,
)
.await
.unwrap();
}
res.save_as_genesis(store)
.await
.unwrap()
.resource_new
.unwrap()
.get_subject()
.clone()
}
};
let site_folder = make_child(drive.clone(), true).await;
let drafts_folder = make_child(drive.clone(), false).await;
let published = make_child(site_folder.clone(), false).await;
let draft = make_child(drafts_folder.clone(), false).await;
let can_read = |subject: crate::Subject| {
let store = &store;
async move {
let res = store.get_resource(&subject).await.unwrap();
super::check_read(store, &res, &crate::agents::ForAgent::Public)
.await
.is_ok()
}
};
assert!(
!can_read(drive.clone()).await,
"the drive itself must stay private"
);
assert!(
can_read(published.clone()).await,
"a child of the public folder inherits public read"
);
assert!(
!can_read(draft.clone()).await,
"a draft in the same drive, outside the public folder, must NOT be publicly readable"
);
let current = store.get_resource(&draft).await.unwrap();
let mut publish = crate::commit::CommitBuilder::new(draft.clone());
publish.set(
crate::urls::PARENT.into(),
Value::AtomicUrl(site_folder.clone()),
);
let commit = publish.sign(&agent, &store, ¤t).await.unwrap();
store.apply_commit(commit, &opts).await.unwrap();
assert!(
can_read(draft.clone()).await,
"publishing by re-parenting into the public folder must grant public read"
);
let current = store.get_resource(&draft).await.unwrap();
let mut unpublish = crate::commit::CommitBuilder::new(draft.clone());
unpublish.set(
crate::urls::PARENT.into(),
Value::AtomicUrl(drafts_folder.clone()),
);
let commit = unpublish.sign(&agent, &store, ¤t).await.unwrap();
store.apply_commit(commit, &opts).await.unwrap();
assert!(
!can_read(draft.clone()).await,
"unpublishing by re-parenting out of the public folder must revoke public read"
);
}
}