use std::sync::Arc;
use crate::dir_cache::{DirCache, DirEntry};
use crate::prelude::*;
use cloudillo_types::meta_adapter;
use cloudillo_types::meta_adapter::FileView;
use cloudillo_types::types::{AccessLevel, TokenScope};
pub const MAX_PARENT_DEPTH: usize = 64;
pub struct FileAccessResult {
pub file_view: FileView,
pub access_level: AccessLevel,
pub read_only: bool,
}
pub enum FileAccessError {
NotFound,
AccessDenied,
InternalError(String),
}
pub struct FileAccessCtx<'a> {
pub user_id_tag: &'a str,
pub tenant_id_tag: &'a str,
pub user_roles: &'a [Box<str>],
}
pub async fn resolve_dir_entry(
meta: &Arc<dyn meta_adapter::MetaAdapter>,
cache: &DirCache,
tn_id: TnId,
file_id: &str,
) -> ClResult<Option<DirEntry>> {
if let Some(entry) = cache.get(tn_id, file_id) {
return Ok(Some(entry)); }
match meta.read_file(tn_id, file_id).await? {
Some(view) => {
let is_folder = view.file_tp.as_deref() == Some("FLDR");
let entry = DirEntry {
parent_id: view.parent_id.clone(),
name: view.file_name.clone(),
is_folder,
};
if is_folder {
cache.put(tn_id, file_id, entry.clone());
}
Ok(Some(entry))
}
None => Ok(None),
}
}
pub async fn walk_parent_chain_for_share(
app: &App,
tn_id: TnId,
file_id: &str,
user_id_tag: &str,
) -> Option<AccessLevel> {
let Ok(cache) = app.ext::<DirCache>() else {
warn!("DirCache extension missing; skipping inherited-share parent walk");
return None;
};
let mut current_id = file_id.to_string();
for _ in 0..MAX_PARENT_DEPTH {
let Ok(Some(entry)) = resolve_dir_entry(&app.meta_adapter, cache, tn_id, ¤t_id).await
else {
break;
};
let Some(parent_id) = entry.parent_id else { break };
if let Ok(Some(perm)) = app
.meta_adapter
.check_share_access(tn_id, 'F', &parent_id, 'U', user_id_tag)
.await
{
return Some(AccessLevel::from_perm_char(perm));
}
current_id = parent_id.to_string();
}
None
}
pub async fn is_descendant_of(
meta: &Arc<dyn meta_adapter::MetaAdapter>,
cache: &DirCache,
tn_id: TnId,
file_id: &str,
ancestor_id: &str,
) -> ClResult<bool> {
let mut current_id = file_id.to_string();
for _ in 0..MAX_PARENT_DEPTH {
let Some(entry) = resolve_dir_entry(meta, cache, tn_id, ¤t_id).await? else {
break;
};
let Some(parent_id) = entry.parent_id else { break };
if parent_id.as_ref() == ancestor_id {
return Ok(true);
}
current_id = parent_id.to_string();
}
Ok(false)
}
pub async fn scope_target_is_folder(
meta: &Arc<dyn meta_adapter::MetaAdapter>,
cache: &DirCache,
tn_id: TnId,
scope_file_id: &str,
) -> ClResult<bool> {
Ok(resolve_dir_entry(meta, cache, tn_id, scope_file_id)
.await?
.is_some_and(|e| e.is_folder))
}
pub async fn check_share_for_file(
app: &App,
tn_id: TnId,
file_id: &str,
user_id_tag: &str,
) -> Option<AccessLevel> {
if let Ok(Some(perm)) =
app.meta_adapter.check_share_access(tn_id, 'F', file_id, 'U', user_id_tag).await
{
return Some(AccessLevel::from_perm_char(perm));
}
walk_parent_chain_for_share(app, tn_id, file_id, user_id_tag).await
}
pub async fn get_access_level(
app: &App,
tn_id: TnId,
file_id: &str,
owner_id_tag: &str,
ctx: &FileAccessCtx<'_>,
inherited_share: Option<AccessLevel>,
) -> AccessLevel {
if ctx.user_id_tag == owner_id_tag {
return AccessLevel::Write;
}
if let Ok(Some(perm)) = app
.meta_adapter
.check_share_access(tn_id, 'F', file_id, 'U', ctx.user_id_tag)
.await
{
return AccessLevel::from_perm_char(perm);
}
if let Some(level) = inherited_share {
return level;
}
if let Some(level) = walk_parent_chain_for_share(app, tn_id, file_id, ctx.user_id_tag).await {
return level;
}
if owner_id_tag == ctx.tenant_id_tag && !ctx.user_roles.is_empty() {
if ctx
.user_roles
.iter()
.any(|r| matches!(r.as_ref(), "leader" | "moderator" | "contributor"))
{
return AccessLevel::Write;
}
return AccessLevel::Read;
}
let action_key = format!("FSHR:{}:{}", file_id, ctx.user_id_tag);
match app.meta_adapter.get_action_by_key(tn_id, &action_key).await {
Ok(Some(action)) => {
if action.typ.as_ref() == "FSHR" {
match action.sub_typ.as_ref().map(AsRef::as_ref) {
Some("WRITE") => AccessLevel::Write,
Some("COMMENT") => AccessLevel::Comment,
_ => AccessLevel::Read,
}
} else {
AccessLevel::None
}
}
Ok(None) | Err(_) => AccessLevel::None,
}
}
pub async fn get_access_level_with_scope(
app: &App,
tn_id: TnId,
file_id: &str,
owner_id_tag: &str,
ctx: &FileAccessCtx<'_>,
scope: Option<&str>,
root_id: Option<&str>,
) -> AccessLevel {
if let Some(scope_str) = scope {
if let Some(token_scope) = TokenScope::parse(scope_str) {
match &token_scope {
TokenScope::File { file_id: scope_file_id, access } => {
if scope_file_id == file_id {
return *access;
}
if let Some(root) = root_id
&& scope_file_id.as_str() == root
{
return *access;
}
if let Ok(Some(perm)) = app
.meta_adapter
.check_share_access(tn_id, 'F', scope_file_id, 'F', file_id)
.await
{
return (*access).min(AccessLevel::from_perm_char(perm));
}
if let Ok(cache) = app.ext::<DirCache>() {
let target_is_folder =
scope_target_is_folder(&app.meta_adapter, cache, tn_id, scope_file_id)
.await
.unwrap_or(false);
let nested_under_scope = target_is_folder
&& is_descendant_of(
&app.meta_adapter,
cache,
tn_id,
file_id,
scope_file_id,
)
.await
.unwrap_or(false);
if nested_under_scope {
return *access;
}
} else {
warn!("DirCache extension missing; folder-share scope grant skipped");
}
return AccessLevel::None;
}
TokenScope::ApkgPublish => {
return AccessLevel::None;
}
}
}
return AccessLevel::None;
}
get_access_level(app, tn_id, file_id, owner_id_tag, ctx, None).await
}
pub async fn check_file_access_with_scope(
app: &App,
tn_id: TnId,
file_id: &str,
ctx: &FileAccessCtx<'_>,
scope: Option<&str>,
via: Option<&str>,
) -> Result<FileAccessResult, FileAccessError> {
use tracing::debug;
let file_view = match app.meta_adapter.read_file(tn_id, file_id).await {
Ok(Some(f)) => f,
Ok(None) => return Err(FileAccessError::NotFound),
Err(e) => return Err(FileAccessError::InternalError(e.to_string())),
};
let owner_id_tag = file_view
.owner
.as_ref()
.and_then(|p| if p.id_tag.is_empty() { None } else { Some(p.id_tag.as_ref()) })
.unwrap_or(ctx.tenant_id_tag);
debug!(file_id = file_id, user = ctx.user_id_tag, owner = owner_id_tag, scope = ?scope, "Checking file access");
let mut access_level = get_access_level_with_scope(
app,
tn_id,
file_id,
owner_id_tag,
ctx,
scope,
file_view.root_id.as_deref(),
)
.await;
if access_level == AccessLevel::None && file_view.visibility == Some('P') {
access_level = AccessLevel::Read;
}
if let Some(via_file_id) = via
&& scope.is_none()
&& access_level != AccessLevel::None
{
match app.meta_adapter.check_share_access(tn_id, 'F', via_file_id, 'F', file_id).await {
Ok(Some(perm)) => {
access_level = access_level.min(AccessLevel::from_perm_char(perm));
}
Ok(None) | Err(_) => {
access_level = AccessLevel::None;
}
}
}
if access_level == AccessLevel::None {
return Err(FileAccessError::AccessDenied);
}
let read_only = access_level != AccessLevel::Write && access_level != AccessLevel::Admin;
Ok(FileAccessResult { file_view, access_level, read_only })
}
pub enum ScopeCheck {
NoScope,
Allowed(AccessLevel),
Denied,
}
pub fn check_scope_allows_file(
scope: Option<&str>,
file_id: &str,
root_id: Option<&str>,
) -> ScopeCheck {
let Some(scope_str) = scope else { return ScopeCheck::NoScope };
let Some(token_scope) = TokenScope::parse(scope_str) else { return ScopeCheck::Denied };
match &token_scope {
TokenScope::File { file_id: scope_file_id, access } => {
if scope_file_id == file_id {
return ScopeCheck::Allowed(*access);
}
if let Some(root) = root_id
&& scope_file_id.as_str() == root
{
return ScopeCheck::Allowed(*access);
}
ScopeCheck::Denied
}
TokenScope::ApkgPublish => ScopeCheck::Denied,
}
}
pub async fn check_scope_allows_create_in(
meta: &Arc<dyn meta_adapter::MetaAdapter>,
cache: &DirCache,
tn_id: TnId,
scope: Option<&str>,
parent_id: Option<&str>,
root_id: Option<&str>,
) -> Result<(), Error> {
let Some(scope_str) = scope else { return Ok(()) };
let Some(token_scope) = TokenScope::parse(scope_str) else {
return Err(Error::PermissionDenied);
};
match &token_scope {
TokenScope::File { file_id: scope_file_id, access } => {
if *access != AccessLevel::Write {
return Err(Error::PermissionDenied);
}
if root_id == Some(scope_file_id.as_str()) {
return Ok(());
}
if let Some(parent) = parent_id
&& scope_target_is_folder(meta, cache, tn_id, scope_file_id).await?
&& (parent == scope_file_id.as_str()
|| is_descendant_of(meta, cache, tn_id, parent, scope_file_id).await?)
{
return Ok(());
}
Err(Error::PermissionDenied)
}
TokenScope::ApkgPublish => Ok(()), }
}
pub fn scope_grants_collection_op(scope: Option<&str>, resource_type: &str, action: &str) -> bool {
let Some(scope) = scope else { return false };
matches!(TokenScope::parse(scope), Some(TokenScope::File { access: AccessLevel::Write, .. }))
&& resource_type == "file"
&& action == "create"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folder_write_scope_grants_file_create() {
assert!(scope_grants_collection_op(Some("file:f1~abc:W"), "file", "create"));
}
#[test]
fn write_scope_denies_non_file_create_ops() {
assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "action", "create"));
assert!(!scope_grants_collection_op(Some("file:f1~abc:W"), "file", "delete"));
}
#[test]
fn read_scope_denies_file_create() {
assert!(!scope_grants_collection_op(Some("file:f1~abc:R"), "file", "create"));
}
#[test]
fn no_scope_denies_file_create() {
assert!(!scope_grants_collection_op(None, "file", "create"));
}
#[test]
fn unparseable_scope_denies_file_create() {
assert!(!scope_grants_collection_op(Some("not-a-valid-scope"), "file", "create"));
}
}