完善共享授权与本地同步闭环

实现独立 share-grants 控制面,区分产品分享授权与本机目录授权。

收紧 shared AI session 的资源范围,并补充 shared cache、pending change 与冲突报告 sidecar。

更新当前架构与执行 checklist,记录 P4 已完成项和剩余 browser smoke。
This commit is contained in:
lix-2026
2026-05-19 11:11:08 +08:00
parent fc4a47e597
commit a1b28a8e38
7 changed files with 1889 additions and 48 deletions
+1
View File
@@ -89,6 +89,7 @@
5. **分享与同步闭环** 5. **分享与同步闭环**
- 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。 - 需要把 share grants、shared workspace cache、shared AI session、只读/可写权限和冲突处理连成产品级闭环。
- share grant 是产品层共享授权,local access grant 是本机目录授权;显式分享不能自动扩大本机 filesystem root 权限。
- 控制面不可用时不得扩大本地缓存权限。 - 控制面不可用时不得扩大本地缓存权限。
6. **插件资源模型** 6. **插件资源模型**
+1 -1
View File
@@ -135,7 +135,7 @@
2. VSCode-like 冲突处理 UI:接受磁盘版本、保留当前编辑器版本、打开 diff / 合并。 2. VSCode-like 冲突处理 UI:接受磁盘版本、保留当前编辑器版本、打开 diff / 合并。
3. agent 写入审计:回收 changed files、diff summary、actor、workspace root、permission level、run id。 3. agent 写入审计:回收 changed files、diff summary、actor、workspace root、permission level、run id。
4. 本地索引:全文搜索、反链、页面引用、资源引用、标签,不依赖 Convex search 才能搜索本地 workspace。 4. 本地索引:全文搜索、反链、页面引用、资源引用、标签,不依赖 Convex search 才能搜索本地 workspace。
5. 分享与同步闭环:share grants、shared workspace cache、shared AI session、离线权限收敛和同步冲突报告。 5. 分享与同步闭环:独立 share grants 控制面、shared workspace cache、shared AI session、离线权限收敛和同步冲突报告share grant 不等于本机 local access grant,不能自动扩大本机目录读写权限
6. 插件资源模型:simplemindmap / office 作为 Resource Tree 对象打开和保存,Markdown 只保留链接或嵌入引用。 6. 插件资源模型:simplemindmap / office 作为 Resource Tree 对象打开和保存,Markdown 只保留链接或嵌入引用。
7. 旧 Convex 数据迁移产品化:真实导出入口、迁移进度、备份、冲突报告和回滚策略。 7. 旧 Convex 数据迁移产品化:真实导出入口、迁移进度、备份、冲突报告和回滚策略。
@@ -243,29 +243,45 @@
### 5.1 分享权限 ### 5.1 分享权限
- [ ] 定义 share grant 与 local access grant 的关系:分享不自动扩大本机文件系统权限。 - [x] 定义 share grant 与 local access grant 的关系:share grant 是产品层共享授权,local access grant 是本机文件系统目录授权;分享不自动扩大本机 root 读写权限。
- [ ] share grant 支持 read / write / ai capability - 实现:`share_grant_admin_api_creates_reads_and_revokes_without_local_access` 验证 share grant 不授予 target 用户本地 filesystem read/write access
- [ ] shared AI session 只能访问 share grant 允许的资源 - [x] 增加 canonical share grant 控制面:默认落在 `/mnt/Data1T/Mnote_data/control-plane/share-grants.json`,记录 `shareId`、owner、target、root、resource scope、permission、capabilities、生命周期和撤销状态
- [ ] 管理员可查看和撤销 share grant。 - 实现:新增 `GET/POST/DELETE /api/admin/share-grants`;支持 `MNOTE_SHARE_GRANTS_FILE` 覆盖测试路径
- [x] share grant 支持 `read` / `write` / `ai` capability,并可映射到 `AiAccessScope.permissionLevel=shared_read/shared_write`
- 实现:Hermes local ACP run 从 canonical share grant 生成 `shared_read/shared_write`
- [x] shared AI session 只能访问 share grant 允许的资源;`allowedResourceIds` / `shareContext` 必须来自 canonical share grant,不接受浏览器自报扩大范围。
- 实现:`hermes_client_run_body_shared_scope_uses_share_grant_resources``acp_runtime_env_limits_shared_scope_to_granted_files` 覆盖 `shareContext``allowedResourceIds` 和文件级 allowed roots。
- [x] 管理员可查看、创建和撤销 share grant;owner 后续可管理自己创建的 share grant。
- 实现:管理员页面新增独立“分享授权”面板,与目录授权面板分离。
### 5.2 同步缓存 ### 5.2 同步缓存
- [ ] 设计 shared workspace cache 目录。 - [x] 设计 shared workspace cache 目录:默认位于 `/mnt/Data1T/Mnote_data/shared/workspaces/<share-id>/`workspace 内缓存 `.mnote/share-cache.json``.mnote/sync-state.json` 只保存授权、版本、来源和同步状态,不成为第二份正文真相
- [ ] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息 - 实现:新增 `/api/local-folder/shared-cache/record``/api/local-folder/sync/pending-change`,均只写 `.mnote` sidecar
- [ ] 本地修改同步回云端前进行权限和版本校验 - [x] 云端同步到本地 cache 时保留来源、版本、权限和冲突信息:至少记录 `shareId``remoteVersion``baseVersion``permission``sourceActor``syncedAt`
- [ ] 离线期间记录 pending changes,恢复在线后生成同步报告 - 实现:`.mnote/share-cache.json` 写入 `mnote.share_cache.v1`
- [x] 本地修改同步回云端前进行权限和版本校验:`shared_read` 永远不能上传 pending write`shared_write` 必须校验 share grant 未撤销且 base version 未过期。
- 验证:`shared_cache_and_sync_state_record_permissions_and_conflict_report` 覆盖 shared_read 拒绝 pending write、shared_write 写入 pending change、remote/base version 冲突返回 409。
- [x] 离线期间记录 pending changes,恢复在线后生成同步报告;pending changes 只能来自已有授权 cache,离线不能新增或扩大授权。
- 实现:pending changes 写入 `.mnote/sync-state.json`,写入前强制校验 canonical share grant。
### 5.3 同步冲突报告 ### 5.3 同步冲突报告
- [ ] 同步冲突复用 P1 的冲突 UI。 - [x] 同步冲突复用 P1 的冲突 UI,并把冲突来源标记为 shared sync 而不是普通外部文件变更
- [ ] 冲突报告包含本地版本、远端版本、base version、修改 actor - 实现:`/api/local-folder/sync/conflict-report` 生成 `source=shared_sync``suggestedActions=["accept_local","accept_remote","open_diff_merge"]` 的报告 envelope,供 P1 diff / merge UI 消费
- [ ] 管理员或 owner 可导出冲突报告。 - [x] 冲突报告包含本地版本、远端版本、base version、修改 actor、shareId 和 resource id
- 验证:`shared_cache_and_sync_state_record_permissions_and_conflict_report`
- [x] 管理员或 owner 可导出冲突报告;报告写入 control-plane 或 workspace `.mnote/sync-reports/`,不写入正文目录。
- 实现:报告写入授权 workspace 的 `.mnote/sync-reports/`
建议验证: 建议验证:
- [ ] 单测:share read 不允许写入。 - [x] 单测:share read 不允许写入。
- [ ] 单测:shared AI session 不扩大 workspace root - 验证:`cargo test -p mnote-web shared_cache -- --nocapture``cargo test -p mnote-web hermes_client_local_shared -- --nocapture`
- [x] 单测:shared AI session 不扩大 workspace root。
- 验证:`cargo test -p mnote-web acp_runtime_env_limits_shared_scope_to_granted_files -- --nocapture`
- [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。 - [ ] browser smoke:共享页面只读用户无法通过 AI 写入正文。
- 说明:当前已有 route / runtime 单测闭环;浏览器 smoke 仍需补脚本覆盖 UI 层拒写提示。
--- ---
+640 -19
View File
@@ -29,6 +29,8 @@ const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner"; const HEADER_HERMES_CLIENT_OWNER: &str = "x-mnote-hermes-client-owner";
const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun"; const ACP_RUNTIME_RUN_MUTATION: &str = "aiSessions:upsertRuntimeRun";
const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent"; const ACP_RUNTIME_EVENT_MUTATION: &str = "aiSessions:appendRuntimeEvent";
const LOCAL_SHARE_GRANTS_JSON: &str = "/mnt/Data1T/Mnote_data/control-plane/share-grants.json";
const ENV_LOCAL_SHARE_GRANTS_FILE: &str = "MNOTE_SHARE_GRANTS_FILE";
static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeState>>> = static HERMES_RUNTIME_REGISTRY: LazyLock<Mutex<HashMap<String, HermesRuntimeState>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -104,6 +106,58 @@ struct LocalAgentAuditSnapshot {
files: BTreeMap<String, LocalAgentAuditFileSnapshot>, files: BTreeMap<String, LocalAgentAuditFileSnapshot>,
} }
#[derive(Debug, Clone)]
struct LocalShareGrant {
share_id: String,
owner_id: Option<String>,
target_user_id: Option<String>,
root_uri: Option<String>,
permission: String,
capabilities: HashSet<String>,
allowed_resource_ids: Vec<String>,
revoked: bool,
}
impl LocalShareGrant {
fn permission_level(&self) -> &'static str {
if local_share_permission_allows_write(&self.permission, &self.capabilities) {
"shared_write"
} else {
"shared_read"
}
}
fn share_context(&self) -> Value {
json!({
"shareId": self.share_id,
"ownerId": self.owner_id,
"targetUserId": self.target_user_id,
"rootUri": self.root_uri,
"permission": self.permission,
"capabilities": self.capabilities.iter().cloned().collect::<Vec<_>>()
})
}
fn allowed_file_paths(&self) -> Vec<String> {
let Some(root_uri) = self.root_uri.as_deref() else {
return Vec::new();
};
let Some(root_path) = file_root_uri_to_permission_path(root_uri) else {
return Vec::new();
};
self.allowed_resource_ids
.iter()
.filter_map(|resource_id| local_resource_id_to_relative_path(resource_id))
.map(|relative_path| {
FsPath::new(&root_path)
.join(relative_path)
.to_string_lossy()
.to_string()
})
.collect()
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CreateSessionRequest { pub struct CreateSessionRequest {
@@ -380,18 +434,34 @@ pub async fn create_session(
.with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web") .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client") .with_header(HEADER_HERMES_CLIENT_OWNER, "mnote-web-hermes-client")
})?; })?;
crate::routes::ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
let share_id = payload let share_id = payload
.share_id .share_id
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()); .filter(|value| !value.is_empty());
let permission_level = payload let share_grant = if let Some(share_id) = share_id {
let grant = load_local_share_grant_for_actor(
share_id,
Some(&context.auth.actor_id),
Some(root_uri),
)
.map_err(|error| error.with_context(&context))?;
Some(grant)
} else {
crate::routes::ensure_local_workspace_access(&context, root_uri)
.map_err(|error| error.with_context(&context))?;
None
};
let permission_level = share_grant
.as_ref()
.map(LocalShareGrant::permission_level)
.or_else(|| {
payload
.permission_level .permission_level
.as_deref() .as_deref()
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
})
.unwrap_or(if share_id.is_some() { .unwrap_or(if share_id.is_some() {
"shared_read" "shared_read"
} else { } else {
@@ -424,6 +494,8 @@ pub async fn create_session(
"visibility": visibility, "visibility": visibility,
"permissionLevel": permission_level, "permissionLevel": permission_level,
"shareId": share_id, "shareId": share_id,
"shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
"allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_else(|| vec![document_id.to_string()]),
"title": payload.title.as_deref().unwrap_or("当前页问答"), "title": payload.title.as_deref().unwrap_or("当前页问答"),
"profile": profile, "profile": profile,
"userId": session_store_user_id, "userId": session_store_user_id,
@@ -442,6 +514,8 @@ pub async fn create_session(
"sourceKind": "local_folder", "sourceKind": "local_folder",
"permissionLevel": permission_level, "permissionLevel": permission_level,
"shareId": share_id, "shareId": share_id,
"shareContext": share_grant.as_ref().map(LocalShareGrant::share_context).unwrap_or(Value::Null),
"allowedResourceIds": share_grant.as_ref().map(|grant| grant.allowed_resource_ids.clone()).unwrap_or_default(),
"actorId": context.auth.actor_id, "actorId": context.auth.actor_id,
"userId": session_store_user_id, "userId": session_store_user_id,
"traceId": trace_id, "traceId": trace_id,
@@ -2828,6 +2902,9 @@ fn merge_acp_runtime_env(
} }
fn local_ai_payload_permission_level(payload: &Value) -> &'static str { fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
if let Some(grant) = local_share_grant_for_payload(payload).ok().flatten() {
return grant.permission_level();
}
let raw = payload let raw = payload
.get("permissionLevel") .get("permissionLevel")
.or_else(|| payload.get("permission_level")) .or_else(|| payload.get("permission_level"))
@@ -2835,7 +2912,11 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
.map(str::trim) .map(str::trim)
.unwrap_or("read_write") .unwrap_or("read_write")
.to_ascii_lowercase(); .to_ascii_lowercase();
if raw == "read" || raw == "readonly" || raw == "read_only" || raw == "shared_read" { if raw == "shared_read" {
"shared_read"
} else if raw == "shared_write" {
"shared_write"
} else if raw == "read" || raw == "readonly" || raw == "read_only" {
"read_only" "read_only"
} else { } else {
"read_write" "read_write"
@@ -2843,7 +2924,261 @@ fn local_ai_payload_permission_level(payload: &Value) -> &'static str {
} }
fn local_ai_payload_is_read_only(payload: &Value) -> bool { fn local_ai_payload_is_read_only(payload: &Value) -> bool {
local_ai_payload_permission_level(payload) == "read_only" matches!(
local_ai_payload_permission_level(payload),
"read_only" | "shared_read"
)
}
fn local_share_grants_path() -> PathBuf {
env::var(ENV_LOCAL_SHARE_GRANTS_FILE)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(LOCAL_SHARE_GRANTS_JSON))
}
fn local_resource_id_to_relative_path(resource_id: &str) -> Option<String> {
let trimmed = resource_id.trim();
let encoded = trimmed.strip_prefix("local-md:")?;
let bytes = encoded.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'~' {
if index + 2 >= bytes.len() {
return None;
}
let hex = &encoded[index + 1..index + 3];
let byte = u8::from_str_radix(hex, 16).ok()?;
decoded.push(byte);
index += 3;
} else {
decoded.push(bytes[index]);
index += 1;
}
}
String::from_utf8(decoded).ok()
}
fn local_share_permission_allows_write(permission: &str, capabilities: &HashSet<String>) -> bool {
let permission = permission.trim().to_ascii_lowercase();
let has_ai = capabilities
.iter()
.any(|capability| capability.trim().eq_ignore_ascii_case("ai"));
(permission == "write" || permission == "shared_write") && has_ai
}
fn local_share_grant_for_payload(payload: &Value) -> Result<Option<LocalShareGrant>, WebError> {
let share_id = payload
.get("shareId")
.or_else(|| payload.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(share_id) = share_id else {
return Ok(None);
};
Ok(Some(load_local_share_grant_for_actor(
share_id,
payload.get("actorId").and_then(Value::as_str),
payload.get("rootUri").and_then(Value::as_str),
)?))
}
fn load_local_share_grant_for_actor(
share_id: &str,
actor_id: Option<&str>,
root_uri: Option<&str>,
) -> Result<LocalShareGrant, WebError> {
let path = local_share_grants_path();
let content = fs::read_to_string(&path).map_err(|error| {
WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_required",
format!("共享 AI 会话需要 canonical share grant: {error}"),
)
})?;
let payload: Value = serde_json::from_str(&content).map_err(|error| {
WebError::bad_request_code(
"local_share_grants_invalid",
format!("share-grants.json 格式非法: {error}"),
)
})?;
let grants = if let Some(array) = payload.as_array() {
array
} else {
payload
.get("grants")
.and_then(Value::as_array)
.ok_or_else(|| {
WebError::bad_request_code(
"local_share_grants_invalid",
"share-grants.json 缺少 grants 数组",
)
})?
};
let Some(raw_grant) = grants.iter().find(|grant| {
grant
.get("shareId")
.or_else(|| grant.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
== Some(share_id)
}) else {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_not_found",
"共享 AI 会话使用的 shareId 不存在",
));
};
let grant = parse_local_share_grant(raw_grant)?;
if grant.revoked {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_revoked",
"共享 AI 会话使用的 share grant 已撤销",
));
}
if let (Some(target), Some(actor_id)) = (grant.target_user_id.as_deref(), actor_id) {
if target.trim() != actor_id.trim() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_actor_mismatch",
"当前用户不是 share grant 的 target 用户",
));
}
}
if let (Some(grant_root), Some(root_uri)) = (grant.root_uri.as_deref(), root_uri) {
if grant_root.trim() != root_uri.trim() {
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_root_mismatch",
"请求 rootUri 与 canonical share grant 不一致",
));
}
}
if !grant
.capabilities
.iter()
.any(|capability| capability.trim().eq_ignore_ascii_case("ai"))
{
return Err(WebError::new(
StatusCode::FORBIDDEN,
"local_share_grant_ai_capability_required",
"共享 AI 会话需要 share grant 包含 ai capability",
));
}
Ok(grant)
}
fn parse_local_share_grant(value: &Value) -> Result<LocalShareGrant, WebError> {
let share_id = value
.get("shareId")
.or_else(|| value.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_share_grant_invalid", "share grant 缺少 shareId")
})?
.to_string();
let owner_id = value
.get("ownerId")
.or_else(|| value.get("ownerUserId"))
.or_else(|| value.get("owner_id"))
.or_else(|| value.get("owner_user_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let target_user_id = value
.get("targetUserId")
.or_else(|| value.get("target_user_id"))
.or_else(|| value.get("targetId"))
.or_else(|| value.get("target_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let root_uri = value
.get("rootUri")
.or_else(|| value.get("root_uri"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned);
let permission = value
.get("permission")
.or_else(|| value.get("permissionLevel"))
.or_else(|| value.get("permission_level"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("read")
.to_ascii_lowercase();
let capabilities = value
.get("capabilities")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let allowed_resource_ids = value
.get("allowedResourceIds")
.or_else(|| value.get("allowed_resource_ids"))
.or_else(|| value.get("resourceIds"))
.or_else(|| value.get("resource_ids"))
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let revoked = value
.get("revoked")
.and_then(Value::as_bool)
.unwrap_or(false)
|| value
.get("active")
.and_then(Value::as_bool)
.map(|active| !active)
.unwrap_or(false)
|| value
.get("revokedAt")
.or_else(|| value.get("revoked_at"))
.and_then(Value::as_str)
.map(str::trim)
.map(|revoked_at| !revoked_at.is_empty())
.unwrap_or(false)
|| value
.get("status")
.and_then(Value::as_str)
.map(str::trim)
.map(|status| status.eq_ignore_ascii_case("revoked"))
.unwrap_or(false);
Ok(LocalShareGrant {
share_id,
owner_id,
target_user_id,
root_uri,
permission,
capabilities,
allowed_resource_ids,
revoked,
})
} }
fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String, String>> { fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String, String>> {
@@ -2885,21 +3220,44 @@ fn acp_allowed_roots_env_for_payload(payload: &Value) -> Option<HashMap<String,
.map(str::trim) .map(str::trim)
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
.unwrap_or("anonymous"); .unwrap_or("anonymous");
let permission_level = local_ai_payload_permission_level(payload); let share_grant = local_share_grant_for_payload(payload).ok().flatten();
let access_scope = json!({ let permission_level = share_grant
.as_ref()
.map(LocalShareGrant::permission_level)
.unwrap_or_else(|| local_ai_payload_permission_level(payload));
let allowed_resource_ids = share_grant
.as_ref()
.map(|grant| grant.allowed_resource_ids.clone())
.filter(|ids| !ids.is_empty())
.unwrap_or_else(|| vec![document_id.to_string()]);
let allowed_file_paths = share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.unwrap_or_default();
let mut access_scope = json!({
"userId": actor_id, "userId": actor_id,
"workspaceId": workspace_id, "workspaceId": workspace_id,
"sessionId": session_id, "sessionId": session_id,
"sourceKind": "local_folder", "sourceKind": "local_folder",
"permissionLevel": permission_level, "permissionLevel": permission_level,
"allowedRoots": [root_uri], "allowedRoots": [root_uri],
"allowedFilePaths": [], "allowedFilePaths": allowed_file_paths,
"allowedResourceIds": [document_id] "allowedResourceIds": allowed_resource_ids
}); });
if let Some(grant) = share_grant.as_ref() {
access_scope["shareContext"] = grant.share_context();
}
let mut env = HashMap::new(); let mut env = HashMap::new();
env.insert( env.insert(
"MNOTE_AI_ALLOWED_ROOTS_JSON".into(), "MNOTE_AI_ALLOWED_ROOTS_JSON".into(),
serde_json::to_string(&vec![root_path.clone()]).ok()?, serde_json::to_string(
&share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.filter(|paths| !paths.is_empty())
.unwrap_or_else(|| vec![root_path.clone()]),
)
.ok()?,
); );
env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path); env.insert("MNOTE_AI_WORKSPACE_ROOT".into(), root_path);
env.insert( env.insert(
@@ -3329,6 +3687,11 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
.get("actorType") .get("actorType")
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or(&context.auth.actor_type); .unwrap_or(&context.auth.actor_type);
let share_grant = if is_local_source {
local_share_grant_for_payload(&payload)?
} else {
None
};
let local_file_reference = if is_local_source { let local_file_reference = if is_local_source {
root_uri.map(|root_uri| { root_uri.map(|root_uri| {
json!({ json!({
@@ -3347,17 +3710,33 @@ fn build_run_upstream_body(context: &RequestContext, payload: Value) -> Result<V
}; };
let local_ai_access_scope = if is_local_source { let local_ai_access_scope = if is_local_source {
root_uri.map(|root_uri| { root_uri.map(|root_uri| {
let permission_level = local_ai_payload_permission_level(&payload); let permission_level = share_grant
json!({ .as_ref()
.map(LocalShareGrant::permission_level)
.unwrap_or_else(|| local_ai_payload_permission_level(&payload));
let allowed_resource_ids = share_grant
.as_ref()
.map(|grant| grant.allowed_resource_ids.clone())
.filter(|ids| !ids.is_empty())
.unwrap_or_else(|| vec![document_id.to_string()]);
let allowed_file_paths = share_grant
.as_ref()
.map(LocalShareGrant::allowed_file_paths)
.unwrap_or_default();
let mut scope = json!({
"userId": actor_id, "userId": actor_id,
"workspaceId": workspace_id_string, "workspaceId": workspace_id_string,
"sessionId": session_id, "sessionId": session_id,
"sourceKind": "local_folder", "sourceKind": "local_folder",
"permissionLevel": permission_level, "permissionLevel": permission_level,
"allowedRoots": [root_uri], "allowedRoots": [root_uri],
"allowedFilePaths": [], "allowedFilePaths": allowed_file_paths,
"allowedResourceIds": [document_id] "allowedResourceIds": allowed_resource_ids
}) });
if let Some(grant) = share_grant.as_ref() {
scope["shareContext"] = grant.share_context();
}
scope
}) })
} else { } else {
None None
@@ -3727,6 +4106,16 @@ fn acp_runtime_run_store_args(
}) })
} }
fn local_session_share_id(payload: &Value) -> Option<String> {
payload
.get("shareId")
.or_else(|| payload.get("share_id"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String { fn runtime_store_user_id(context: &RequestContext, payload: &Value) -> String {
payload payload
.get("actorId") .get("actorId")
@@ -4320,8 +4709,18 @@ async fn persist_acp_runtime_run(
WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
.with_context(context) .with_context(context)
})?; })?;
let share_id = local_session_share_id(payload);
if let Some(share_id) = share_id.as_deref() {
load_local_share_grant_for_actor(
share_id,
payload.get("actorId").and_then(Value::as_str),
Some(root_uri),
)
.map_err(|error| error.with_context(context))?;
} else {
crate::routes::ensure_local_workspace_access(context, root_uri) crate::routes::ensure_local_workspace_access(context, root_uri)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
}
let mut event = args.clone(); let mut event = args.clone();
if let Value::Object(map) = &mut event { if let Value::Object(map) = &mut event {
map.insert("eventType".into(), Value::String("run.started".into())); map.insert("eventType".into(), Value::String("run.started".into()));
@@ -4330,12 +4729,17 @@ async fn persist_acp_runtime_run(
Value::String("local_ai_session_jsonl".into()), Value::String("local_ai_session_jsonl".into()),
); );
} }
append_local_ai_session_event(root_uri, &registration.session_id, &event, None) append_local_ai_session_event(
root_uri,
&registration.session_id,
&event,
share_id.as_deref(),
)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
return Ok(json!({ return Ok(json!({
"ok": true, "ok": true,
"persistence": "local_ai_session_jsonl", "persistence": "local_ai_session_jsonl",
"sessionStorage": "local_private", "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
"sessionId": registration.session_id, "sessionId": registration.session_id,
"runId": run_id "runId": run_id
})); }));
@@ -4428,8 +4832,18 @@ async fn persist_acp_runtime_event(
WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri") WebError::bad_request_code("local_ai_session_root_required", "缺少本地会话 rootUri")
.with_context(context) .with_context(context)
})?; })?;
let share_id = local_session_share_id(run_payload);
if let Some(share_id) = share_id.as_deref() {
load_local_share_grant_for_actor(
share_id,
run_payload.get("actorId").and_then(Value::as_str),
Some(root_uri),
)
.map_err(|error| error.with_context(context))?;
} else {
crate::routes::ensure_local_workspace_access(context, root_uri) crate::routes::ensure_local_workspace_access(context, root_uri)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
}
let mut event = args.clone(); let mut event = args.clone();
if let Value::Object(map) = &mut event { if let Value::Object(map) = &mut event {
map.insert( map.insert(
@@ -4437,12 +4851,17 @@ async fn persist_acp_runtime_event(
Value::String("local_ai_session_jsonl".into()), Value::String("local_ai_session_jsonl".into()),
); );
} }
append_local_ai_session_event(root_uri, &registration.session_id, &event, None) append_local_ai_session_event(
root_uri,
&registration.session_id,
&event,
share_id.as_deref(),
)
.map_err(|error| error.with_context(context))?; .map_err(|error| error.with_context(context))?;
return Ok(json!({ return Ok(json!({
"ok": true, "ok": true,
"persistence": "local_ai_session_jsonl", "persistence": "local_ai_session_jsonl",
"sessionStorage": "local_private", "sessionStorage": if share_id.is_some() { "local_shared" } else { "local_private" },
"sessionId": registration.session_id, "sessionId": registration.session_id,
"runId": run_id "runId": run_id
})); }));
@@ -5672,18 +6091,47 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() { async fn hermes_client_local_shared_write_session_writes_shared_jsonl_with_audit() {
let _guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-{}", "mnote-local-ai-session-shared-{}",
std::process::id() std::process::id()
)); ));
let config_root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(&config_root).expect("config root");
fs::write( fs::write(
root.join(".mnote").join("workspace.json"), root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
) )
.expect("manifest"); .expect("manifest");
let root_uri = format!("file://{}", root.display()); let root_uri = format!("file://{}", root.display());
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_1",
"shareId": "share_1",
"ownerUserId": "user_1",
"targetUserId": "user_1",
"rootUri": root_uri,
"documentId": "local-md:README.md",
"allowedResourceIds": ["local-md:README.md"],
"permission": "write",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let response = app() let response = app()
.oneshot( .oneshot(
@@ -5727,24 +6175,57 @@ mod tests {
assert!(jsonl.contains("\"eventType\":\"session.created\"")); assert!(jsonl.contains("\"eventType\":\"session.created\""));
assert!(jsonl.contains("\"eventType\":\"audit.shared_write\"")); assert!(jsonl.contains("\"eventType\":\"audit.shared_write\""));
assert!(jsonl.contains("\"shareId\":\"share_1\"")); assert!(jsonl.contains("\"shareId\":\"share_1\""));
assert!(jsonl.contains("\"allowedResourceIds\":[\"local-md:README.md\"]"));
assert!(jsonl.contains("\"shareContext\""));
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
} }
#[tokio::test] #[tokio::test]
async fn hermes_client_local_shared_read_session_write_is_forbidden() { async fn hermes_client_local_shared_read_session_write_is_forbidden() {
let _guard = env_lock().lock().expect("env lock");
let root = std::env::temp_dir().join(format!( let root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-read-{}", "mnote-local-ai-session-shared-read-{}",
std::process::id() std::process::id()
)); ));
let config_root = std::env::temp_dir().join(format!(
"mnote-local-ai-session-shared-read-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(root.join(".mnote")).expect("metadata"); fs::create_dir_all(root.join(".mnote")).expect("metadata");
fs::create_dir_all(&config_root).expect("config root");
fs::write( fs::write(
root.join(".mnote").join("workspace.json"), root.join(".mnote").join("workspace.json"),
r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#, r#"{"workspaceId":"local-ws-user-1","ownerId":"user_1","createdAt":"2026-05-18T00:00:00Z","capabilities":["local_files","ai_sessions"]}"#,
) )
.expect("manifest"); .expect("manifest");
let root_uri = format!("file://{}", root.display()); let root_uri = format!("file://{}", root.display());
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_read_1",
"shareId": "share_1",
"ownerUserId": "user_1",
"targetUserId": "user_1",
"rootUri": root_uri,
"documentId": "local-md:README.md",
"allowedResourceIds": ["local-md:README.md"],
"permission": "read",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let response = app() let response = app()
.oneshot( .oneshot(
@@ -5775,7 +6256,9 @@ mod tests {
assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(response.status(), StatusCode::FORBIDDEN);
assert!(!root.join("ai-sessions").join("shared").exists()); assert!(!root.join("ai-sessions").join("shared").exists());
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&root); let _ = fs::remove_dir_all(&root);
let _ = fs::remove_dir_all(&config_root);
} }
#[tokio::test] #[tokio::test]
@@ -7082,6 +7565,74 @@ mod tests {
assert!(!instructions.contains("\"pageText\"")); assert!(!instructions.contains("\"pageText\""));
} }
#[test]
fn hermes_client_run_body_shared_scope_uses_share_grant_resources() {
let _guard = env_lock().lock().expect("env lock");
let config_root = std::env::temp_dir().join(format!(
"mnote-shared-run-body-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(&config_root).expect("config root");
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_scope_1",
"shareId": "share_scope_1",
"ownerUserId": "owner_1",
"targetUserId": "target_1",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"documentId": "local-md:docs~2FShared.md",
"allowedResourceIds": ["local-md:docs~2FShared.md"],
"permission": "write",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let mut headers = HeaderMap::new();
headers.insert("x-mnote-actor-id", "target_1".parse().unwrap());
headers.insert("x-mnote-actor-type", "user".parse().unwrap());
let context = RequestContext::from_http_parts(
&axum::http::Method::POST,
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let body = build_run_upstream_body(
&context,
json!({
"workspaceId": "local-workspace-shared",
"documentId": "local-md:docs~2FShared.md",
"sessionId": "sess_shared_scope_1",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"shareId": "share_scope_1",
"actorId": "target_1",
"message": "编辑共享页面",
"traceId": "trace_shared_scope_1"
}),
)
.expect("body");
let instructions = body["instructions"].as_str().expect("instructions");
assert!(instructions.contains("\"permissionLevel\":\"shared_write\""));
assert!(instructions.contains("\"shareContext\""));
assert!(instructions.contains("\"shareId\":\"share_scope_1\""));
assert!(instructions.contains("\"allowedResourceIds\":[\"local-md:docs~2FShared.md\"]"));
assert!(instructions.contains(
"\"allowedFilePaths\":[\"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/docs/Shared.md\"]"
));
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&config_root);
}
#[test] #[test]
fn acp_runtime_env_limits_local_source_to_allowed_root() { fn acp_runtime_env_limits_local_source_to_allowed_root() {
let payload = json!({ let payload = json!({
@@ -7122,6 +7673,76 @@ mod tests {
); );
} }
#[test]
fn acp_runtime_env_limits_shared_scope_to_granted_files() {
let _guard = env_lock().lock().expect("env lock");
let config_root = std::env::temp_dir().join(format!(
"mnote-shared-acp-env-config-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&config_root);
fs::create_dir_all(&config_root).expect("config root");
let share_grants_file = config_root.join("share-grants.json");
fs::write(
&share_grants_file,
json!({
"grants": [{
"id": "share_grant_env_1",
"shareId": "share_env_1",
"ownerUserId": "owner_1",
"targetUserId": "target_1",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"allowedResourceIds": ["local-md:README.md"],
"permission": "read",
"capabilities": ["ai", "share"],
"createdAt": "1",
"active": true
}]
})
.to_string(),
)
.expect("share grants");
std::env::set_var("MNOTE_SHARE_GRANTS_FILE", &share_grants_file);
let payload = json!({
"workspaceId": "local-workspace-shared",
"documentId": "local-md:README.md",
"sessionId": "sess_shared_env_1",
"sourceKind": "local_folder",
"rootUri": "file:///mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space",
"shareId": "share_env_1",
"actorId": "target_1"
});
let env = acp_allowed_roots_env_for_payload(&payload).expect("env");
let allowed_roots = serde_json::from_str::<Vec<String>>(
env.get("MNOTE_AI_ALLOWED_ROOTS_JSON")
.expect("allowed roots"),
)
.expect("allowed roots json");
assert_eq!(
allowed_roots,
vec!["/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"]
);
assert_eq!(
env.get("MNOTE_AI_WORKSPACE_ROOT").map(String::as_str),
Some("/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space")
);
let scope = serde_json::from_str::<serde_json::Value>(
env.get("MNOTE_AI_ACCESS_SCOPE_JSON").expect("scope"),
)
.expect("scope json");
assert_eq!(scope["permissionLevel"], "shared_read");
assert_eq!(scope["shareContext"]["shareId"], "share_env_1");
assert_eq!(scope["allowedResourceIds"][0], "local-md:README.md");
assert_eq!(
scope["allowedFilePaths"][0],
"/mnt/Data1T/Mnote_data/users/owner_1/workspaces/my-space/README.md"
);
std::env::remove_var("MNOTE_SHARE_GRANTS_FILE");
let _ = fs::remove_dir_all(&config_root);
}
#[test] #[test]
fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() { fn hermes_client_run_actor_falls_back_to_dev_user_for_cookie_auth() {
let state = test_state(); let state = test_state();
File diff suppressed because it is too large Load Diff
+21
View File
@@ -127,6 +127,27 @@ pub fn build_router(state: AppState) -> Router {
"/api/admin/access-policy/grants/{grant_id}", "/api/admin/access-policy/grants/{grant_id}",
delete(local_folder_source::delete_local_access_grant), delete(local_folder_source::delete_local_access_grant),
) )
.route(
"/api/admin/share-grants",
get(local_folder_source::get_share_grants)
.post(local_folder_source::create_share_grant),
)
.route(
"/api/admin/share-grants/{share_id}",
delete(local_folder_source::delete_share_grant),
)
.route(
"/api/local-folder/shared-cache/record",
post(local_folder_source::record_shared_cache),
)
.route(
"/api/local-folder/sync/pending-change",
post(local_folder_source::record_sync_pending_change),
)
.route(
"/api/local-folder/sync/conflict-report",
post(local_folder_source::write_sync_conflict_report),
)
.route("/api/ai-agent/run", post(compat::next_ai_agent_run)) .route("/api/ai-agent/run", post(compat::next_ai_agent_run))
.route( .route(
"/api/page-ai/block-edit-workflow", "/api/page-ai/block-edit-workflow",
@@ -7,6 +7,7 @@ use leptos::prelude::*;
pub fn AdminAccessPolicyPage( pub fn AdminAccessPolicyPage(
#[prop(optional)] workspace_name: Option<String>, #[prop(optional)] workspace_name: Option<String>,
#[prop(optional)] policy_path: Option<String>, #[prop(optional)] policy_path: Option<String>,
#[prop(optional)] share_grants_path: Option<String>,
) -> impl IntoView { ) -> impl IntoView {
let workspace_name = workspace_name let workspace_name = workspace_name
.unwrap_or_else(|| "开发用户 的空间".to_string()) .unwrap_or_else(|| "开发用户 的空间".to_string())
@@ -14,6 +15,8 @@ pub fn AdminAccessPolicyPage(
.to_string(); .to_string();
let policy_path = policy_path let policy_path = policy_path
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string()); .unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/access-policy.json".to_string());
let share_grants_path = share_grants_path
.unwrap_or_else(|| "/mnt/Data1T/Mnote_data/control-plane/share-grants.json".to_string());
view! { view! {
<PageLayout current_nav="admin" workspace_name={workspace_name.clone()} topbar_title={"目录授权".to_string()} show_admin_access_policy=true> <PageLayout current_nav="admin" workspace_name={workspace_name.clone()} topbar_title={"目录授权".to_string()} show_admin_access_policy=true>
<main class="mnote-admin-policy-page" data-testid="mnote-admin-access-policy-page"> <main class="mnote-admin-policy-page" data-testid="mnote-admin-access-policy-page">
@@ -31,6 +34,10 @@ pub fn AdminAccessPolicyPage(
<span class="mnote-admin-policy-summary-label">"策略文件"</span> <span class="mnote-admin-policy-summary-label">"策略文件"</span>
<code data-testid="mnote-admin-policy-path">{policy_path.clone()}</code> <code data-testid="mnote-admin-policy-path">{policy_path.clone()}</code>
</div> </div>
<div class="mnote-admin-policy-summary-item">
<span class="mnote-admin-policy-summary-label">"分享授权文件"</span>
<code data-testid="mnote-admin-share-grants-path">{share_grants_path.clone()}</code>
</div>
</section> </section>
<section class="mnote-admin-policy-panel"> <section class="mnote-admin-policy-panel">
@@ -104,6 +111,76 @@ pub fn AdminAccessPolicyPage(
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre> <pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-result"></pre>
</form> </form>
</section> </section>
<section class="mnote-admin-policy-panel" data-testid="mnote-admin-share-grants-panel">
<header class="mnote-admin-policy-panel-header">
<h2>"分享授权"</h2>
<button type="button" data-testid="mnote-admin-share-grants-refresh" data-admin-action="refresh-share-grants">"刷新"</button>
</header>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-share-grants-json">{""}</pre>
<div class="mnote-admin-policy-note" data-testid="mnote-admin-share-grants-message"></div>
</section>
<section class="mnote-admin-policy-grid">
<form class="mnote-admin-policy-form" data-admin-form="create-share-grant">
<header><h2>"新增分享授权"</h2></header>
<label>
<span>"grantId"</span>
<input data-testid="mnote-admin-share-grant-id" name="shareGrantId" type="text" placeholder="可留空自动生成" />
</label>
<label>
<span>"shareId"</span>
<input data-testid="mnote-admin-share-id" name="shareId" type="text" placeholder="share_xxx" required />
</label>
<label>
<span>"ownerUserId"</span>
<input data-testid="mnote-admin-share-owner-user-id" name="ownerUserId" type="text" placeholder="owner_123" required />
</label>
<label>
<span>"targetUserId"</span>
<input data-testid="mnote-admin-share-target-user-id" name="targetUserId" type="text" placeholder="target_123" required />
</label>
<label>
<span>"rootUri"</span>
<input data-testid="mnote-admin-share-root-uri" name="shareRootUri" type="text" />
</label>
<label>
<span>"rootPath"</span>
<input data-testid="mnote-admin-share-root-path" name="shareRootPath" type="text" />
</label>
<label>
<span>"documentId"</span>
<input data-testid="mnote-admin-share-document-id" name="documentId" type="text" placeholder="doc_123" />
</label>
<label>
<span>"allowedResourceIds"</span>
<input data-testid="mnote-admin-share-resource-ids" name="allowedResourceIds" type="text" placeholder="doc_123,mindmap_456" />
</label>
<label>
<span>"permission"</span>
<select data-testid="mnote-admin-share-permission" name="sharePermission">
<option value="read">"read"</option>
<option value="write">"write"</option>
</select>
</label>
<label>
<span>"capabilities"</span>
<input data-testid="mnote-admin-share-capabilities" name="shareCapabilities" type="text" placeholder="ai,share" />
</label>
<button type="submit" data-testid="mnote-admin-create-share-grant-submit">"创建分享授权"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-create-share-grant-result"></pre>
</form>
<form class="mnote-admin-policy-form" data-admin-form="delete-share-grant">
<header><h2>"撤销分享授权"</h2></header>
<label>
<span>"shareId 或 grantId"</span>
<input data-testid="mnote-admin-delete-share-id" name="deleteShareId" type="text" placeholder="share_xxx" required />
</label>
<button type="submit" data-testid="mnote-admin-delete-share-grant-submit">"撤销"</button>
<pre class="mnote-admin-policy-json" data-testid="mnote-admin-delete-share-grant-result"></pre>
</form>
</section>
<script>{ADMIN_POLICY_SCRIPT}</script> <script>{ADMIN_POLICY_SCRIPT}</script>
</main> </main>
</PageLayout> </PageLayout>
@@ -116,10 +193,15 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
if (!root) return; if (!root) return;
var message = root.querySelector('[data-testid="mnote-admin-policy-message"]'); var message = root.querySelector('[data-testid="mnote-admin-policy-message"]');
var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]'); var policyJson = root.querySelector('[data-testid="mnote-admin-policy-json"]');
var shareGrantsJson = root.querySelector('[data-testid="mnote-admin-share-grants-json"]');
var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]'); var validateResult = root.querySelector('[data-testid="mnote-admin-validate-result"]');
var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]'); var createResult = root.querySelector('[data-testid="mnote-admin-create-result"]');
var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]'); var deleteResult = root.querySelector('[data-testid="mnote-admin-delete-result"]');
var shareGrantsMessage = root.querySelector('[data-testid="mnote-admin-share-grants-message"]');
var createShareGrantResult = root.querySelector('[data-testid="mnote-admin-create-share-grant-result"]');
var deleteShareGrantResult = root.querySelector('[data-testid="mnote-admin-delete-share-grant-result"]');
var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]'); var refreshButton = root.querySelector('[data-admin-action="refresh-policy"]');
var refreshShareGrantsButton = root.querySelector('[data-admin-action="refresh-share-grants"]');
function setText(node, value) { function setText(node, value) {
if (!node) return; if (!node) return;
@@ -143,6 +225,29 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
}; };
} }
function splitList(value) {
return String(value || '')
.split(/[,\s]+/)
.map(function (item) { return item.trim(); })
.filter(Boolean);
}
function shareGrantFormValues(form) {
var data = new FormData(form);
return {
id: String(data.get('shareGrantId') || '').trim(),
shareId: String(data.get('shareId') || '').trim(),
ownerUserId: String(data.get('ownerUserId') || '').trim(),
targetUserId: String(data.get('targetUserId') || '').trim(),
rootUri: String(data.get('shareRootUri') || '').trim(),
rootPath: String(data.get('shareRootPath') || '').trim(),
documentId: String(data.get('documentId') || '').trim(),
allowedResourceIds: splitList(data.get('allowedResourceIds')),
permission: String(data.get('sharePermission') || 'read').trim(),
capabilities: splitList(data.get('shareCapabilities')),
};
}
async function requestJson(url, options) { async function requestJson(url, options) {
var response = await fetch(url, { var response = await fetch(url, {
credentials: 'include', credentials: 'include',
@@ -162,11 +267,22 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
setText(message, ''); setText(message, '');
} }
async function refreshShareGrants() {
var payload = await requestJson('/api/admin/share-grants', { method: 'GET', headers: {} });
setText(shareGrantsJson, payload);
setText(shareGrantsMessage, '');
}
refreshButton && refreshButton.addEventListener('click', function () { refreshButton && refreshButton.addEventListener('click', function () {
setText(message, '...'); setText(message, '...');
refreshPolicy().catch(function (error) { setText(message, error.message || ''); }); refreshPolicy().catch(function (error) { setText(message, error.message || ''); });
}); });
refreshShareGrantsButton && refreshShareGrantsButton.addEventListener('click', function () {
setText(shareGrantsMessage, '...');
refreshShareGrants().catch(function (error) { setText(shareGrantsMessage, error.message || ''); });
});
root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) { root.querySelector('[data-admin-form="validate-root"]').addEventListener('submit', function (event) {
event.preventDefault(); event.preventDefault();
var values = formValues(event.currentTarget); var values = formValues(event.currentTarget);
@@ -218,9 +334,47 @@ const ADMIN_POLICY_SCRIPT: &str = r#"
}); });
}); });
root.querySelector('[data-admin-form="create-share-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var values = shareGrantFormValues(event.currentTarget);
setText(createShareGrantResult, '...');
requestJson('/api/admin/share-grants', {
method: 'POST',
body: JSON.stringify(values),
}).then(function (payload) {
setText(createShareGrantResult, payload);
setText(shareGrantsMessage, '');
return refreshShareGrants();
}).catch(function (error) {
setText(createShareGrantResult, { ok: false, error: error.message || '' });
setText(shareGrantsMessage, error.message || '');
});
});
root.querySelector('[data-admin-form="delete-share-grant"]').addEventListener('submit', function (event) {
event.preventDefault();
var data = new FormData(event.currentTarget);
var shareId = String(data.get('deleteShareId') || '').trim();
setText(deleteShareGrantResult, '...');
requestJson('/api/admin/share-grants/' + encodeURIComponent(shareId), {
method: 'DELETE',
headers: {},
}).then(function (payload) {
setText(deleteShareGrantResult, payload);
setText(shareGrantsMessage, '');
return refreshShareGrants();
}).catch(function (error) {
setText(deleteShareGrantResult, { ok: false, error: error.message || '' });
setText(shareGrantsMessage, error.message || '');
});
});
refreshPolicy().catch(function (error) { refreshPolicy().catch(function (error) {
setText(message, error.message || ''); setText(message, error.message || '');
}); });
refreshShareGrants().catch(function (error) {
setText(shareGrantsMessage, error.message || '');
});
})(); })();
"#; "#;
@@ -239,8 +393,12 @@ mod tests {
}); });
assert!(html.contains("mnote-admin-access-policy-page")); assert!(html.contains("mnote-admin-access-policy-page"));
assert!(html.contains("mnote-admin-policy-json")); assert!(html.contains("mnote-admin-policy-json"));
assert!(html.contains("mnote-admin-share-grants-panel"));
assert!(html.contains("mnote-admin-share-grants-json"));
assert!(html.contains("mnote-admin-validate-root-submit")); assert!(html.contains("mnote-admin-validate-root-submit"));
assert!(html.contains("mnote-admin-create-grant-submit")); assert!(html.contains("mnote-admin-create-grant-submit"));
assert!(html.contains("mnote-admin-delete-grant-submit")); assert!(html.contains("mnote-admin-delete-grant-submit"));
assert!(html.contains("mnote-admin-create-share-grant-submit"));
assert!(html.contains("mnote-admin-delete-share-grant-submit"));
} }
} }