469 lines
13 KiB
Rust
469 lines
13 KiB
Rust
use std::{str::FromStr, sync::Arc};
|
|
|
|
use actix_web::http::Method;
|
|
use anyhow::anyhow;
|
|
use async_trait::async_trait;
|
|
use casbin::{CoreApi, MgmtApi};
|
|
use database::user::select_uid_from_uuid;
|
|
use sqlx::PgPool;
|
|
use tokio::sync::{broadcast, RwLock};
|
|
use tracing::log::warn;
|
|
use tracing::{error, instrument};
|
|
|
|
use uuid::Uuid;
|
|
|
|
use crate::biz::{
|
|
collab::member_listener::{CollabMemberAction, CollabMemberNotification},
|
|
workspace::{
|
|
access_control::WorkspaceAccessControl,
|
|
member_listener::{WorkspaceMemberAction, WorkspaceMemberNotification},
|
|
},
|
|
};
|
|
use app_error::AppError;
|
|
use database::workspace::select_permission;
|
|
use database_entity::dto::{AFAccessLevel, AFCollabMember, AFRole};
|
|
|
|
use crate::biz::casbin::enforcer_ext::{enforcer_remove, enforcer_update};
|
|
use realtime::collaborate::{CollabAccessControl, CollabUserId};
|
|
|
|
use super::{
|
|
Action, ActionType, ObjectType, POLICY_FIELD_INDEX_ACTION, POLICY_FIELD_INDEX_OBJECT,
|
|
POLICY_FIELD_INDEX_USER,
|
|
};
|
|
|
|
/// Manages access control using Casbin.
|
|
///
|
|
/// Stores access control policies in the form `subject, object, role`
|
|
/// where `subject` is `uid`, `object` is `oid`, and `role` is [AFAccessLevel] or [AFRole].
|
|
///
|
|
/// Roles are mapped to the corresponding actions that they are allowed to perform.
|
|
/// `FullAccess` has write
|
|
/// `FullAccess` has read
|
|
///
|
|
/// Access control requests are made in the form `subject, object, action`
|
|
/// and will be evaluated against the policies and mappings stored,
|
|
/// according to the model defined.
|
|
pub struct CasbinAccessControl {
|
|
pg_pool: PgPool,
|
|
enforcer: Arc<RwLock<casbin::Enforcer>>,
|
|
}
|
|
|
|
impl Clone for CasbinAccessControl {
|
|
fn clone(&self) -> Self {
|
|
Self {
|
|
pg_pool: self.pg_pool.clone(),
|
|
enforcer: Arc::clone(&self.enforcer),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl CasbinAccessControl {
|
|
pub fn new(
|
|
pg_pool: PgPool,
|
|
collab_listener: broadcast::Receiver<CollabMemberNotification>,
|
|
workspace_listener: broadcast::Receiver<WorkspaceMemberNotification>,
|
|
enforcer: casbin::Enforcer,
|
|
) -> Self {
|
|
let enforcer = Arc::new(RwLock::new(enforcer));
|
|
spawn_listen_on_workspace_member_change(workspace_listener, enforcer.clone());
|
|
spawn_listen_on_collab_member_change(pg_pool.clone(), collab_listener, enforcer.clone());
|
|
Self { pg_pool, enforcer }
|
|
}
|
|
pub fn new_collab_access_control(&self) -> CasbinCollabAccessControl {
|
|
CasbinCollabAccessControl {
|
|
casbin_access_control: self.clone(),
|
|
}
|
|
}
|
|
|
|
pub fn new_workspace_access_control(&self) -> CasbinWorkspaceAccessControl {
|
|
CasbinWorkspaceAccessControl {
|
|
casbin_access_control: self.clone(),
|
|
}
|
|
}
|
|
|
|
/// Only expose this method for testing
|
|
#[cfg(debug_assertions)]
|
|
pub fn get_enforcer(&self) -> &Arc<RwLock<casbin::Enforcer>> {
|
|
&self.enforcer
|
|
}
|
|
|
|
pub async fn update(
|
|
&self,
|
|
uid: &i64,
|
|
obj: &ObjectType<'_>,
|
|
act: &ActionType,
|
|
) -> Result<bool, AppError> {
|
|
enforcer_update(&self.enforcer, uid, obj, act).await
|
|
}
|
|
|
|
pub async fn remove(&self, uid: &i64, obj: &ObjectType<'_>) -> Result<bool, AppError> {
|
|
let mut enforcer = self.enforcer.write().await;
|
|
enforcer_remove(&mut enforcer, uid, obj).await
|
|
}
|
|
|
|
/// Get uid which is used for all operations.
|
|
async fn get_uid(&self, user: &CollabUserId<'_>) -> Result<i64, AppError> {
|
|
let uid = match user {
|
|
CollabUserId::UserId(uid) => **uid,
|
|
CollabUserId::UserUuid(uuid) => select_uid_from_uuid(&self.pg_pool, uuid).await?,
|
|
};
|
|
Ok(uid)
|
|
}
|
|
|
|
async fn get_collab_member(&self, uid: &i64, oid: &str) -> Result<AFCollabMember, AppError> {
|
|
database::collab::select_collab_member(uid, oid, &self.pg_pool).await
|
|
}
|
|
|
|
async fn get_workspace_member_role(
|
|
&self,
|
|
uid: &i64,
|
|
workspace_id: &Uuid,
|
|
) -> Result<AFRole, AppError> {
|
|
database::workspace::select_workspace_member(&self.pg_pool, uid, workspace_id)
|
|
.await
|
|
.map(|r| r.role)
|
|
}
|
|
}
|
|
|
|
fn spawn_listen_on_collab_member_change(
|
|
pg_pool: PgPool,
|
|
mut listener: broadcast::Receiver<CollabMemberNotification>,
|
|
enforcer: Arc<RwLock<casbin::Enforcer>>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
while let Ok(change) = listener.recv().await {
|
|
match change.action_type {
|
|
CollabMemberAction::INSERT | CollabMemberAction::UPDATE => {
|
|
if let Some(member_row) = change.new {
|
|
if let Ok(Some(row)) = select_permission(&pg_pool, &member_row.permission_id).await {
|
|
if let Err(err) = enforcer_update(
|
|
&enforcer,
|
|
&member_row.uid,
|
|
&ObjectType::Collab(&member_row.oid),
|
|
&ActionType::Level(row.access_level),
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"Failed to update the user:{} collab{} access control, error: {}",
|
|
member_row.uid, member_row.oid, err
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
warn!("The new collab member is None")
|
|
}
|
|
},
|
|
CollabMemberAction::DELETE => {
|
|
if let (Some(oid), Some(uid)) = (change.old_oid(), change.old_uid()) {
|
|
let mut enforcer = enforcer.write().await;
|
|
if let Err(err) = enforcer_remove(&mut enforcer, uid, &ObjectType::Collab(oid)).await {
|
|
warn!(
|
|
"Failed to remove the user:{} collab{} access control, error: {}",
|
|
uid, oid, err
|
|
);
|
|
}
|
|
} else {
|
|
warn!("The oid or uid is None")
|
|
}
|
|
},
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
fn spawn_listen_on_workspace_member_change(
|
|
mut listener: broadcast::Receiver<WorkspaceMemberNotification>,
|
|
enforcer: Arc<RwLock<casbin::Enforcer>>,
|
|
) {
|
|
tokio::spawn(async move {
|
|
while let Ok(change) = listener.recv().await {
|
|
match change.action_type {
|
|
WorkspaceMemberAction::INSERT | WorkspaceMemberAction::UPDATE => match change.new {
|
|
None => {
|
|
warn!("The workspace member change can't be None when the action is INSERT or UPDATE")
|
|
},
|
|
Some(member_row) => {
|
|
if let Err(err) = enforcer_update(
|
|
&enforcer,
|
|
&member_row.uid,
|
|
&ObjectType::Workspace(&member_row.workspace_id.to_string()),
|
|
&ActionType::Role(AFRole::from(member_row.role_id)),
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"Failed to update the user:{} workspace:{} access control, error: {}",
|
|
member_row.uid, member_row.workspace_id, err
|
|
);
|
|
}
|
|
},
|
|
},
|
|
WorkspaceMemberAction::DELETE => match change.old {
|
|
None => warn!("The workspace member change can't be None when the action is DELETE"),
|
|
Some(member_row) => {
|
|
let mut enforcer = enforcer.write().await;
|
|
if let Err(err) = enforcer_remove(
|
|
&mut enforcer,
|
|
&member_row.uid,
|
|
&ObjectType::Workspace(&member_row.workspace_id.to_string()),
|
|
)
|
|
.await
|
|
{
|
|
warn!(
|
|
"Failed to remove the user:{} workspace: {} access control, error: {}",
|
|
member_row.uid, member_row.workspace_id, err
|
|
);
|
|
}
|
|
},
|
|
},
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CasbinCollabAccessControl {
|
|
casbin_access_control: CasbinAccessControl,
|
|
}
|
|
|
|
impl CasbinCollabAccessControl {
|
|
#[instrument(level = "trace", skip_all)]
|
|
pub async fn update_member(&self, uid: &i64, oid: &str, access_level: AFAccessLevel) {
|
|
let _ = self
|
|
.casbin_access_control
|
|
.update(
|
|
uid,
|
|
&ObjectType::Collab(oid),
|
|
&ActionType::Level(access_level),
|
|
)
|
|
.await;
|
|
}
|
|
pub async fn remove_member(&self, uid: &i64, oid: &str) {
|
|
let _ = self
|
|
.casbin_access_control
|
|
.remove(uid, &ObjectType::Collab(oid))
|
|
.await;
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl CollabAccessControl for CasbinCollabAccessControl {
|
|
async fn get_collab_access_level(
|
|
&self,
|
|
user: CollabUserId<'_>,
|
|
oid: &str,
|
|
) -> Result<AFAccessLevel, AppError> {
|
|
let uid = self.casbin_access_control.get_uid(&user).await?;
|
|
let collab_id = ObjectType::Collab(oid).to_string();
|
|
let policies = self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.get_filtered_policy(POLICY_FIELD_INDEX_OBJECT, vec![collab_id]);
|
|
|
|
// There should only be one entry per user per object, which is enforced in [CasbinAccessControl], so just take one using next.
|
|
let mut access_level = policies
|
|
.into_iter()
|
|
.find(|p| p[POLICY_FIELD_INDEX_USER] == uid.to_string())
|
|
.map(|p| p[POLICY_FIELD_INDEX_ACTION].clone())
|
|
.and_then(|s| i32::from_str(s.as_str()).ok())
|
|
.map(AFAccessLevel::from);
|
|
|
|
if access_level.is_none() {
|
|
match self
|
|
.casbin_access_control
|
|
.get_collab_member(&uid, oid)
|
|
.await
|
|
{
|
|
Ok(member) => {
|
|
access_level = Some(member.permission.access_level);
|
|
self
|
|
.casbin_access_control
|
|
.update(
|
|
&uid,
|
|
&ObjectType::Collab(oid),
|
|
&ActionType::Level(member.permission.access_level),
|
|
)
|
|
.await?;
|
|
},
|
|
Err(err) => {
|
|
error!(
|
|
"Failed to get member:{} in collab:{}, error: {}",
|
|
uid, oid, err
|
|
);
|
|
},
|
|
}
|
|
}
|
|
|
|
access_level.ok_or(AppError::RecordNotFound(format!(
|
|
"user:{} is not a member of collab:{}",
|
|
uid, oid
|
|
)))
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
async fn cache_collab_access_level(
|
|
&self,
|
|
user: CollabUserId<'_>,
|
|
oid: &str,
|
|
level: AFAccessLevel,
|
|
) -> Result<(), AppError> {
|
|
let uid = self.casbin_access_control.get_uid(&user).await?;
|
|
self
|
|
.casbin_access_control
|
|
.update(&uid, &ObjectType::Collab(oid), &ActionType::Level(level))
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn can_access_http_method(
|
|
&self,
|
|
user: CollabUserId<'_>,
|
|
oid: &str,
|
|
method: &Method,
|
|
) -> Result<bool, AppError> {
|
|
let uid = self.casbin_access_control.get_uid(&user).await?;
|
|
let action = if Method::POST == method || Method::PUT == method || Method::DELETE == method {
|
|
Action::Write
|
|
} else {
|
|
Action::Read
|
|
};
|
|
|
|
// If collab does not exist, allow access.
|
|
// Workspace access control will still check it.
|
|
let collab_exists = self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.get_all_objects()
|
|
.contains(&ObjectType::Collab(oid).to_string());
|
|
|
|
if !collab_exists {
|
|
return Ok(true);
|
|
}
|
|
|
|
self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.enforce((
|
|
uid.to_string(),
|
|
ObjectType::Collab(oid).to_string(),
|
|
action.to_string(),
|
|
))
|
|
.map_err(|e| AppError::Internal(anyhow!("casbin error enforce: {e:?}")))
|
|
}
|
|
|
|
async fn can_send_collab_update(&self, uid: &i64, oid: &str) -> Result<bool, AppError> {
|
|
self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.enforce((
|
|
uid.to_string(),
|
|
ObjectType::Collab(oid).to_string(),
|
|
Action::Write.to_string(),
|
|
))
|
|
.map_err(|e| AppError::Internal(anyhow!("casbin error enforce: {e:?}")))
|
|
}
|
|
|
|
async fn can_receive_collab_update(&self, uid: &i64, oid: &str) -> Result<bool, AppError> {
|
|
self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.enforce((
|
|
uid.to_string(),
|
|
ObjectType::Collab(oid).to_string(),
|
|
Action::Read.to_string(),
|
|
))
|
|
.map_err(|e| AppError::Internal(anyhow!("casbin error enforce: {e:?}")))
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct CasbinWorkspaceAccessControl {
|
|
casbin_access_control: CasbinAccessControl,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl WorkspaceAccessControl for CasbinWorkspaceAccessControl {
|
|
async fn get_role_from_uuid(
|
|
&self,
|
|
user_uuid: &Uuid,
|
|
workspace_id: &Uuid,
|
|
) -> Result<AFRole, AppError> {
|
|
let uid = self
|
|
.casbin_access_control
|
|
.get_uid(&CollabUserId::UserUuid(user_uuid))
|
|
.await?;
|
|
self.get_role_from_uid(&uid, workspace_id).await
|
|
}
|
|
|
|
async fn get_role_from_uid(&self, uid: &i64, workspace_id: &Uuid) -> Result<AFRole, AppError> {
|
|
let policies = self
|
|
.casbin_access_control
|
|
.enforcer
|
|
.read()
|
|
.await
|
|
.get_filtered_policy(
|
|
POLICY_FIELD_INDEX_OBJECT,
|
|
vec![ObjectType::Workspace(&workspace_id.to_string()).to_string()],
|
|
);
|
|
|
|
let role = match policies
|
|
.into_iter()
|
|
.find(|p| p[POLICY_FIELD_INDEX_USER] == uid.to_string())
|
|
{
|
|
Some(policy) => i32::from_str(policy[POLICY_FIELD_INDEX_ACTION].as_str())
|
|
.ok()
|
|
.map(AFRole::from),
|
|
None => self
|
|
.casbin_access_control
|
|
.get_workspace_member_role(uid, workspace_id)
|
|
.await
|
|
.ok(),
|
|
};
|
|
|
|
role.ok_or_else(|| {
|
|
AppError::NotEnoughPermissions(format!(
|
|
"user:{} is not a member of workspace:{}",
|
|
uid, workspace_id
|
|
))
|
|
})
|
|
}
|
|
|
|
#[instrument(level = "trace", skip_all)]
|
|
async fn update_member(
|
|
&self,
|
|
uid: &i64,
|
|
workspace_id: &Uuid,
|
|
role: AFRole,
|
|
) -> Result<(), AppError> {
|
|
let _ = self
|
|
.casbin_access_control
|
|
.update(
|
|
uid,
|
|
&ObjectType::Workspace(&workspace_id.to_string()),
|
|
&ActionType::Role(role),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn remove_member(&self, uid: &i64, workspace_id: &Uuid) -> Result<(), AppError> {
|
|
let _ = self
|
|
.casbin_access_control
|
|
.remove(uid, &ObjectType::Workspace(&workspace_id.to_string()))
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
}
|