use super::{
AccessGrant, AclFacts, AuthorizationRequest, DocAction, DocAuthorizationRequest, DocRole, QuotaUsage,
RequestedDocAction, RequestedWorkspaceAction, WorkspaceAction, WorkspaceRole, authorize, evaluate_workspace_quota,
};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandEffect {
Expansion,
Contraction,
Recovery,
Neutral,
}
impl CommandEffect {
pub const fn requires_quota_guard(self) -> bool {
matches!(self, Self::Expansion | Self::Neutral)
}
fn from_workspace_roles(current: Option<WorkspaceRole>, next: WorkspaceRole) -> Self {
role_change_effect(current, Some(next))
}
fn from_doc_roles(current: Option<DocRole>, next: Option<DocRole>) -> Self {
role_change_effect(current, next)
}
}
#[derive(Clone, Copy, Debug)]
pub struct CommandQuotaFacts<'a> {
pub grant: &'a AccessGrant,
pub usage: QuotaUsage,
}
#[derive(Clone, Copy, Debug)]
pub struct CommandFacts<'a> {
pub acl: &'a AclFacts,
pub access_grant: &'a AccessGrant,
pub quota: Option<CommandQuotaFacts<'a>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum DocLifecycleCommand {
Trash,
Restore,
Delete,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DomainCommand {
CreateDoc,
UpdateDoc {
doc_id: String,
},
CreateComment {
doc_id: String,
},
MutateComment {
doc_id: String,
authored_by_actor: bool,
delete: bool,
},
CreateReply {
doc_id: String,
},
MutateReply {
doc_id: String,
authored_by_actor: bool,
delete: bool,
},
PublishDoc {
doc_id: String,
},
UnpublishDoc {
doc_id: String,
},
ApplyDocLifecycle {
doc_id: String,
lifecycle: DocLifecycleCommand,
},
AppendRootUpdate {
doc_id: String,
},
RecoverDoc {
doc_id: String,
},
TransitionWorkspaceRole {
current_role: Option<WorkspaceRole>,
new_role: WorkspaceRole,
target_active_member: bool,
},
TransitionDocRole {
doc_id: String,
current_role: Option<DocRole>,
new_role: Option<DocRole>,
target_active_member: bool,
},
GrantDocRoles {
doc_id: String,
current_roles: Vec<Option<DocRole>>,
new_role: DocRole,
},
SetDocDefaultRole {
doc_id: String,
current_role: DocRole,
new_role: DocRole,
},
RevokeWorkspaceMember {
target_role: WorkspaceRole,
actor_is_target: bool,
},
LeaveWorkspace,
}
impl DomainCommand {
pub fn effect(&self) -> CommandEffect {
command_effect(self)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandPermission {
Workspace(WorkspaceAction),
Doc(DocAction),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandDenialReason {
Permission(CommandPermission),
WorkspaceNotFound,
WorkspaceReadonly,
QuotaFactsUnavailable,
ActiveMemberRequired,
WorkspaceOwnerCannotLeave,
CannotRevokeSelf,
OwnerMustTransfer,
TargetMemberMustBeActive,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CommandConstraint {
PreviousOwnerFallback(WorkspaceRole),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommandAuthorizationDecision {
pub allowed: bool,
pub effect: CommandEffect,
pub denial_reason: Option<CommandDenialReason>,
pub constraint: Option<CommandConstraint>,
}
pub fn authorize_command(command: &DomainCommand, facts: CommandFacts<'_>) -> CommandAuthorizationDecision {
let effect = command.effect();
let (request, rule) = command_request(command);
let decision = authorize(&request, facts.acl, facts.access_grant);
let permission_allowed = match rule {
CommandRule::AllDoc => decision.docs[0].decisions.iter().all(|decision| decision.allowed),
CommandRule::CommentMutation { authored_by_actor } => {
let decisions = &decision.docs[0].decisions;
decisions[0].allowed && ((authored_by_actor && decisions[1].allowed) || decisions[2].allowed)
}
CommandRule::Workspace => {
decision.workspace[0].allowed
|| (decision.effective_workspace_role == Some(WorkspaceRole::Owner)
&& matches!(effect, CommandEffect::Contraction | CommandEffect::Recovery))
}
CommandRule::Leave => {
facts.acl.workspace_exists
&& facts.acl.active_member
&& facts.acl.assigned_workspace_role != Some(WorkspaceRole::Owner)
}
};
let invariant_denial = facts.acl.workspace_exists.then(|| command_invariant(command)).flatten();
let quota_denial = (permission_allowed && invariant_denial.is_none() && effect.requires_quota_guard())
.then(|| match facts.quota {
Some(quota)
if evaluate_workspace_quota(quota.grant, quota.usage)
.readonly_reasons
.is_empty() =>
{
None
}
Some(_) => Some(CommandDenialReason::WorkspaceReadonly),
None => Some(CommandDenialReason::QuotaFactsUnavailable),
})
.flatten();
let allowed = permission_allowed && invariant_denial.is_none() && quota_denial.is_none();
let denial_reason = if allowed {
None
} else if let Some(reason) = invariant_denial {
Some(reason)
} else if let Some(reason) = quota_denial {
Some(reason)
} else {
Some(match rule {
_ if !facts.acl.workspace_exists => CommandDenialReason::WorkspaceNotFound,
CommandRule::CommentMutation { authored_by_actor } => {
CommandDenialReason::Permission(denied_comment_action(&request, &decision, authored_by_actor))
}
CommandRule::Leave if !facts.acl.active_member => CommandDenialReason::ActiveMemberRequired,
CommandRule::Leave => CommandDenialReason::WorkspaceOwnerCannotLeave,
CommandRule::AllDoc => CommandDenialReason::Permission(denied_doc_action(&request, &decision)),
CommandRule::Workspace => CommandDenialReason::Permission(denied_workspace_action(&request, &decision)),
})
};
let constraint = if allowed
&& matches!(
command,
DomainCommand::TransitionWorkspaceRole {
new_role: WorkspaceRole::Owner,
..
}
) {
Some(CommandConstraint::PreviousOwnerFallback(
if decision.workspace.get(1).is_some_and(|decision| decision.allowed) {
WorkspaceRole::Admin
} else {
WorkspaceRole::Member
},
))
} else {
None
};
CommandAuthorizationDecision {
allowed,
effect,
denial_reason,
constraint,
}
}
fn command_invariant(command: &DomainCommand) -> Option<CommandDenialReason> {
match command {
DomainCommand::TransitionWorkspaceRole {
current_role: Some(WorkspaceRole::Owner),
new_role,
..
} if *new_role != WorkspaceRole::Owner => Some(CommandDenialReason::OwnerMustTransfer),
DomainCommand::TransitionWorkspaceRole {
new_role: WorkspaceRole::Owner,
target_active_member: false,
..
}
| DomainCommand::TransitionDocRole {
new_role: Some(DocRole::Owner),
target_active_member: false,
..
} => Some(CommandDenialReason::TargetMemberMustBeActive),
DomainCommand::TransitionDocRole {
current_role: Some(DocRole::Owner),
new_role,
..
} if *new_role != Some(DocRole::Owner) => Some(CommandDenialReason::OwnerMustTransfer),
DomainCommand::RevokeWorkspaceMember {
actor_is_target: true, ..
} => Some(CommandDenialReason::CannotRevokeSelf),
DomainCommand::RevokeWorkspaceMember {
target_role: WorkspaceRole::Owner,
..
} => Some(CommandDenialReason::OwnerMustTransfer),
_ => None,
}
}
#[derive(Clone, Copy)]
enum CommandRule {
AllDoc,
CommentMutation { authored_by_actor: bool },
Workspace,
Leave,
}
fn denied_doc_action(request: &AuthorizationRequest, decision: &super::AuthorizationDecision) -> CommandPermission {
let index = decision.docs[0]
.decisions
.iter()
.position(|decision| !decision.allowed)
.expect("denied command has a denied document action");
let RequestedDocAction::Known(action) = request.doc_actions[0].actions[index] else {
unreachable!("domain commands only contain known document actions")
};
CommandPermission::Doc(action)
}
fn denied_comment_action(
request: &AuthorizationRequest,
decision: &super::AuthorizationDecision,
authored_by_actor: bool,
) -> CommandPermission {
let decisions = &decision.docs[0].decisions;
let index = if !decisions[0].allowed {
0
} else if authored_by_actor {
1
} else {
2
};
let RequestedDocAction::Known(action) = request.doc_actions[0].actions[index] else {
unreachable!("domain commands only contain known document actions")
};
CommandPermission::Doc(action)
}
fn denied_workspace_action(
request: &AuthorizationRequest,
decision: &super::AuthorizationDecision,
) -> CommandPermission {
let index = decision
.workspace
.iter()
.position(|decision| !decision.allowed)
.expect("denied command has a denied workspace action");
let RequestedWorkspaceAction::Known(action) = request.workspace_actions[index] else {
unreachable!("domain commands only contain known workspace actions")
};
CommandPermission::Workspace(action)
}
fn command_request(command: &DomainCommand) -> (AuthorizationRequest, CommandRule) {
let (workspace_actions, doc_id, doc_actions, rule) = match command {
DomainCommand::CreateDoc => (
vec![WorkspaceAction::CreateDoc],
None,
Vec::new(),
CommandRule::Workspace,
),
DomainCommand::UpdateDoc { doc_id } => (Vec::new(), Some(doc_id), vec![DocAction::Update], CommandRule::AllDoc),
DomainCommand::CreateComment { doc_id } | DomainCommand::CreateReply { doc_id } => (
Vec::new(),
Some(doc_id),
vec![DocAction::Read, DocAction::CommentsCreate],
CommandRule::AllDoc,
),
DomainCommand::MutateComment {
doc_id,
authored_by_actor,
..
}
| DomainCommand::MutateReply {
doc_id,
authored_by_actor,
..
} => (
Vec::new(),
Some(doc_id),
vec![DocAction::Read, DocAction::CommentsCreate, DocAction::CommentsModerate],
CommandRule::CommentMutation {
authored_by_actor: *authored_by_actor,
},
),
DomainCommand::PublishDoc { doc_id } => (Vec::new(), Some(doc_id), vec![DocAction::Publish], CommandRule::AllDoc),
DomainCommand::UnpublishDoc { doc_id } => (
Vec::new(),
Some(doc_id),
vec![DocAction::Unpublish],
CommandRule::AllDoc,
),
DomainCommand::ApplyDocLifecycle { doc_id, lifecycle } => (
Vec::new(),
Some(doc_id),
vec![match lifecycle {
DocLifecycleCommand::Trash => DocAction::Trash,
DocLifecycleCommand::Restore => DocAction::Restore,
DocLifecycleCommand::Delete => DocAction::Delete,
}],
CommandRule::AllDoc,
),
DomainCommand::AppendRootUpdate { doc_id } => {
(Vec::new(), Some(doc_id), vec![DocAction::Update], CommandRule::AllDoc)
}
DomainCommand::RecoverDoc { doc_id } => (
Vec::new(),
Some(doc_id),
vec![DocAction::HistoryRead, DocAction::Update],
CommandRule::AllDoc,
),
DomainCommand::TransitionWorkspaceRole { new_role, .. } => (
if *new_role == WorkspaceRole::Owner {
vec![WorkspaceAction::TransferOwner, WorkspaceAction::AdministratorsManage]
} else {
vec![WorkspaceAction::AdministratorsManage]
},
None,
Vec::new(),
CommandRule::Workspace,
),
DomainCommand::TransitionDocRole { doc_id, new_role, .. } => (
Vec::new(),
Some(doc_id),
vec![if *new_role == Some(DocRole::Owner) {
DocAction::TransferOwner
} else {
DocAction::UsersManage
}],
CommandRule::AllDoc,
),
DomainCommand::GrantDocRoles { doc_id, .. } | DomainCommand::SetDocDefaultRole { doc_id, .. } => (
Vec::new(),
Some(doc_id),
vec![DocAction::UsersManage],
CommandRule::AllDoc,
),
DomainCommand::RevokeWorkspaceMember { target_role, .. } => (
vec![if *target_role == WorkspaceRole::Admin {
WorkspaceAction::AdministratorsManage
} else {
WorkspaceAction::UsersManage
}],
None,
Vec::new(),
CommandRule::Workspace,
),
DomainCommand::LeaveWorkspace => (Vec::new(), None, Vec::new(), CommandRule::Leave),
};
(
AuthorizationRequest {
workspace_actions: workspace_actions
.into_iter()
.map(RequestedWorkspaceAction::from)
.collect(),
doc_actions: doc_id
.map(|doc_id| DocAuthorizationRequest {
doc_id: doc_id.clone(),
actions: doc_actions.into_iter().map(RequestedDocAction::from).collect(),
})
.into_iter()
.collect(),
},
rule,
)
}
fn command_effect(command: &DomainCommand) -> CommandEffect {
match command {
DomainCommand::CreateDoc
| DomainCommand::CreateComment { .. }
| DomainCommand::CreateReply { .. }
| DomainCommand::PublishDoc { .. } => CommandEffect::Expansion,
DomainCommand::UpdateDoc { .. }
| DomainCommand::MutateComment { delete: false, .. }
| DomainCommand::MutateReply { delete: false, .. }
| DomainCommand::RecoverDoc { .. }
| DomainCommand::AppendRootUpdate { .. }
| DomainCommand::ApplyDocLifecycle {
lifecycle: DocLifecycleCommand::Restore,
..
} => CommandEffect::Neutral,
DomainCommand::MutateComment { delete: true, .. }
| DomainCommand::MutateReply { delete: true, .. }
| DomainCommand::UnpublishDoc { .. }
| DomainCommand::ApplyDocLifecycle {
lifecycle: DocLifecycleCommand::Trash | DocLifecycleCommand::Delete,
..
}
| DomainCommand::RevokeWorkspaceMember { .. }
| DomainCommand::LeaveWorkspace => CommandEffect::Contraction,
DomainCommand::TransitionWorkspaceRole {
current_role, new_role, ..
} => CommandEffect::from_workspace_roles(*current_role, *new_role),
DomainCommand::TransitionDocRole {
current_role, new_role, ..
} => CommandEffect::from_doc_roles(*current_role, *new_role),
DomainCommand::GrantDocRoles {
current_roles,
new_role,
..
} => aggregate_role_effect(
current_roles
.iter()
.copied()
.map(|current| CommandEffect::from_doc_roles(current, Some(*new_role))),
),
DomainCommand::SetDocDefaultRole {
current_role, new_role, ..
} => CommandEffect::from_doc_roles(Some(*current_role), Some(*new_role)),
}
}
fn aggregate_role_effect(effects: impl IntoIterator<Item = CommandEffect>) -> CommandEffect {
let mut result = CommandEffect::Recovery;
for effect in effects {
match effect {
CommandEffect::Expansion => return CommandEffect::Expansion,
CommandEffect::Neutral => result = CommandEffect::Neutral,
CommandEffect::Contraction if result == CommandEffect::Recovery => result = CommandEffect::Contraction,
CommandEffect::Contraction | CommandEffect::Recovery => {}
}
}
result
}
fn role_change_effect<R: Ord>(current: Option<R>, next: Option<R>) -> CommandEffect {
match (current, next) {
(Some(current), Some(next)) if next > current => CommandEffect::Expansion,
(Some(current), Some(next)) if next < current => CommandEffect::Contraction,
(None, Some(_)) => CommandEffect::Expansion,
(Some(_), None) => CommandEffect::Recovery,
_ => CommandEffect::Neutral,
}
}
#[cfg(test)]
#[path = "../tests/access_control/command/tests.rs"]
mod tests;