diff --git a/rust/crates/mnote-web/src/provider_identity_sync.rs b/rust/crates/mnote-web/src/provider_identity_sync.rs index b0d9c7c4..f07859ff 100644 --- a/rust/crates/mnote-web/src/provider_identity_sync.rs +++ b/rust/crates/mnote-web/src/provider_identity_sync.rs @@ -1,5 +1,5 @@ use crate::error::WebError; -use control_plane::UserRecord; +use control_plane::{ControlPlaneStore, DirectoryGrantRecord, UserRecord}; use reqwest::header::{HeaderMap, HeaderValue}; use serde_json::{json, Value}; use std::collections::hash_map::DefaultHasher; @@ -96,6 +96,7 @@ fn sync_disabled() -> bool { pub async fn sync_provider_identities( user: &UserRecord, password: &str, + directory_grants: &[DirectoryGrantRecord], ) -> Vec { if sync_disabled() { return vec![ProviderIdentitySyncResult { @@ -112,12 +113,12 @@ pub async fn sync_provider_identities( match (sync_openhub, sync_weknora) { (true, true) => { let (openhub, weknora) = tokio::join!( - sync_openhub_identity(user, password), + sync_openhub_identity(user, password, directory_grants), sync_weknora_identity(user, password) ); vec![openhub, weknora] } - (true, false) => vec![sync_openhub_identity(user, password).await], + (true, false) => vec![sync_openhub_identity(user, password, directory_grants).await], (false, true) => vec![sync_weknora_identity(user, password).await], (false, false) => vec![ProviderIdentitySyncResult { provider: "all", @@ -128,6 +129,59 @@ pub async fn sync_provider_identities( } } +pub async fn sync_openhub_directory_permissions_for_user_id( + control_plane: &dyn ControlPlaneStore, + user_id: &str, +) -> ProviderIdentitySyncResult { + if sync_disabled() || !env_flag("MNOTE_PROVIDER_IDENTITY_SYNC_OPENHUB", true) { + return ProviderIdentitySyncResult { + provider: "openhub", + ok: true, + message: "provider identity sync disabled".to_string(), + provider_user_id: None, + }; + } + let user_id = user_id.trim(); + let provider_user_id = stable_openhub_user_id(user_id); + let directory_grants = match control_plane.list_directory_grants_for_actor(user_id) { + Ok(grants) => grants, + Err(error) => { + return ProviderIdentitySyncResult { + provider: "openhub", + ok: false, + message: format!("mnote directory grants read failed: {error}"), + provider_user_id: Some(provider_user_id.to_string()), + }; + } + }; + let payload = json!({ + "mnote_user_id": user_id, + "allowedRoots": openhub_allowed_roots_payload(&directory_grants), + }); + match post_json( + join_url( + &openhub_base_url(), + &format!("/api/internal/mnote/users/{provider_user_id}/directory-permissions/sync"), + ), + payload, + ) + .await + { + Ok(_) => ProviderIdentitySyncResult { + provider: "openhub", + ok: true, + message: "directory permissions synced".to_string(), + provider_user_id: Some(provider_user_id.to_string()), + }, + Err(error) => ProviderIdentitySyncResult { + provider: "openhub", + ok: false, + message: error.message().to_string(), + provider_user_id: Some(provider_user_id.to_string()), + }, + } +} + async fn post_json(url: String, body: Value) -> Result { let client = reqwest::Client::builder() .timeout(Duration::from_millis(PROVIDER_IDENTITY_SYNC_TIMEOUT_MS)) @@ -156,7 +210,11 @@ async fn post_json(url: String, body: Value) -> Result { Ok(payload) } -async fn sync_openhub_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult { +async fn sync_openhub_identity( + user: &UserRecord, + password: &str, + directory_grants: &[DirectoryGrantRecord], +) -> ProviderIdentitySyncResult { let provider_user_id = stable_openhub_user_id(&user.id); let payload = json!({ "provider_user_id": provider_user_id, @@ -165,6 +223,7 @@ async fn sync_openhub_identity(user: &UserRecord, password: &str) -> ProviderIde "email": fallback_email(user), "password": password, "workspace_path": default_workspace_path(user), + "allowedRoots": openhub_allowed_roots_payload(directory_grants), "disabled": user.status != "active", "is_admin": false, }); @@ -189,6 +248,36 @@ async fn sync_openhub_identity(user: &UserRecord, password: &str) -> ProviderIde } } +fn openhub_allowed_roots_payload(directory_grants: &[DirectoryGrantRecord]) -> Vec { + directory_grants + .iter() + .filter(|grant| !is_default_workspace_auto_grant(grant)) + .filter(|grant| grant.status.trim() == "active") + .filter(|grant| !grant.root_path.trim().is_empty()) + .map(|grant| { + json!({ + "grantId": grant.id, + "rootUri": grant.root_uri, + "rootPath": grant.root_path, + "permission": grant.permission, + "recursive": grant.recursive, + "capabilities": serde_json::from_str::>(&grant.capabilities_json).unwrap_or_default(), + "source": grant.source, + }) + }) + .collect() +} + +fn is_default_workspace_auto_grant(grant: &DirectoryGrantRecord) -> bool { + grant.source.trim() == "auto" + && grant.permission.trim() == "write" + && grant.recursive + && grant.created_by.as_deref().map(str::trim) == Some(grant.user_id.as_str()) + && grant.workspace_id.is_some() + && grant.root_uri.starts_with("local://users/") + && grant.root_uri.ends_with("/workspaces/my-space") +} + async fn sync_weknora_identity(user: &UserRecord, password: &str) -> ProviderIdentitySyncResult { let payload = json!({ "mnote_user_id": user.id, diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index c82a6e77..a8195f00 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -2203,7 +2203,16 @@ async fn handle_sqlite_auth_action( }); let provider_sync_results = if flow == "signUp" { - let results = sync_provider_identities(&resolved.user, &password_for_provider_sync).await; + let directory_grants = state + .control_plane() + .list_directory_grants_for_actor(&resolved.user.id) + .unwrap_or_default(); + let results = sync_provider_identities( + &resolved.user, + &password_for_provider_sync, + &directory_grants, + ) + .await; let _ = state.control_plane().append_audit(AppendAuditInput { actor_user_id: Some(resolved.user.id.clone()), action: "control.auth.provider_identities_synced".to_string(), diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 4e31c316..e624ce62 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -5,6 +5,7 @@ use crate::page_aggregate::{ PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, }; +use crate::provider_identity_sync::sync_openhub_directory_permissions_for_user_id; use crate::routes::local_markdown_parser::{ file_stem_title, parse_markdown_attachment_refs, parse_markdown_page, split_frontmatter, }; @@ -2124,6 +2125,7 @@ fn delete_sqlite_local_access_grant_for_context( "默认空间的系统授权不能撤销", )); } + let revoked_user_id = grants.first().map(|grant| grant.user_id.clone()); state .control_plane() .revoke_directory_grant(grant_id, None) @@ -2154,6 +2156,7 @@ fn delete_sqlite_local_access_grant_for_context( "controlPlane": "sqlite", "policyPath": local_access_policy_path().display().to_string(), "deletedGrantId": grant_id, + "revokedUserId": revoked_user_id, "policy": { "grants": state .control_plane() @@ -2371,6 +2374,14 @@ pub async fn create_local_access_grant( ) -> Result<(StatusCode, Json), WebError> { let payload = add_sqlite_local_access_grant_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; + if let Some(user_id) = payload + .pointer("/grant/userId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await; + } Ok((StatusCode::OK, Json(payload))) } @@ -2381,6 +2392,14 @@ pub async fn create_user_access_grant( ) -> Result<(StatusCode, Json), WebError> { let payload = add_sqlite_user_access_grant_for_context(&state, &context, request) .map_err(|error| error.with_context(&context))?; + if let Some(user_id) = payload + .pointer("/grant/userId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await; + } Ok((StatusCode::OK, Json(payload))) } @@ -2391,6 +2410,14 @@ pub async fn delete_local_access_grant( ) -> Result<(StatusCode, Json), WebError> { let payload = delete_sqlite_local_access_grant_for_context(&state, &context, &grant_id) .map_err(|error| error.with_context(&context))?; + if let Some(user_id) = payload + .get("revokedUserId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await; + } Ok((StatusCode::OK, Json(payload))) } @@ -2401,6 +2428,14 @@ pub async fn delete_user_access_grant( ) -> Result<(StatusCode, Json), WebError> { let payload = delete_sqlite_user_access_grant_for_context(&state, &context, &grant_id) .map_err(|error| error.with_context(&context))?; + if let Some(user_id) = payload + .get("revokedUserId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + sync_openhub_directory_permissions_for_user_id(state.control_plane(), user_id).await; + } Ok((StatusCode::OK, Json(payload))) } @@ -11244,6 +11279,7 @@ fn main() {} .await .expect("owner can revoke self-created grant"); assert_eq!(deleted["deletedGrantId"], grant_id); + assert_eq!(deleted["revokedUserId"], "user_target"); let revoked_error = ensure_local_workspace_write_access_with_state( &state, &request_context("user_target", "user"),