use pubky_common::capabilities::Action;
use pubky_common::crypto::PublicKey;
use crate::client_server::auth::AuthSession;
use crate::constants::{PRIVATE_ROOT, PUBLIC_ROOT};
use crate::shared::webdav::StoragePath;
use crate::shared::HttpError;
const STORAGE_ROOTS: [&str; 2] = [PUBLIC_ROOT, PRIVATE_ROOT];
pub fn has_write_permission(
session: &AuthSession,
pubkey: &PublicKey,
path: &StoragePath,
) -> Result<(), HttpError> {
let path_str = path.as_str();
if !STORAGE_ROOTS.iter().any(|root| path_str.starts_with(root)) {
return Err(HttpError::forbidden_with_message(
"Writing to directories other than '/pub/' and '/priv/' is forbidden",
));
}
session_has_action(session, pubkey, path, Action::Write)
}
pub fn has_read_permission(
session: Option<&AuthSession>,
pubkey: Option<&PublicKey>,
path: &StoragePath,
) -> Result<(), HttpError> {
let path_str = path.as_str();
if path_str.starts_with(PUBLIC_ROOT) {
return Ok(());
}
if !path_str.starts_with(PRIVATE_ROOT) {
return Err(HttpError::forbidden_with_message(
"Reading from directories other than '/pub/' and '/priv/' is forbidden",
));
}
let session = session.ok_or_else(|| {
HttpError::unauthorized_with_message("Authentication required to read private storage")
})?;
let pubkey = pubkey.ok_or_else(|| {
HttpError::forbidden_with_message("A private read must be scoped to exactly one user")
})?;
session_has_action(session, pubkey, path, Action::Read)
}
fn session_has_action(
session: &AuthSession,
pubkey: &PublicKey,
path: &StoragePath,
action: Action,
) -> Result<(), HttpError> {
if session.user_key() != pubkey {
return Err(HttpError::forbidden_with_message(
"Session user does not match target tenant",
));
}
let granted = session
.capabilities()
.iter()
.any(|cap| cap.scope_covers_path(path) && cap.actions().contains(&action));
if granted {
return Ok(());
}
let what = match action {
Action::Read => "read access",
Action::Write => "write access",
Action::Unknown(_) => "access",
};
Err(HttpError::forbidden_with_message(format!(
"Session does not have {what} to path"
)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::client_server::auth::grant::session::GrantSession;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use pubky_common::auth::jws::GrantId;
use pubky_common::capabilities::{Capabilities, Capability};
use pubky_common::crypto::{Keypair, PublicKey};
fn dummy_pk() -> PublicKey {
Keypair::random().public_key()
}
fn web_path(s: &str) -> StoragePath {
StoragePath::new(s).expect("test path must be a syntactically valid webdav path")
}
fn session_with_key(pk: PublicKey, capabilities: Capabilities) -> AuthSession {
AuthSession::Grant(GrantSession::test(
pk,
capabilities,
GrantId::generate(),
9999999999,
))
}
fn session_with_caps(capabilities: Capabilities) -> (AuthSession, PublicKey) {
let pk = dummy_pk();
let session = session_with_key(pk.clone(), capabilities);
(session, pk)
}
fn root_caps() -> Capabilities {
Capabilities::from(vec![Capability::root()])
}
fn scoped_caps(scope: &str) -> Capabilities {
Capabilities::from(vec![Capability::write(scope).unwrap()])
}
fn read_only_caps() -> Capabilities {
Capabilities::from(vec![Capability::read("/").unwrap()])
}
fn read_scoped_caps(scope: &str) -> Capabilities {
Capabilities::from(vec![Capability::read(scope).unwrap()])
}
fn read_rejection_status(result: Result<(), HttpError>) -> StatusCode {
result
.expect_err("expected the read to be rejected")
.into_response()
.status()
}
#[test]
fn root_capability_grants_access_to_any_pub_path() {
let (session, pubky) = session_with_caps(root_caps());
assert!(has_write_permission(&session, &pubky, &web_path("/pub/anything")).is_ok());
}
#[test]
fn empty_capabilities_denies_access() {
let (session, pubky) = session_with_caps(Capabilities::from(vec![]));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/file")).is_err());
}
#[test]
fn read_only_capabilities_deny_write() {
let (session, pubky) = session_with_caps(read_only_caps());
assert!(has_write_permission(&session, &pubky, &web_path("/pub/file.txt")).is_err());
}
#[test]
fn scoped_capability_grants_access_to_subpath() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/my.app/"));
assert!(
has_write_permission(&session, &pubky, &web_path("/pub/my.app/nested/file")).is_ok()
);
}
#[test]
fn scoped_capability_denies_access_to_sibling_path() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/my.app/"));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/other.app/file")).is_err());
}
#[test]
fn scoped_capability_without_slash_rejects_prefix_attack() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/app"));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/app-evil/file")).is_err());
}
#[test]
fn scoped_capability_without_slash_allows_exact_match() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/app"));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/app")).is_ok());
}
#[test]
fn directory_scope_denies_write_to_directory_path_without_trailing_slash() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/pubky.app/"));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/pubky.app")).is_err());
}
#[test]
fn file_scope_denies_write_to_descendant() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/app"));
assert!(has_write_permission(&session, &pubky, &web_path("/pub/app/foo")).is_err());
}
#[test]
fn cross_tenant_write_is_rejected() {
let session = session_with_key(dummy_pk(), root_caps());
let pubky = dummy_pk();
assert!(has_write_permission(&session, &pubky, &web_path("/pub/file.txt")).is_err());
}
#[test]
fn same_tenant_write_with_root_caps_is_allowed() {
let pk = dummy_pk();
let session = session_with_key(pk.clone(), root_caps());
let pubky = pk;
assert!(has_write_permission(&session, &pubky, &web_path("/pub/file.txt")).is_ok());
}
#[test]
fn write_outside_writable_roots_is_rejected() {
let (session, pubky) = session_with_caps(root_caps());
assert!(has_write_permission(&session, &pubky, &web_path("/foo/example.com/x")).is_err());
}
#[test]
fn root_capability_grants_access_to_any_priv_path() {
let (session, pubky) = session_with_caps(root_caps());
assert!(has_write_permission(&session, &pubky, &web_path("/priv/anything")).is_ok());
}
#[test]
fn priv_path_with_covering_cap_is_allowed() {
let (session, pubky) = session_with_caps(scoped_caps("/priv/app/"));
assert!(has_write_permission(&session, &pubky, &web_path("/priv/app/x")).is_ok());
}
#[test]
fn priv_path_with_only_pub_caps_is_denied() {
let (session, pubky) = session_with_caps(scoped_caps("/pub/app/"));
assert!(has_write_permission(&session, &pubky, &web_path("/priv/app/x")).is_err());
}
#[test]
fn pub_read_is_allowed_anonymously() {
let pubky = dummy_pk();
assert!(has_read_permission(None, Some(&pubky), &web_path("/pub/anything")).is_ok());
}
#[test]
fn pub_read_is_allowed_with_session() {
let (session, pubky) = session_with_caps(root_caps());
assert!(has_read_permission(Some(&session), Some(&pubky), &web_path("/pub/x")).is_ok());
}
#[test]
fn priv_read_without_session_is_unauthorized() {
let pubky = dummy_pk();
let status = read_rejection_status(has_read_permission(
None,
Some(&pubky),
&web_path("/priv/x"),
));
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[test]
fn priv_read_cross_tenant_is_forbidden() {
let session = session_with_key(dummy_pk(), root_caps());
let pubky = dummy_pk();
let status = read_rejection_status(has_read_permission(
Some(&session),
Some(&pubky),
&web_path("/priv/x"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn priv_read_with_only_write_cap_is_forbidden() {
let (session, pubky) = session_with_caps(scoped_caps("/priv/app/"));
let status = read_rejection_status(has_read_permission(
Some(&session),
Some(&pubky),
&web_path("/priv/app/x"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn priv_read_with_covering_read_cap_is_allowed() {
let (session, pubky) = session_with_caps(read_scoped_caps("/priv/app/"));
assert!(
has_read_permission(Some(&session), Some(&pubky), &web_path("/priv/app/x")).is_ok()
);
}
#[test]
fn priv_read_with_root_cap_is_allowed() {
let (session, pubky) = session_with_caps(root_caps());
assert!(
has_read_permission(Some(&session), Some(&pubky), &web_path("/priv/anything")).is_ok()
);
}
#[test]
fn priv_read_cap_does_not_cover_sibling() {
let (session, pubky) = session_with_caps(read_scoped_caps("/priv/app/"));
let status = read_rejection_status(has_read_permission(
Some(&session),
Some(&pubky),
&web_path("/priv/other/x"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn read_outside_writable_roots_is_forbidden() {
let (session, pubky) = session_with_caps(root_caps());
let status = read_rejection_status(has_read_permission(
Some(&session),
Some(&pubky),
&web_path("/foo/x"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn priv_read_cap_does_not_cover_parent_dir() {
let (session, pubky) = session_with_caps(read_scoped_caps("/priv/app/"));
let status = read_rejection_status(has_read_permission(
Some(&session),
Some(&pubky),
&web_path("/priv/"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn pub_read_without_a_tenant_is_allowed() {
assert!(has_read_permission(None, None, &web_path("/pub/anything")).is_ok());
}
#[test]
fn priv_read_without_a_single_tenant_is_forbidden() {
let (session, _pubky) = session_with_caps(root_caps());
let status = read_rejection_status(has_read_permission(
Some(&session),
None,
&web_path("/priv/x"),
));
assert_eq!(status, StatusCode::FORBIDDEN);
}
#[test]
fn priv_read_without_session_or_tenant_is_unauthorized() {
let status = read_rejection_status(has_read_permission(None, None, &web_path("/priv/x")));
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
}