fix(server): migrate old tables (#14954)

This commit is contained in:
DarkSky
2026-05-13 21:57:28 +08:00
committed by GitHub
parent f19a922793
commit 322f2ba986
10 changed files with 2449 additions and 0 deletions
+6
View File
@@ -169,6 +169,8 @@ export interface Chunk {
*/
export declare function createDocWithMarkdown(title: string, markdown: string, docId: string): Buffer
export declare function evaluatePermissionV1(input: any): any
export declare function fetchRemoteAttachment(request: RemoteAttachmentFetchRequest): Promise<RemoteAttachmentFetchResponse>
export declare function fromModelName(modelName: string): Tokenizer | null
@@ -475,6 +477,10 @@ export declare function parsePageDoc(docBin: Buffer, maxSummaryLength?: number |
export declare function parseWorkspaceDoc(docBin: Buffer): NativeWorkspaceDocContent | null
export declare function permissionActionRoleMatrixV1(): any
export declare function permissionActionRoleMatrixV1Json(): string
export declare function processImage(input: Buffer, maxEdge: number, keepExif: boolean): Promise<Buffer>
export type PromptBuiltin = 'Date'|
+1
View File
@@ -9,6 +9,7 @@ pub mod hashcash;
pub mod html_sanitize;
pub mod image;
pub mod llm;
pub mod permission;
pub mod safe_fetch;
pub mod tiktoken;
@@ -0,0 +1,255 @@
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use super::types::{DocRole, WorkspaceRole};
pub(super) const VERSION: u32 = 1;
const WORKSPACE_EXTERNAL_ACTIONS: &[&str] = &[
"Workspace.Read",
"Workspace.Organize.Read",
"Workspace.Properties.Read",
"Workspace.Blobs.Read",
];
const WORKSPACE_MEMBER_ACTIONS: &[&str] = &[
"Workspace.Sync",
"Workspace.CreateDoc",
"Workspace.Users.Read",
"Workspace.Settings.Read",
"Workspace.Blobs.Write",
"Workspace.Blobs.List",
"Workspace.Copilot",
];
const WORKSPACE_ADMIN_ACTIONS: &[&str] = &[
"Workspace.Users.Manage",
"Workspace.Settings.Update",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
];
const WORKSPACE_OWNER_ACTIONS: &[&str] = &[
"Workspace.Delete",
"Workspace.Administrators.Manage",
"Workspace.TransferOwner",
"Workspace.Payment.Manage",
];
const DOC_EXTERNAL_ACTIONS: &[&str] = &["Doc.Read", "Doc.Copy", "Doc.Properties.Read", "Doc.Comments.Read"];
const DOC_READER_ACTIONS: &[&str] = &["Doc.Users.Read", "Doc.Duplicate"];
const DOC_COMMENTER_ACTIONS: &[&str] = &["Doc.Comments.Create"];
const DOC_EDITOR_ACTIONS: &[&str] = &[
"Doc.Trash",
"Doc.Restore",
"Doc.Delete",
"Doc.Properties.Update",
"Doc.Update",
"Doc.Comments.Update",
"Doc.Comments.Resolve",
"Doc.Comments.Delete",
];
const DOC_MANAGER_ACTIONS: &[&str] = &["Doc.Publish", "Doc.Users.Manage"];
const DOC_OWNER_ACTIONS: &[&str] = &["Doc.TransferOwner"];
pub(super) const WORKSPACE_PREVIEW_ACTION: &str = "Workspace.Preview";
pub(super) const DOC_PREVIEW_ACTION: &str = "Doc.Preview";
const WORKSPACE_WRITE_ACTIONS: &[&str] = &[
"Workspace.Sync",
"Workspace.CreateDoc",
"Workspace.Delete",
"Workspace.TransferOwner",
"Workspace.Users.Manage",
"Workspace.Administrators.Manage",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
"Workspace.Settings.Update",
"Workspace.Blobs.Write",
"Workspace.Payment.Manage",
];
const DOC_WRITE_ACTIONS: &[&str] = &[
"Doc.Duplicate",
"Doc.Trash",
"Doc.Restore",
"Doc.Delete",
"Doc.Update",
"Doc.Publish",
"Doc.TransferOwner",
"Doc.Properties.Update",
"Doc.Users.Manage",
"Doc.Comments.Create",
"Doc.Comments.Update",
"Doc.Comments.Delete",
"Doc.Comments.Resolve",
];
fn action_set(groups: &[&[&str]]) -> BTreeSet<String> {
groups
.iter()
.flat_map(|group| group.iter().copied())
.map(str::to_string)
.collect()
}
pub(super) fn workspace_actions_for_role(role: WorkspaceRole) -> BTreeSet<String> {
match role {
WorkspaceRole::External => action_set(&[WORKSPACE_EXTERNAL_ACTIONS]),
WorkspaceRole::Member => action_set(&[WORKSPACE_EXTERNAL_ACTIONS, WORKSPACE_MEMBER_ACTIONS]),
WorkspaceRole::Admin => action_set(&[
WORKSPACE_EXTERNAL_ACTIONS,
WORKSPACE_MEMBER_ACTIONS,
WORKSPACE_ADMIN_ACTIONS,
]),
WorkspaceRole::Owner => action_set(&[
WORKSPACE_EXTERNAL_ACTIONS,
WORKSPACE_MEMBER_ACTIONS,
WORKSPACE_ADMIN_ACTIONS,
WORKSPACE_OWNER_ACTIONS,
]),
}
}
pub(super) fn doc_actions_for_role(role: DocRole) -> BTreeSet<String> {
match role {
DocRole::None => BTreeSet::new(),
DocRole::External => action_set(&[DOC_EXTERNAL_ACTIONS]),
DocRole::Reader => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS]),
DocRole::Commenter => action_set(&[DOC_EXTERNAL_ACTIONS, DOC_READER_ACTIONS, DOC_COMMENTER_ACTIONS]),
DocRole::Editor => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
]),
DocRole::Manager => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
DOC_MANAGER_ACTIONS,
]),
DocRole::Owner => action_set(&[
DOC_EXTERNAL_ACTIONS,
DOC_READER_ACTIONS,
DOC_COMMENTER_ACTIONS,
DOC_EDITOR_ACTIONS,
DOC_MANAGER_ACTIONS,
DOC_OWNER_ACTIONS,
]),
}
}
pub(super) fn is_write_action(action: &str) -> bool {
WORKSPACE_WRITE_ACTIONS.contains(&action) || DOC_WRITE_ACTIONS.contains(&action)
}
pub(super) fn is_readonly_restricted_action(action: &str) -> bool {
matches!(
action,
"Workspace.CreateDoc"
| "Workspace.Settings.Update"
| "Workspace.Properties.Create"
| "Workspace.Properties.Update"
| "Workspace.Properties.Delete"
| "Workspace.Blobs.Write"
| "Doc.Update"
| "Doc.Duplicate"
| "Doc.Publish"
| "Doc.Comments.Create"
| "Doc.Comments.Update"
| "Doc.Comments.Resolve"
)
}
pub(super) fn role_matrix_json() -> Value {
let workspace_roles = [
("external", WorkspaceRole::External),
("member", WorkspaceRole::Member),
("admin", WorkspaceRole::Admin),
("owner", WorkspaceRole::Owner),
]
.into_iter()
.map(|(name, role)| (name, workspace_actions_for_role(role).into_iter().collect::<Vec<_>>()))
.collect::<BTreeMap<_, _>>();
let doc_roles = [
("none", DocRole::None),
("external", DocRole::External),
("reader", DocRole::Reader),
("commenter", DocRole::Commenter),
("editor", DocRole::Editor),
("manager", DocRole::Manager),
("owner", DocRole::Owner),
]
.into_iter()
.map(|(name, role)| (name, doc_actions_for_role(role).into_iter().collect::<Vec<_>>()))
.collect::<BTreeMap<_, _>>();
json!({
"version": VERSION,
"workspace": {
"roles": workspace_roles,
"capabilityProfiles": {
"workspacePreview": [WORKSPACE_PREVIEW_ACTION],
},
"readonlyWriteActions": {
"restricted": [
"Workspace.CreateDoc",
"Workspace.Settings.Update",
"Workspace.Properties.Create",
"Workspace.Properties.Update",
"Workspace.Properties.Delete",
"Workspace.Blobs.Write",
],
},
},
"doc": {
"roles": doc_roles,
"capabilityProfiles": {
"docPreview": [DOC_PREVIEW_ACTION],
},
"readonlyWriteActions": {
"restricted": [
"Doc.Update",
"Doc.Duplicate",
"Doc.Publish",
"Doc.Comments.Create",
"Doc.Comments.Update",
"Doc.Comments.Resolve",
],
},
},
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrix_artifact_exposes_profiles_and_restrictions() {
let artifact = role_matrix_json();
assert_eq!(artifact["version"], 1);
assert_eq!(
artifact["doc"]["capabilityProfiles"]["docPreview"][0],
DOC_PREVIEW_ACTION
);
assert_eq!(
artifact["workspace"]["roles"]["owner"][0],
"Workspace.Administrators.Manage"
);
assert!(
artifact["doc"]["roles"]["external"]
.as_array()
.unwrap()
.contains(&json!("Doc.Read"))
);
}
}
@@ -0,0 +1,305 @@
use std::collections::BTreeSet;
use serde::Serialize;
use super::{
actions::{
DOC_PREVIEW_ACTION, WORKSPACE_PREVIEW_ACTION, doc_actions_for_role, is_readonly_restricted_action, is_write_action,
workspace_actions_for_role,
},
types::{
Candidate, DocRole, PermissionDecisionRestrictionV1, PermissionDecisionSourceV1, PermissionDecisionV1,
PermissionDocInputV1, PermissionEvaluationInputV1, WorkspaceRole,
},
};
pub(super) fn parse_workspace_role(role: &str) -> anyhow::Result<WorkspaceRole> {
match role {
"external" => Ok(WorkspaceRole::External),
"member" => Ok(WorkspaceRole::Member),
"admin" => Ok(WorkspaceRole::Admin),
"owner" => Ok(WorkspaceRole::Owner),
_ => anyhow::bail!("unknown workspace role: {role}"),
}
}
fn parse_doc_role(role: &str) -> anyhow::Result<DocRole> {
match role {
"none" => Ok(DocRole::None),
"external" => Ok(DocRole::External),
"reader" => Ok(DocRole::Reader),
"commenter" => Ok(DocRole::Commenter),
"editor" => Ok(DocRole::Editor),
"manager" => Ok(DocRole::Manager),
"owner" => Ok(DocRole::Owner),
_ => anyhow::bail!("unknown doc role: {role}"),
}
}
pub(super) fn role_name(role: impl Serialize) -> String {
serde_json::to_value(role)
.ok()
.and_then(|value| value.as_str().map(str::to_string))
.unwrap_or_default()
}
fn active_workspace_role(input: &PermissionEvaluationInputV1) -> anyhow::Result<Option<WorkspaceRole>> {
let Some(role) = input.workspace.role.as_deref() else {
if input.workspace.local && input.subject.allow_local {
return Ok(Some(WorkspaceRole::Owner));
}
if input.workspace.public && sharing_enabled(input, None) {
return Ok(Some(WorkspaceRole::External));
}
return Ok(None);
};
if input.workspace.member_state.as_deref().unwrap_or("active") != "active" {
return Ok(None);
}
let role = parse_workspace_role(role)?;
if role == WorkspaceRole::External {
return Ok(None);
}
Ok(Some(role))
}
fn sharing_enabled(input: &PermissionEvaluationInputV1, doc: Option<&PermissionDocInputV1>) -> bool {
doc
.and_then(|doc| doc.sharing_enabled)
.or(input.runtime.sharing_enabled)
.or(input.workspace.sharing_enabled)
.unwrap_or(true)
}
fn url_preview_enabled(input: &PermissionEvaluationInputV1) -> bool {
input
.runtime
.url_preview_enabled
.or(input.workspace.url_preview_enabled)
.unwrap_or(false)
}
fn restricted_decision(input: &PermissionEvaluationInputV1, action: &str) -> Vec<PermissionDecisionRestrictionV1> {
if !is_write_action(action) {
return Vec::new();
}
let mut restrictions = Vec::new();
if !input.runtime.known {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "runtime_unknown",
reason: None,
});
}
if input.runtime.stale {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "runtime_stale",
reason: None,
});
}
if input.runtime.readonly && is_readonly_restricted_action(action) {
restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "readonly",
reason: input.runtime.readonly_reason.clone(),
});
}
restrictions
}
pub(super) fn decide(
input: &PermissionEvaluationInputV1,
action: &str,
candidates: &[Candidate],
) -> PermissionDecisionV1 {
let sources = candidates
.iter()
.filter(|candidate| candidate.actions.contains(action))
.map(|candidate| PermissionDecisionSourceV1 {
source_type: candidate.source_type,
role: Some(candidate.role.clone()),
})
.collect::<Vec<_>>();
let restrictions = restricted_decision(input, action);
PermissionDecisionV1 {
action: action.to_string(),
allowed: !sources.is_empty() && restrictions.is_empty(),
sources,
restrictions,
}
}
pub(super) fn decide_doc(
input: &PermissionEvaluationInputV1,
doc: &PermissionDocInputV1,
action: &str,
candidates: &[Candidate],
) -> PermissionDecisionV1 {
let mut decision = decide(input, action, candidates);
if action == "Doc.Publish" && !sharing_enabled(input, Some(doc)) {
decision.restrictions.push(PermissionDecisionRestrictionV1 {
restriction_type: "sharing-disabled",
reason: None,
});
decision.allowed = false;
}
decision
}
pub(super) fn workspace_candidates(input: &PermissionEvaluationInputV1) -> anyhow::Result<Vec<Candidate>> {
let mut candidates = Vec::new();
if let Some(role) = active_workspace_role(input)? {
candidates.push(Candidate {
source_type: "workspace-member",
role: role_name(role),
actions: workspace_actions_for_role(role),
owner: role == WorkspaceRole::Owner,
});
}
if input.legacy_compat_mode && input.subject.allow_local && input.workspace.local {
candidates.push(Candidate {
source_type: "local-workspace",
role: "owner".to_string(),
actions: workspace_actions_for_role(WorkspaceRole::Owner),
owner: true,
});
}
if input.workspace.public && sharing_enabled(input, None) {
candidates.push(Candidate {
source_type: "workspace-policy",
role: "external".to_string(),
actions: workspace_actions_for_role(WorkspaceRole::External),
owner: false,
});
}
if sharing_enabled(input, None) && (input.workspace.public || url_preview_enabled(input)) {
candidates.push(Candidate {
source_type: "workspace-preview-policy",
role: "preview".to_string(),
actions: BTreeSet::from([WORKSPACE_PREVIEW_ACTION.to_string()]),
owner: false,
});
}
Ok(candidates)
}
pub(super) fn best_doc_role(candidates: &[Candidate]) -> Option<String> {
candidates
.iter()
.filter_map(|candidate| parse_doc_role(&candidate.role).ok())
.filter(|role| *role != DocRole::None)
.max()
.map(role_name)
}
pub(super) fn doc_candidates(
input: &PermissionEvaluationInputV1,
doc: &PermissionDocInputV1,
) -> anyhow::Result<Vec<Candidate>> {
let mut candidates = Vec::new();
let active_workspace_role = active_workspace_role(input)?;
let active_workspace_member = matches!(
active_workspace_role,
Some(WorkspaceRole::Member | WorkspaceRole::Admin | WorkspaceRole::Owner)
);
let sharing = sharing_enabled(input, Some(doc));
match active_workspace_role {
Some(WorkspaceRole::Owner) => candidates.push(Candidate {
source_type: "inherited-workspace-role",
role: "owner".to_string(),
actions: doc_actions_for_role(DocRole::Owner),
owner: false,
}),
Some(WorkspaceRole::Admin) => candidates.push(Candidate {
source_type: "inherited-workspace-role",
role: "manager".to_string(),
actions: doc_actions_for_role(DocRole::Manager),
owner: false,
}),
_ => {}
}
let explicit_user_role = doc
.explicit_user_role
.as_deref()
.map(parse_doc_role)
.transpose()?
.filter(|role| *role != DocRole::None);
if let Some(mut role) = explicit_user_role {
if !active_workspace_member {
role = role.min(DocRole::Editor);
}
if active_workspace_member || sharing {
candidates.push(Candidate {
source_type: "doc-grant",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: role == DocRole::Owner,
});
}
}
if doc.group_grants_enabled && !input.subject.group_ids.is_empty() {
let subject_groups = input.subject.group_ids.iter().collect::<BTreeSet<_>>();
for grant in &doc.group_grants {
if subject_groups.contains(&grant.group_id) {
let role = parse_doc_role(&grant.role)?;
candidates.push(Candidate {
source_type: "group-grant",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
}
}
if matches!(active_workspace_role, Some(role) if role != WorkspaceRole::External)
&& explicit_user_role.is_none()
&& let Some(role) = doc.member_default_role.as_deref()
{
let role = parse_doc_role(role)?;
candidates.push(Candidate {
source_type: "member-default-policy",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
if sharing
&& doc.visibility.as_deref() == Some("public")
&& let Some(role) = doc.public_role.as_deref()
{
let role = parse_doc_role(role)?;
candidates.push(Candidate {
source_type: "public-policy",
role: role_name(role),
actions: doc_actions_for_role(role),
owner: false,
});
}
if sharing && (doc.preview_enabled || doc.visibility.as_deref() == Some("public") || url_preview_enabled(input)) {
candidates.push(Candidate {
source_type: "doc-preview-policy",
role: "preview".to_string(),
actions: BTreeSet::from([DOC_PREVIEW_ACTION.to_string()]),
owner: false,
});
}
if !sharing {
for candidate in &mut candidates {
candidate.actions.remove("Doc.Publish");
}
}
Ok(candidates)
}
@@ -0,0 +1,368 @@
use super::{
actions::VERSION,
candidates::{
best_doc_role, decide, decide_doc, doc_candidates, parse_workspace_role, role_name, workspace_candidates,
},
types::{
PermissionDocEvaluationOutputV1, PermissionEvaluationInputV1, PermissionEvaluationOutputV1,
PermissionWorkspaceEvaluationOutputV1,
},
};
pub fn evaluate_permission(input: PermissionEvaluationInputV1) -> anyhow::Result<PermissionEvaluationOutputV1> {
if input.version != VERSION {
anyhow::bail!("unsupported permission evaluation input version: {}", input.version);
}
let workspace_candidates = workspace_candidates(&input)?;
let workspace_decisions = input
.workspace_actions
.iter()
.map(|action| decide(&input, action, &workspace_candidates))
.collect::<Vec<_>>();
let workspace_effective_role = workspace_candidates
.iter()
.filter_map(|candidate| parse_workspace_role(&candidate.role).ok())
.max()
.map(role_name);
let workspace_resource_owner_role = workspace_candidates
.iter()
.any(|candidate| candidate.owner)
.then(|| "owner".to_string());
let mut docs = Vec::with_capacity(input.docs.len());
for doc in &input.docs {
let candidates = doc_candidates(&input, doc)?;
let decisions = doc
.actions
.iter()
.map(|action| decide_doc(&input, doc, action, &candidates))
.collect::<Vec<_>>();
let resource_owner_role = candidates
.iter()
.any(|candidate| candidate.owner)
.then(|| "owner".to_string());
docs.push(PermissionDocEvaluationOutputV1 {
doc_id: doc.doc_id.clone(),
resource_owner_role,
effective_role: best_doc_role(&candidates),
decisions,
});
}
Ok(PermissionEvaluationOutputV1 {
version: VERSION,
workspace: PermissionWorkspaceEvaluationOutputV1 {
resource_owner_role: workspace_resource_owner_role,
effective_role: workspace_effective_role,
decisions: workspace_decisions,
},
docs,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::types::{
PermissionDecisionV1, PermissionDocInputV1, PermissionGroupGrantInputV1, PermissionRuntimeInputV1,
PermissionSubjectInputV1, PermissionWorkspaceInputV1,
};
fn base_input() -> PermissionEvaluationInputV1 {
PermissionEvaluationInputV1 {
version: 1,
legacy_compat_mode: false,
subject: PermissionSubjectInputV1::default(),
runtime: PermissionRuntimeInputV1 {
known: true,
sharing_enabled: Some(true),
..Default::default()
},
workspace: PermissionWorkspaceInputV1 {
role: Some("member".to_string()),
member_state: Some("active".to_string()),
sharing_enabled: Some(true),
..Default::default()
},
workspace_actions: vec!["Workspace.Read".to_string(), "Workspace.CreateDoc".to_string()],
docs: vec![PermissionDocInputV1 {
doc_id: "doc".to_string(),
actions: vec!["Doc.Read".to_string(), "Doc.Update".to_string()],
member_default_role: Some("manager".to_string()),
..Default::default()
}],
}
}
fn decision<'a>(decisions: &'a [PermissionDecisionV1], action: &str) -> &'a PermissionDecisionV1 {
decisions.iter().find(|decision| decision.action == action).unwrap()
}
#[test]
fn active_member_role_authorizes_workspace_and_doc_default() {
let output = evaluate_permission(base_input()).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(decision(&output.workspace.decisions, "Workspace.CreateDoc").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
#[test]
fn pending_and_waiting_members_do_not_authorize() {
for state in ["pending", "waiting_review", "waiting_seat"] {
let mut input = base_input();
input.workspace.member_state = Some(state.to_string());
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
}
#[test]
fn owner_and_admin_inherit_doc_permissions_without_doc_ownership_pollution() {
let mut owner_input = base_input();
owner_input.workspace.role = Some("owner".to_string());
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
let owner_output = evaluate_permission(owner_input).unwrap();
let owner_doc = &owner_output.docs[0];
assert!(decision(&owner_doc.decisions, "Doc.TransferOwner").allowed);
assert_eq!(owner_doc.resource_owner_role, None);
assert_eq!(owner_doc.effective_role.as_deref(), Some("owner"));
let mut admin_input = base_input();
admin_input.workspace.role = Some("admin".to_string());
admin_input.docs[0].actions = vec!["Doc.Users.Manage".to_string(), "Doc.TransferOwner".to_string()];
let admin_output = evaluate_permission(admin_input).unwrap();
assert!(decision(&admin_output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&admin_output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(admin_output.docs[0].resource_owner_role, None);
}
#[test]
fn explicit_doc_grant_sets_resource_owner_only_for_owner_grant() {
let mut input = base_input();
input.docs[0].explicit_user_role = Some("reader".to_string());
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert_eq!(output.docs[0].resource_owner_role, None);
let mut owner_input = base_input();
owner_input.docs[0].explicit_user_role = Some("owner".to_string());
owner_input.docs[0].actions = vec!["Doc.TransferOwner".to_string()];
let owner_output = evaluate_permission(owner_input).unwrap();
assert!(decision(&owner_output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(owner_output.docs[0].resource_owner_role.as_deref(), Some("owner"));
}
#[test]
fn explicit_none_legacy_row_behaves_like_missing_grant() {
let mut input = base_input();
input.docs[0].explicit_user_role = Some("none".to_string());
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
let update = decision(&output.docs[0].decisions, "Doc.Update");
assert!(update.allowed);
assert_eq!(update.sources[0].source_type, "member-default-policy");
}
#[test]
fn non_member_explicit_doc_grant_is_capped_at_editor() {
let mut input = base_input();
input.workspace.role = None;
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn legacy_external_workspace_row_does_not_uncap_explicit_doc_grant() {
let mut input = base_input();
input.workspace.role = Some("external".to_string());
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn public_workspace_policy_does_not_uncap_explicit_doc_grant() {
let mut input = base_input();
input.workspace.role = None;
input.workspace.public = true;
input.docs[0].explicit_user_role = Some("owner".to_string());
input.docs[0].actions = vec![
"Doc.Update".to_string(),
"Doc.Users.Manage".to_string(),
"Doc.TransferOwner".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Update").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Manage").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.TransferOwner").allowed);
assert_eq!(output.docs[0].effective_role.as_deref(), Some("editor"));
assert_eq!(output.docs[0].resource_owner_role, None);
}
#[test]
fn member_default_none_unions_with_public_policy() {
let mut input = base_input();
input.docs[0].member_default_role = Some("none".to_string());
input.docs[0].visibility = Some("public".to_string());
input.docs[0].public_role = Some("external".to_string());
input.docs[0].actions = vec![
"Doc.Read".to_string(),
"Doc.Users.Read".to_string(),
"Doc.Duplicate".to_string(),
];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Users.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Duplicate").allowed);
}
#[test]
fn public_doc_external_profile_and_url_preview_do_not_grant_read() {
let mut public_input = base_input();
public_input.workspace.role = None;
public_input.docs[0].visibility = Some("public".to_string());
public_input.docs[0].public_role = Some("external".to_string());
public_input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Users.Read".to_string()];
let public_output = evaluate_permission(public_input).unwrap();
assert!(decision(&public_output.docs[0].decisions, "Doc.Read").allowed);
assert!(!decision(&public_output.docs[0].decisions, "Doc.Users.Read").allowed);
let mut preview_input = base_input();
preview_input.workspace.role = None;
preview_input.runtime.url_preview_enabled = Some(true);
preview_input.docs[0].actions = vec!["Doc.Preview".to_string(), "Doc.Read".to_string()];
let preview_output = evaluate_permission(preview_input).unwrap();
assert!(decision(&preview_output.docs[0].decisions, "Doc.Preview").allowed);
assert!(!decision(&preview_output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn public_workspace_shell_does_not_grant_private_doc_read() {
let mut input = base_input();
input.workspace.role = None;
input.workspace.public = true;
input.workspace_actions = vec!["Workspace.Read".to_string()];
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].visibility = Some("private".to_string());
input.docs[0].public_role = None;
input.docs[0].actions = vec!["Doc.Read".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Read").allowed);
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn sharing_disabled_blocks_public_and_non_member_explicit_sources() {
let mut input = base_input();
input.workspace.role = None;
input.runtime.sharing_enabled = Some(false);
input.docs[0].visibility = Some("public".to_string());
input.docs[0].public_role = Some("external".to_string());
input.docs[0].explicit_user_role = Some("reader".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.docs[0].decisions, "Doc.Read").allowed);
}
#[test]
fn doc_publish_requires_sharing_enabled() {
let mut input = base_input();
input.docs[0].member_default_role = Some("manager".to_string());
input.docs[0].actions = vec!["Doc.Publish".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Publish").allowed);
let mut disabled_input = base_input();
disabled_input.runtime.sharing_enabled = Some(false);
disabled_input.docs[0].member_default_role = Some("manager".to_string());
disabled_input.docs[0].actions = vec!["Doc.Publish".to_string()];
let disabled_output = evaluate_permission(disabled_input).unwrap();
let publish = decision(&disabled_output.docs[0].decisions, "Doc.Publish");
assert!(!publish.allowed);
assert_eq!(publish.restrictions[0].restriction_type, "sharing-disabled");
}
#[test]
fn readonly_and_unknown_runtime_fail_closed_for_write_actions() {
let mut input = base_input();
input.runtime.readonly = true;
input.runtime.readonly_reason = Some("storage_overflow".to_string());
input.docs[0].actions = vec!["Doc.Read".to_string(), "Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.docs[0].decisions, "Doc.Read").allowed);
let update = decision(&output.docs[0].decisions, "Doc.Update");
assert!(!update.allowed);
assert_eq!(update.restrictions[0].restriction_type, "readonly");
let mut unknown_input = base_input();
unknown_input.runtime.known = false;
let unknown_output = evaluate_permission(unknown_input).unwrap();
assert!(!decision(&unknown_output.workspace.decisions, "Workspace.CreateDoc").allowed);
let mut stale_input = base_input();
stale_input.runtime.stale = true;
let stale_output = evaluate_permission(stale_input).unwrap();
let create_doc = decision(&stale_output.workspace.decisions, "Workspace.CreateDoc");
assert!(!create_doc.allowed);
assert_eq!(create_doc.restrictions[0].restriction_type, "runtime_stale");
}
#[test]
fn legacy_local_workspace_fallback_is_opt_in() {
let mut input = base_input();
input.legacy_compat_mode = true;
input.subject.allow_local = true;
input.workspace = PermissionWorkspaceInputV1 {
local: true,
..Default::default()
};
input.workspace_actions = vec!["Workspace.Delete".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(decision(&output.workspace.decisions, "Workspace.Delete").allowed);
}
#[test]
fn empty_group_ids_do_not_enable_group_grants() {
let mut input = base_input();
input.docs[0].member_default_role = Some("none".to_string());
input.docs[0].group_grants_enabled = true;
input.docs[0].group_grants = vec![PermissionGroupGrantInputV1 {
group_id: "group".to_string(),
role: "manager".to_string(),
}];
input.docs[0].actions = vec!["Doc.Update".to_string()];
let output = evaluate_permission(input).unwrap();
assert!(!decision(&output.docs[0].decisions, "Doc.Update").allowed);
}
}
@@ -0,0 +1,30 @@
mod actions;
mod candidates;
mod evaluator;
mod types;
use actions::role_matrix_json;
pub use evaluator::evaluate_permission;
use napi::{Error as NapiError, Result, Status};
use napi_derive::napi;
use serde_json::Value;
pub use types::*;
#[napi]
pub fn evaluate_permission_v1(input: Value) -> Result<Value> {
let input = serde_json::from_value::<PermissionEvaluationInputV1>(input)
.map_err(|err| NapiError::new(Status::InvalidArg, err.to_string()))?;
evaluate_permission(input)
.and_then(|output| serde_json::to_value(output).map_err(Into::into))
.map_err(|err| NapiError::new(Status::GenericFailure, err.to_string()))
}
#[napi]
pub fn permission_action_role_matrix_v1() -> Value {
role_matrix_json()
}
#[napi]
pub fn permission_action_role_matrix_v1_json() -> String {
serde_json::to_string_pretty(&role_matrix_json()).unwrap_or_else(|_| "{}".to_string())
}
@@ -0,0 +1,182 @@
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum WorkspaceRole {
External,
Member,
Admin,
Owner,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(super) enum DocRole {
None,
External,
Reader,
Commenter,
Editor,
Manager,
Owner,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionSubjectInputV1 {
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub group_ids: Vec<String>,
#[serde(default)]
pub allow_local: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionRuntimeInputV1 {
#[serde(default)]
pub known: bool,
#[serde(default)]
pub stale: bool,
#[serde(default)]
pub readonly: bool,
#[serde(default)]
pub readonly_reason: Option<String>,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub url_preview_enabled: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionWorkspaceInputV1 {
#[serde(default)]
pub role: Option<String>,
#[serde(default)]
pub member_state: Option<String>,
#[serde(default)]
pub public: bool,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub url_preview_enabled: Option<bool>,
#[serde(default)]
pub local: bool,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionGroupGrantInputV1 {
pub group_id: String,
pub role: String,
}
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDocInputV1 {
pub doc_id: String,
#[serde(default)]
pub actions: Vec<String>,
#[serde(default)]
pub explicit_user_role: Option<String>,
#[serde(default)]
pub group_grants: Vec<PermissionGroupGrantInputV1>,
#[serde(default)]
pub group_grants_enabled: bool,
#[serde(default)]
pub member_default_role: Option<String>,
#[serde(default)]
pub public_role: Option<String>,
#[serde(default)]
pub visibility: Option<String>,
#[serde(default)]
pub sharing_enabled: Option<bool>,
#[serde(default)]
pub preview_enabled: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionEvaluationInputV1 {
pub version: u32,
#[serde(default)]
pub legacy_compat_mode: bool,
#[serde(default)]
pub subject: PermissionSubjectInputV1,
#[serde(default)]
pub runtime: PermissionRuntimeInputV1,
#[serde(default)]
pub workspace: PermissionWorkspaceInputV1,
#[serde(default)]
pub workspace_actions: Vec<String>,
#[serde(default)]
pub docs: Vec<PermissionDocInputV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionSourceV1 {
#[serde(rename = "type")]
pub source_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionRestrictionV1 {
#[serde(rename = "type")]
pub restriction_type: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDecisionV1 {
pub action: String,
pub allowed: bool,
pub sources: Vec<PermissionDecisionSourceV1>,
pub restrictions: Vec<PermissionDecisionRestrictionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionWorkspaceEvaluationOutputV1 {
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_owner_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_role: Option<String>,
pub decisions: Vec<PermissionDecisionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionDocEvaluationOutputV1 {
pub doc_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource_owner_role: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub effective_role: Option<String>,
pub decisions: Vec<PermissionDecisionV1>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PermissionEvaluationOutputV1 {
pub version: u32,
pub workspace: PermissionWorkspaceEvaluationOutputV1,
pub docs: Vec<PermissionDocEvaluationOutputV1>,
}
#[derive(Clone)]
pub(super) struct Candidate {
pub source_type: &'static str,
pub role: String,
pub actions: BTreeSet<String>,
pub owner: bool,
}
+127
View File
@@ -28,6 +28,9 @@ model User {
features UserFeature[]
userStripeCustomer UserStripeCustomer?
workspaces WorkspaceUserRole[]
workspaceMembers WorkspaceMember[]
workspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_invitee")
createdWorkspaceInvitations WorkspaceInvitation[] @relation("workspace_invitation_inviter")
// Invite others to join the workspace
WorkspaceInvitations WorkspaceUserRole[] @relation("inviter")
docPermissions WorkspaceDocUserRole[]
@@ -157,12 +160,136 @@ model Workspace {
workspaceAdminStatsDaily WorkspaceAdminStatsDaily[]
workspaceDocViewDaily WorkspaceDocViewDaily[]
workspaceMemberLastAccess WorkspaceMemberLastAccess[]
runtimeState WorkspaceRuntimeState?
accessPolicy WorkspaceAccessPolicy?
projectedMembers WorkspaceMember[]
projectedInvitations WorkspaceInvitation[]
docAccessPolicies DocAccessPolicy[]
docGrants DocGrant[]
@@index([lastCheckEmbeddings])
@@index([createdAt])
@@map("workspaces")
}
model WorkspaceRuntimeState {
workspaceId String @id @map("workspace_id") @db.VarChar
known Boolean @default(false)
readonly Boolean @default(false)
readonlyReasons String[] @default([]) @map("readonly_reasons")
lastReconciledAt DateTime? @map("last_reconciled_at") @db.Timestamptz(3)
staleAfter DateTime? @map("stale_after") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@map("workspace_runtime_states")
}
model WorkspaceMember {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
userId String @map("user_id") @db.VarChar
role String @db.Text
state String @default("active") @db.Text
source String @default("legacy") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, state])
@@index([workspaceId, role, state])
@@unique([workspaceId, userId, state])
@@map("workspace_members")
}
model WorkspaceInvitation {
id String @id @default(uuid()) @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
inviteeUserId String? @map("invitee_user_id") @db.VarChar
normalizedEmail String? @map("normalized_email") @db.VarChar
inviterUserId String? @map("inviter_user_id") @db.VarChar
requestedRole String @default("member") @map("requested_role") @db.Text
status String @db.Text
kind String @default("email") @db.Text
tokenHash String? @unique(map: "workspace_invitations_token_hash_key") @map("token_hash") @db.Text
legacyPermissionId String? @map("legacy_permission_id") @db.VarChar
expiresAt DateTime? @map("expires_at") @db.Timestamptz(3)
acceptedAt DateTime? @map("accepted_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
inviteeUser User? @relation("workspace_invitation_invitee", fields: [inviteeUserId], references: [id], onDelete: SetNull)
inviter User? @relation("workspace_invitation_inviter", fields: [inviterUserId], references: [id], onDelete: SetNull)
@@index([workspaceId, status])
@@index([inviteeUserId, status])
@@index([workspaceId, normalizedEmail, status])
@@unique([workspaceId, inviteeUserId])
@@map("workspace_invitations")
}
model WorkspaceAccessPolicy {
workspaceId String @id @map("workspace_id") @db.VarChar
visibility String @default("private") @db.Text
sharingEnabled Boolean @default(true) @map("sharing_enabled")
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
memberDefaultDocRole String @default("manager") @map("member_default_doc_role") @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@index([visibility])
@@index([urlPreviewEnabled, sharingEnabled])
@@map("workspace_access_policies")
}
model DocAccessPolicy {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
visibility String @default("private") @db.Text
publicRole String? @map("public_role") @db.Text
memberDefaultRole String? @map("member_default_role") @db.Text
urlPreviewEnabled Boolean @default(false) @map("url_preview_enabled")
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId])
@@index([workspaceId, docId])
@@map("doc_access_policies")
}
model DocGrant {
workspaceId String @map("workspace_id") @db.VarChar
docId String @map("doc_id") @db.VarChar
principalType String @map("principal_type") @db.Text
principalId String @map("principal_id") @db.VarChar
role String @db.Text
grantedBy String? @map("granted_by") @db.VarChar
legacyWorkspaceId String? @map("legacy_workspace_id") @db.VarChar
legacyDocId String? @map("legacy_doc_id") @db.VarChar
legacyUserId String? @map("legacy_user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@id([workspaceId, docId, principalType, principalId])
@@unique([legacyWorkspaceId, legacyDocId, legacyUserId])
@@index([principalType, principalId, role])
@@index([workspaceId, docId, role])
@@map("doc_grants")
}
// Table for workspace page meta data
// NOTE:
// We won't make sure every page has a corresponding record in this table.
+98
View File
@@ -152,6 +152,104 @@ export const AFFINE_PRO_PUBLIC_KEY = serverNativeModule.AFFINE_PRO_PUBLIC_KEY;
export const AFFINE_PRO_LICENSE_AES_KEY =
serverNativeModule.AFFINE_PRO_LICENSE_AES_KEY;
export type PermissionWorkspaceRole = 'external' | 'member' | 'admin' | 'owner';
export type PermissionDocRole =
| 'none'
| 'external'
| 'reader'
| 'commenter'
| 'editor'
| 'manager'
| 'owner';
export type PermissionEvaluationInputV1 = {
version: 1;
legacyCompatMode?: boolean;
subject?: {
userId?: string;
groupIds?: string[];
allowLocal?: boolean;
};
runtime?: {
known?: boolean;
stale?: boolean;
readonly?: boolean;
readonlyReason?: string;
sharingEnabled?: boolean;
urlPreviewEnabled?: boolean;
};
workspace?: {
role?: PermissionWorkspaceRole;
memberState?: 'active' | 'pending' | 'waiting_review' | 'waiting_seat';
public?: boolean;
sharingEnabled?: boolean;
urlPreviewEnabled?: boolean;
local?: boolean;
};
workspaceActions?: string[];
docs?: Array<{
docId: string;
actions?: string[];
explicitUserRole?: PermissionDocRole;
groupGrants?: Array<{ groupId: string; role: PermissionDocRole }>;
groupGrantsEnabled?: boolean;
memberDefaultRole?: PermissionDocRole;
publicRole?: 'external';
visibility?: 'private' | 'public';
sharingEnabled?: boolean;
previewEnabled?: boolean;
}>;
};
export type PermissionDecisionV1 = {
action: string;
allowed: boolean;
sources: Array<{
type:
| 'workspace-member'
| 'workspace-policy'
| 'workspace-preview-policy'
| 'local-workspace'
| 'inherited-workspace-role'
| 'doc-grant'
| 'group-grant'
| 'member-default-policy'
| 'public-policy'
| 'doc-preview-policy';
role?: string;
}>;
restrictions: Array<{
type: 'runtime_unknown' | 'runtime_stale' | 'readonly' | 'sharing-disabled';
reason?: string;
}>;
};
export type PermissionEvaluationOutputV1 = {
version: 1;
workspace: {
resourceOwnerRole?: PermissionWorkspaceRole;
effectiveRole?: PermissionWorkspaceRole;
decisions: PermissionDecisionV1[];
};
docs: Array<{
docId: string;
resourceOwnerRole?: 'owner';
effectiveRole?: PermissionDocRole;
decisions: PermissionDecisionV1[];
}>;
};
export const evaluatePermissionV1 = (
input: PermissionEvaluationInputV1
): PermissionEvaluationOutputV1 =>
serverNativeModule.evaluatePermissionV1(input);
export const permissionActionRoleMatrixV1 = (): unknown =>
serverNativeModule.permissionActionRoleMatrixV1();
export const permissionActionRoleMatrixV1Json =
serverNativeModule.permissionActionRoleMatrixV1Json;
// MCP write tools exports
export const createDocWithMarkdown = serverNativeModule.createDocWithMarkdown;
export const updateDocWithMarkdown = serverNativeModule.updateDocWithMarkdown;