use super::{
AccessGrant, AclFacts, AuthorizationDecision, AuthorizationRequest, Decision, DecisionSource, DenialReason,
DocAction, DocAuthorizationDecision, DocRole, RequestedDocAction, RequestedWorkspaceAction, WorkspaceAction,
WorkspaceRole,
};
pub fn authorize(request: &AuthorizationRequest, facts: &AclFacts, grant: &AccessGrant) -> AuthorizationDecision {
if !facts.workspace_exists {
return missing_workspace(request);
}
let assigned_role = facts.assigned_workspace_role.filter(|_| facts.active_member);
let effective_role = match assigned_role {
Some(WorkspaceRole::Admin) if !grant.rights.commercial => Some(WorkspaceRole::Member),
role => role,
};
let workspace_preview_decision = super::evaluate_workspace_preview(facts);
let workspace_preview = request
.workspace_actions
.iter()
.any(|action| matches!(action, RequestedWorkspaceAction::Known(WorkspaceAction::Preview)))
.then_some(workspace_preview_decision);
let workspace = request
.workspace_actions
.iter()
.map(|action| workspace_decision(action, effective_role, facts, grant, workspace_preview_decision))
.collect();
let docs = request
.doc_actions
.iter()
.map(|requested| {
let doc = facts.docs.iter().find(|doc| doc.doc_id == requested.doc_id);
let (role, source) = doc.map_or((None, None), |doc| doc_role(doc, effective_role, facts));
let preview_decision = super::evaluate_doc_preview(doc, facts.url_preview_enabled);
let preview = requested
.actions
.iter()
.any(|action| matches!(action, RequestedDocAction::Known(DocAction::Preview)))
.then_some(preview_decision);
let decisions = requested
.actions
.iter()
.map(|action| doc_decision(action, role, source, assigned_role, facts, grant, doc, preview_decision))
.collect();
DocAuthorizationDecision {
doc_id: requested.doc_id.clone(),
effective_role: role,
preview,
decisions,
}
})
.collect();
AuthorizationDecision {
effective_workspace_role: effective_role,
workspace_preview,
workspace,
docs,
}
}
fn workspace_decision(
requested: &RequestedWorkspaceAction,
role: Option<WorkspaceRole>,
facts: &AclFacts,
grant: &AccessGrant,
preview: super::DocPreviewDecision,
) -> Decision {
let RequestedWorkspaceAction::Known(action) = requested else {
return denied(requested.as_str(), DenialReason::UnknownAction);
};
let public_external = facts.workspace_public && facts.sharing_enabled;
let allowed = match action {
WorkspaceAction::Preview => preview.exposure.is_allowed(),
_ => {
role.is_some_and(|role| workspace_role_allows(role, *action))
|| public_external && workspace_role_allows(WorkspaceRole::External, *action)
}
};
if *action == WorkspaceAction::AdministratorsManage && !grant.rights.commercial {
return denied(action.as_str(), DenialReason::CommercialEntitlementRequired);
}
Decision {
action: action.as_str().to_string(),
allowed,
source: allowed.then_some(if role.is_some() {
DecisionSource::WorkspaceMember
} else if *action == WorkspaceAction::Preview {
DecisionSource::WorkspacePreviewPolicy
} else {
DecisionSource::WorkspacePolicy
}),
denial_reason: None,
}
}
fn workspace_role_allows(role: WorkspaceRole, action: WorkspaceAction) -> bool {
let minimum = match action {
WorkspaceAction::Read | WorkspaceAction::OrganizeRead | WorkspaceAction::PropertiesRead => WorkspaceRole::External,
WorkspaceAction::Sync
| WorkspaceAction::CreateDoc
| WorkspaceAction::UsersRead
| WorkspaceAction::SettingsRead
| WorkspaceAction::BlobsUpload
| WorkspaceAction::Copilot => WorkspaceRole::Member,
WorkspaceAction::UsersManage
| WorkspaceAction::AdministratorsManage
| WorkspaceAction::SettingsUpdate
| WorkspaceAction::PropertiesCreate
| WorkspaceAction::PropertiesUpdate
| WorkspaceAction::PropertiesDelete
| WorkspaceAction::BlobsManage => WorkspaceRole::Admin,
WorkspaceAction::Delete | WorkspaceAction::TransferOwner | WorkspaceAction::PaymentManage => WorkspaceRole::Owner,
WorkspaceAction::Preview => return false,
};
role >= minimum
}
fn doc_role(
doc: &super::DocAclFacts,
workspace_role: Option<WorkspaceRole>,
facts: &AclFacts,
) -> (Option<DocRole>, Option<DecisionSource>) {
let mut candidates = Vec::new();
match workspace_role {
Some(WorkspaceRole::Owner) => candidates.push((DocRole::Owner, DecisionSource::InheritedWorkspaceRole)),
Some(WorkspaceRole::Admin) => candidates.push((DocRole::Manager, DecisionSource::InheritedWorkspaceRole)),
_ => {}
}
if let Some(mut role) = doc.explicit_user_role.filter(|role| *role != DocRole::None) {
if !facts.active_member {
role = role.min(DocRole::Editor);
}
if facts.active_member || doc.sharing_enabled {
candidates.push((role, DecisionSource::DocGrant));
}
} else if facts.active_member {
if let Some(role) = doc.member_default_role.filter(|role| *role != DocRole::None) {
candidates.push((role, DecisionSource::MemberDefaultPolicy));
}
}
if doc.sharing_enabled && doc.visibility_public {
if let Some(role) = doc.public_role.filter(|role| *role != DocRole::None) {
candidates.push((role, DecisionSource::PublicPolicy));
}
}
candidates
.into_iter()
.max_by_key(|(role, _)| *role)
.map_or((None, None), |(role, source)| (Some(role), Some(source)))
}
fn doc_decision(
requested: &RequestedDocAction,
role: Option<DocRole>,
source: Option<DecisionSource>,
assigned_workspace_role: Option<WorkspaceRole>,
facts: &AclFacts,
grant: &AccessGrant,
doc: Option<&super::DocAclFacts>,
preview: super::DocPreviewDecision,
) -> Decision {
let RequestedDocAction::Known(action) = requested else {
return denied(requested.as_str(), DenialReason::UnknownAction);
};
let doc_read = role.is_some_and(|role| role >= DocRole::External);
if *action == DocAction::HistoryRead && (!facts.active_member || !doc_read) {
return denied(action.as_str(), DenialReason::ActiveMemberRequired);
}
if *action == DocAction::AnalyticsRead && (!facts.active_member || !grant.rights.commercial) {
return denied(action.as_str(), DenialReason::CommercialEntitlementRequired);
}
if *action == DocAction::AnalyticsViewersRead {
let privileged = facts.active_member
&& matches!(
assigned_workspace_role,
Some(WorkspaceRole::Admin | WorkspaceRole::Owner)
);
return if privileged && grant.rights.commercial && doc_read {
Decision {
action: action.as_str().to_string(),
allowed: true,
source: Some(DecisionSource::WorkspaceMember),
denial_reason: None,
}
} else {
denied(action.as_str(), DenialReason::WorkspaceAdminRequired)
};
}
if *action == DocAction::Publish && doc.is_some_and(|doc| !doc.sharing_enabled) {
return denied(action.as_str(), DenialReason::SharingDisabled);
}
if *action == DocAction::Preview {
return Decision {
action: action.as_str().to_string(),
allowed: preview.exposure.is_allowed(),
source: preview
.exposure
.is_allowed()
.then_some(DecisionSource::DocPreviewPolicy),
denial_reason: None,
};
}
let allowed = role.is_some_and(|role| doc_role_allows(role, *action));
Decision {
action: action.as_str().to_string(),
allowed,
source: allowed.then_some(source.unwrap_or(DecisionSource::DocGrant)),
denial_reason: None,
}
}
pub fn doc_role_allows(role: DocRole, action: DocAction) -> bool {
let minimum = match action {
DocAction::Read | DocAction::Copy | DocAction::PropertiesRead | DocAction::CommentsRead => DocRole::External,
DocAction::UsersRead | DocAction::Duplicate | DocAction::HistoryRead => DocRole::Reader,
DocAction::CommentsCreate => DocRole::Commenter,
DocAction::PropertiesUpdate | DocAction::Update | DocAction::AnalyticsRead | DocAction::CommentsModerate => {
DocRole::Editor
}
DocAction::Publish
| DocAction::Unpublish
| DocAction::Trash
| DocAction::Restore
| DocAction::Delete
| DocAction::UsersManage => DocRole::Manager,
DocAction::TransferOwner => DocRole::Owner,
DocAction::AnalyticsViewersRead | DocAction::Preview => return false,
};
role >= minimum
}
fn missing_workspace(request: &AuthorizationRequest) -> AuthorizationDecision {
AuthorizationDecision {
effective_workspace_role: None,
workspace_preview: None,
workspace: request
.workspace_actions
.iter()
.map(|action| denied(action.as_str(), DenialReason::WorkspaceNotFound))
.collect(),
docs: request
.doc_actions
.iter()
.map(|doc| DocAuthorizationDecision {
doc_id: doc.doc_id.clone(),
effective_role: None,
preview: None,
decisions: doc
.actions
.iter()
.map(|action| denied(action.as_str(), DenialReason::WorkspaceNotFound))
.collect(),
})
.collect(),
}
}
fn denied(action: &str, reason: DenialReason) -> Decision {
Decision {
action: action.to_string(),
allowed: false,
source: None,
denial_reason: Some(reason),
}
}
#[cfg(test)]
#[path = "../tests/access_control/permission/tests.rs"]
mod tests;