feat: additional access control config

This commit is contained in:
khorshuheng 2024-10-15 14:13:47 +08:00
parent 1fd57b055d
commit 34a7fd3633
11 changed files with 196 additions and 93 deletions

View file

@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH request_id_workspace_member_count AS (\n SELECT\n request_id,\n COUNT(*) AS member_count\n FROM af_access_request\n JOIN af_workspace_member USING (workspace_id)\n WHERE request_id = $1\n GROUP BY request_id\n )\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n owner_profile.email,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon,\n request_id_workspace_member_count.member_count\n ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n (\n af_user.uuid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN request_id_workspace_member_count USING (request_id)\n WHERE request_id = $1\n ",
"query": "\n WITH request_id_workspace_member_count AS (\n SELECT\n request_id,\n COUNT(*) AS member_count\n FROM af_access_request\n JOIN af_workspace_member USING (workspace_id)\n WHERE request_id = $1\n GROUP BY request_id\n )\n SELECT\n request_id,\n view_id,\n (\n workspace_id,\n af_workspace.database_storage_id,\n af_workspace.owner_uid,\n owner_profile.name,\n owner_profile.email,\n af_workspace.created_at,\n af_workspace.workspace_type,\n af_workspace.deleted_at,\n af_workspace.workspace_name,\n af_workspace.icon,\n request_id_workspace_member_count.member_count\n ) AS \"workspace!: AFWorkspaceWithMemberCountRow\",\n (\n af_user.uid,\n af_user.uuid,\n af_user.name,\n af_user.email,\n af_user.metadata ->> 'icon_url'\n ) AS \"requester!: AFAccessRequesterColumn\",\n status AS \"status: AFAccessRequestStatusColumn\",\n af_access_request.created_at AS created_at\n FROM af_access_request\n JOIN af_user USING (uid)\n JOIN af_workspace USING (workspace_id)\n JOIN af_user AS owner_profile ON af_workspace.owner_uid = owner_profile.uid\n JOIN request_id_workspace_member_count USING (request_id)\n WHERE request_id = $1\n ",
"describe": {
"columns": [
{
@ -48,5 +48,5 @@
false
]
},
"hash": "0c3ae560880e82218d13c5992540386ea1566e45e31acd5fb51886aabcd98479"
"hash": "67b381fdcd20f8cfe782d939e56bf94f105cdb23a59fefb846afe8105d91d129"
}

View file

@ -1397,6 +1397,7 @@ pub struct AccessRequestWithViewId {
#[derive(Serialize, Deserialize, Debug)]
pub struct AccessRequesterInfo {
pub uid: i64,
pub uuid: Uuid,
pub email: String,
pub name: String,

View file

@ -80,6 +80,7 @@ pub async fn select_access_request_by_request_id<'a, E: Executor<'a, Database =
request_id_workspace_member_count.member_count
) AS "workspace!: AFWorkspaceWithMemberCountRow",
(
af_user.uid,
af_user.uuid,
af_user.name,
af_user.email,

View file

@ -581,6 +581,7 @@ impl From<AFAccessRequestStatusColumn> for AccessRequestStatus {
#[derive(sqlx::Type, Serialize, Debug)]
pub struct AFAccessRequesterColumn {
pub uid: i64,
pub uuid: Uuid,
pub name: String,
pub email: String,
@ -590,6 +591,7 @@ pub struct AFAccessRequesterColumn {
impl From<AFAccessRequesterColumn> for AccessRequesterInfo {
fn from(value: AFAccessRequesterColumn) -> Self {
Self {
uid: value.uid,
uuid: value.uuid,
name: value.name,
email: value.email,

View file

@ -100,6 +100,7 @@ async fn post_approve_access_request_handler(
)))?;
approve_or_reject_access_request(
&state.pg_pool,
state.workspace_access_control.clone(),
state.mailer.clone(),
&appflowy_web_url,
access_request_id,

View file

@ -1,3 +1,4 @@
use access_control::act::Action;
use actix_web::web::{Bytes, Payload};
use actix_web::web::{Data, Json, PayloadConfig};
use actix_web::{web, Scope};
@ -262,11 +263,24 @@ async fn patch_workspace_handler(
}
async fn delete_workspace_handler(
_user_id: UserUuid,
user_uuid: UserUuid,
workspace_id: web::Path<Uuid>,
state: Data<AppState>,
) -> Result<Json<AppResponse<()>>> {
// TODO: add permission for workspace deletion
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_role(&uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "delete workspace".to_string(),
}
.into(),
);
}
workspace::ops::delete_workspace_for_user(
state.pg_pool.clone(),
*workspace_id,
@ -299,6 +313,21 @@ async fn post_workspace_invite_handler(
payload: Json<Vec<WorkspaceMemberInvitation>>,
state: Data<AppState>,
) -> Result<JsonAppResponse<()>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_role(&uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "invite workspace member".to_string(),
}
.into(),
);
}
let invited_members = payload.into_inner();
workspace::ops::invite_workspace_members(
&state.mailer,
@ -367,13 +396,20 @@ async fn get_workspace_settings_handler(
workspace_id: web::Path<Uuid>,
) -> Result<JsonAppResponse<AFWorkspaceSettings>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let settings = workspace::ops::get_workspace_settings(
&state.pg_pool,
state.workspace_access_control.clone(),
&workspace_id,
&uid,
)
.await?;
let has_access = state
.workspace_access_control
.enforce_action(&uid, &workspace_id.to_string(), Action::Read)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "read workspace setting".to_string(),
}
.into(),
);
}
let settings = workspace::ops::get_workspace_settings(&state.pg_pool, &workspace_id).await?;
Ok(AppResponse::Ok().with_data(settings).into())
}
@ -387,23 +423,44 @@ async fn post_workspace_settings_handler(
let data = data.into_inner();
trace!("workspace settings: {:?}", data);
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let settings = workspace::ops::update_workspace_settings(
&state.pg_pool,
state.workspace_access_control.clone(),
&workspace_id,
&uid,
data,
)
.await?;
let has_access = state
.workspace_access_control
.enforce_action(&uid, &workspace_id.to_string(), Action::Write)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "update workspace setting".to_string(),
}
.into(),
);
}
let settings =
workspace::ops::update_workspace_settings(&state.pg_pool, &workspace_id, data).await?;
Ok(AppResponse::Ok().with_data(settings).into())
}
#[instrument(skip_all, err)]
async fn get_workspace_members_handler(
_user_uuid: UserUuid,
user_uuid: UserUuid,
state: Data<AppState>,
workspace_id: web::Path<Uuid>,
) -> Result<JsonAppResponse<Vec<AFWorkspaceMember>>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_action(&uid, &workspace_id.to_string(), Action::Read)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "get workspace members".to_string(),
}
.into(),
);
}
let members = workspace::ops::get_workspace_members(&state.pg_pool, &workspace_id)
.await?
.into_iter()
@ -420,11 +477,26 @@ async fn get_workspace_members_handler(
#[instrument(skip_all, err)]
async fn remove_workspace_member_handler(
_user_uuid: UserUuid,
user_uuid: UserUuid,
payload: Json<WorkspaceMembers>,
state: Data<AppState>,
workspace_id: web::Path<Uuid>,
) -> Result<JsonAppResponse<()>> {
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_role(&uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "remove workspace member".to_string(),
}
.into(),
);
}
let member_emails = payload
.into_inner()
.0
@ -444,12 +516,28 @@ async fn remove_workspace_member_handler(
#[instrument(skip_all, err)]
async fn get_workspace_member_handler(
user_uuid: UserUuid,
state: Data<AppState>,
path: web::Path<(Uuid, i64)>,
) -> Result<JsonAppResponse<AFWorkspaceMember>> {
let (workspace_id, user_id) = path.into_inner();
let (workspace_id, user_uuid_to_retrieved) = path.into_inner();
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_action(&uid, &workspace_id.to_string(), Action::Read)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "get workspace member".to_string(),
}
.into(),
);
}
let member_row =
workspace::ops::get_workspace_member(&user_id, &state.pg_pool, &workspace_id).await?;
workspace::ops::get_workspace_member(&user_uuid_to_retrieved, &state.pg_pool, &workspace_id)
.await?;
let member = AFWorkspaceMember {
name: member_row.name,
email: member_row.email,
@ -490,21 +578,35 @@ async fn leave_workspace_handler(
#[instrument(level = "debug", skip_all, err)]
async fn update_workspace_member_handler(
user_uuid: UserUuid,
payload: Json<WorkspaceMemberChangeset>,
state: Data<AppState>,
workspace_id: web::Path<Uuid>,
) -> Result<JsonAppResponse<()>> {
// TODO: only owner is allowed to update member role
let workspace_id = workspace_id.into_inner();
let uid = state.user_cache.get_user_uid(&user_uuid).await?;
let has_access = state
.workspace_access_control
.enforce_role(&uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(
AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "update workspace member".to_string(),
}
.into(),
);
}
let changeset = payload.into_inner();
if changeset.role.is_some() {
let uid = select_uid_from_email(&state.pg_pool, &changeset.email)
let changeset_uid = select_uid_from_email(&state.pg_pool, &changeset.email)
.await
.map_err(AppResponseError::from)?;
workspace::ops::update_workspace_member(
&uid,
&changeset_uid,
&state.pg_pool,
&workspace_id,
&changeset,

View file

@ -165,8 +165,10 @@ pub async fn run_actix_server(
SessionMiddleware::builder(redis_store.clone(), key.clone())
.build(),
)
// .wrap(DecryptPayloadMiddleware)
.wrap(Condition::new(config.access_control.is_enabled, access_control.clone()))
.wrap(Condition::new(
config.access_control.is_enabled && config.access_control.enable_middleware,
access_control.clone())
)
.wrap(RequestIdMiddleware)
.service(server_info_scope())
.service(user_scope())
@ -270,7 +272,6 @@ pub async fn init_state(config: &Config, rt_cmd_tx: CLCommandSender) -> Result<A
info!("Setting up Pg listeners...");
let pg_listeners = Arc::new(PgListeners::new(&pg_pool).await?);
// let collab_member_listener = pg_listeners.subscribe_collab_member_change();
// let workspace_member_listener = pg_listeners.subscribe_workspace_member_change();
info!(
"Setting up access controls, is_enable: {}",
@ -279,31 +280,25 @@ pub async fn init_state(config: &Config, rt_cmd_tx: CLCommandSender) -> Result<A
let access_control =
AccessControl::new(pg_pool.clone(), metrics.access_control_metrics.clone()).await?;
// spawn_listen_on_workspace_member_change(workspace_member_listener, access_control.clone());
// spawn_listen_on_collab_member_change(
// pg_pool.clone(),
// collab_member_listener,
// access_control.clone(),
// );
let user_cache = UserCache::new(pg_pool.clone()).await;
let collab_access_control: Arc<dyn CollabAccessControl> = if config.access_control.is_enabled {
Arc::new(CollabAccessControlImpl::new(access_control.clone()))
} else {
Arc::new(NoOpsCollabAccessControlImpl::new())
};
let collab_access_control: Arc<dyn CollabAccessControl> =
if config.access_control.is_enabled && config.access_control.enable_collab_access_control {
Arc::new(CollabAccessControlImpl::new(access_control.clone()))
} else {
Arc::new(NoOpsCollabAccessControlImpl::new())
};
let workspace_access_control: Arc<dyn WorkspaceAccessControl> =
if config.access_control.is_enabled {
if config.access_control.is_enabled && config.access_control.enable_workspace_access_control {
Arc::new(WorkspaceAccessControlImpl::new(access_control.clone()))
} else {
Arc::new(NoOpsWorkspaceAccessControlImpl::new())
};
let realtime_access_control: Arc<dyn RealtimeAccessControl> = if config.access_control.is_enabled
{
Arc::new(RealtimeCollabAccessControlImpl::new(access_control))
} else {
Arc::new(NoOpsRealtimeCollabAccessControlImpl::new())
};
let realtime_access_control: Arc<dyn RealtimeAccessControl> =
if config.access_control.is_enabled && config.access_control.enable_realtime_access_control {
Arc::new(RealtimeCollabAccessControlImpl::new(access_control))
} else {
Arc::new(NoOpsRealtimeCollabAccessControlImpl::new())
};
let collab_cache = CollabCache::new(redis_conn_manager.clone(), pg_pool.clone());
let collab_storage_access_control = CollabStorageAccessControlImpl {

View file

@ -8,6 +8,7 @@ use crate::{
},
mailer::{WorkspaceAccessRequestApprovedMailerParam, WorkspaceAccessRequestMailerParam},
};
use access_control::workspace::WorkspaceAccessControl;
use anyhow::Context;
use app_error::AppError;
use appflowy_collaborate::collab::storage::CollabAccessControlStorage;
@ -116,8 +117,10 @@ pub async fn get_access_request(
Ok(access_request)
}
#[allow(clippy::too_many_arguments)]
pub async fn approve_or_reject_access_request(
pg_pool: &PgPool,
workspace_access_control: Arc<dyn WorkspaceAccessControl>,
mailer: AFCloudMailer,
appflowy_web_url: &str,
request_id: Uuid,
@ -126,7 +129,14 @@ pub async fn approve_or_reject_access_request(
is_approved: bool,
) -> Result<(), AppError> {
let access_request = select_access_request_by_request_id(pg_pool, request_id).await?;
if access_request.workspace.owner_uid != uid {
let has_access = workspace_access_control
.enforce_role(
&uid,
&access_request.workspace.workspace_id.to_string(),
AFRole::Owner,
)
.await?;
if !has_access {
return Err(AppError::NotEnoughPermissions {
user: user_uuid.to_string(),
action: "approve access request".to_string(),
@ -140,9 +150,16 @@ pub async fn approve_or_reject_access_request(
&mut txn,
&access_request.workspace.workspace_id,
&access_request.requester.email,
role,
role.clone(),
)
.await?;
workspace_access_control
.insert_role(
&access_request.requester.uid,
&access_request.workspace.workspace_id,
role.clone(),
)
.await?;
let cloned_mailer = mailer.clone();
let launch_workspace_url = format!(
"{}/app/{}",

View file

@ -13,7 +13,7 @@ use tracing::instrument;
use uuid::Uuid;
use access_control::workspace::WorkspaceAccessControl;
use app_error::{AppError, ErrorCode};
use app_error::AppError;
use appflowy_collaborate::collab::storage::CollabAccessControlStorage;
use database::collab::upsert_collab_member_with_txn;
use database::file::s3_client_impl::S3BucketStorage;
@ -611,43 +611,17 @@ pub async fn get_workspace_document_total_bytes(
pub async fn get_workspace_settings(
pg_pool: &PgPool,
workspace_access_control: Arc<dyn WorkspaceAccessControl>,
workspace_id: &Uuid,
owner_uid: &i64,
) -> Result<AFWorkspaceSettings, AppResponseError> {
let has_access = workspace_access_control
.enforce_role(owner_uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(AppResponseError::new(
ErrorCode::UserUnAuthorized,
"Only workspace owner can access workspace settings",
));
}
let settings = select_workspace_settings(pg_pool, workspace_id).await?;
Ok(settings.unwrap_or_default())
}
pub async fn update_workspace_settings(
pg_pool: &PgPool,
workspace_access_control: Arc<dyn WorkspaceAccessControl>,
workspace_id: &Uuid,
owner_uid: &i64,
change: AFWorkspaceSettingsChange,
) -> Result<AFWorkspaceSettings, AppResponseError> {
let has_access = workspace_access_control
.enforce_role(owner_uid, &workspace_id.to_string(), AFRole::Owner)
.await?;
if !has_access {
return Err(AppResponseError::new(
ErrorCode::UserUnAuthorized,
"Only workspace owner can edit workspace settings",
));
}
let mut tx = pg_pool.begin().await?;
let mut setting = select_workspace_settings(tx.deref_mut(), workspace_id)
.await?

View file

@ -33,6 +33,10 @@ pub struct Config {
pub struct AccessControlSetting {
pub is_enabled: bool,
pub enable_middleware: bool,
pub enable_workspace_access_control: bool,
pub enable_collab_access_control: bool,
pub enable_realtime_access_control: bool,
}
#[derive(serde::Deserialize, Clone, Debug)]
@ -176,6 +180,18 @@ pub fn get_configuration() -> Result<Config, anyhow::Error> {
is_enabled: get_env_var("APPFLOWY_ACCESS_CONTROL", "false")
.parse()
.context("fail to get APPFLOWY_ACCESS_CONTROL")?,
enable_middleware: get_env_var("APPFLOWY_ACCESS_CONTROL_MIDDLEWARE", "true")
.parse()
.context("fail to get APPFLOWY_ACCESS_CONTROL_MIDDLEWARE")?,
enable_workspace_access_control: get_env_var("APPFLOWY_ACCESS_CONTROL_WORKSPACE", "true")
.parse()
.context("fail to get APPFLOWY_ACCESS_CONTROL_WORKSPACE")?,
enable_collab_access_control: get_env_var("APPFLOWY_ACCESS_CONTROL_COLLAB", "true")
.parse()
.context("fail to get APPFLOWY_ACCESS_CONTROL_COLLAB")?,
enable_realtime_access_control: get_env_var("APPFLOWY_ACCESS_CONTROL_REALTIME", "true")
.parse()
.context("fail to get APPFLOWY_ACCESS_CONTROL_REALTIME")?,
},
db_settings: DatabaseSetting {
pg_conn_opts: PgConnectOptions::from_str(&get_env_var(

View file

@ -1,4 +1,3 @@
use app_error::ErrorCode;
use client_api::Client;
use client_api_test::generate_unique_registered_user_client;
use database_entity::dto::{AFRole, AFWorkspaceInvitationStatus, AFWorkspaceSettingsChange};
@ -31,6 +30,9 @@ async fn get_and_set_workspace_by_owner() {
#[tokio::test]
async fn get_and_set_workspace_by_non_owner() {
// TODO: currently, workspace settings contains only AI preference, which is
// better suited as a user setting. Meanwhile, we can permit workspace members
// to view the settings.
let (alice_client, _alice) = generate_unique_registered_user_client().await;
let workspaces = alice_client.get_workspaces().await.unwrap();
let alice_workspace_id = workspaces.first().unwrap().workspace_id;
@ -39,26 +41,18 @@ async fn get_and_set_workspace_by_non_owner() {
invite_user_to_workspace(&alice_workspace_id, &alice_client, &bob_client, &bob.email).await;
let resp = bob_client
bob_client
.get_workspace_settings(&alice_workspace_id.to_string())
.await;
assert!(
resp.is_err(),
"non-owner should not have access to workspace settings"
);
assert_eq!(resp.err().unwrap().code, ErrorCode::UserUnAuthorized);
.await
.unwrap();
let resp = bob_client
bob_client
.update_workspace_settings(
&alice_workspace_id.to_string(),
&AFWorkspaceSettingsChange::new().disable_search_indexing(true),
)
.await;
assert!(
resp.is_err(),
"non-owner should not be able to edit workspace settings"
);
assert_eq!(resp.err().unwrap().code, ErrorCode::UserUnAuthorized);
.await
.unwrap();
}
async fn invite_user_to_workspace(