diff --git a/packages/backend/native/index.d.ts b/packages/backend/native/index.d.ts index 161f6a4380..348f8a0912 100644 --- a/packages/backend/native/index.d.ts +++ b/packages/backend/native/index.d.ts @@ -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 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 export type PromptBuiltin = 'Date'| diff --git a/packages/backend/native/src/lib.rs b/packages/backend/native/src/lib.rs index c9f7b11553..de38586786 100644 --- a/packages/backend/native/src/lib.rs +++ b/packages/backend/native/src/lib.rs @@ -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; diff --git a/packages/backend/native/src/permission/actions.rs b/packages/backend/native/src/permission/actions.rs new file mode 100644 index 0000000000..7896e367a5 --- /dev/null +++ b/packages/backend/native/src/permission/actions.rs @@ -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 { + groups + .iter() + .flat_map(|group| group.iter().copied()) + .map(str::to_string) + .collect() +} + +pub(super) fn workspace_actions_for_role(role: WorkspaceRole) -> BTreeSet { + 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 { + 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::>())) + .collect::>(); + + 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::>())) + .collect::>(); + + 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")) + ); + } +} diff --git a/packages/backend/native/src/permission/candidates.rs b/packages/backend/native/src/permission/candidates.rs new file mode 100644 index 0000000000..ef55ad02ef --- /dev/null +++ b/packages/backend/native/src/permission/candidates.rs @@ -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 { + 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 { + 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> { + 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 { + 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::>(); + 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> { + 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 { + 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> { + 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::>(); + 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) +} diff --git a/packages/backend/native/src/permission/evaluator.rs b/packages/backend/native/src/permission/evaluator.rs new file mode 100644 index 0000000000..63efd78bfb --- /dev/null +++ b/packages/backend/native/src/permission/evaluator.rs @@ -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 { + 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::>(); + 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::>(); + 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); + } +} diff --git a/packages/backend/native/src/permission/mod.rs b/packages/backend/native/src/permission/mod.rs new file mode 100644 index 0000000000..f85805b615 --- /dev/null +++ b/packages/backend/native/src/permission/mod.rs @@ -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 { + let input = serde_json::from_value::(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()) +} diff --git a/packages/backend/native/src/permission/types.rs b/packages/backend/native/src/permission/types.rs new file mode 100644 index 0000000000..6e9501ece8 --- /dev/null +++ b/packages/backend/native/src/permission/types.rs @@ -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, + #[serde(default)] + pub group_ids: Vec, + #[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, + #[serde(default)] + pub sharing_enabled: Option, + #[serde(default)] + pub url_preview_enabled: Option, +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionWorkspaceInputV1 { + #[serde(default)] + pub role: Option, + #[serde(default)] + pub member_state: Option, + #[serde(default)] + pub public: bool, + #[serde(default)] + pub sharing_enabled: Option, + #[serde(default)] + pub url_preview_enabled: Option, + #[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, + #[serde(default)] + pub explicit_user_role: Option, + #[serde(default)] + pub group_grants: Vec, + #[serde(default)] + pub group_grants_enabled: bool, + #[serde(default)] + pub member_default_role: Option, + #[serde(default)] + pub public_role: Option, + #[serde(default)] + pub visibility: Option, + #[serde(default)] + pub sharing_enabled: Option, + #[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, + #[serde(default)] + pub docs: Vec, +} + +#[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, +} + +#[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, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionDecisionV1 { + pub action: String, + pub allowed: bool, + pub sources: Vec, + pub restrictions: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_role: Option, + pub decisions: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub effective_role: Option, + pub decisions: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionEvaluationOutputV1 { + pub version: u32, + pub workspace: PermissionWorkspaceEvaluationOutputV1, + pub docs: Vec, +} + +#[derive(Clone)] +pub(super) struct Candidate { + pub source_type: &'static str, + pub role: String, + pub actions: BTreeSet, + pub owner: bool, +} diff --git a/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql b/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql new file mode 100644 index 0000000000..d499488cc5 --- /dev/null +++ b/packages/backend/server/migrations/20260512133700_workspace_runtime_states/migration.sql @@ -0,0 +1,1077 @@ +-- CreateTable +CREATE TABLE "workspace_runtime_states" ( + "workspace_id" VARCHAR NOT NULL, + "known" BOOLEAN NOT NULL DEFAULT false, + "readonly" BOOLEAN NOT NULL DEFAULT false, + "readonly_reasons" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + "last_reconciled_at" TIMESTAMPTZ(3), + "stale_after" TIMESTAMPTZ(3), + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_runtime_states_pkey" PRIMARY KEY ("workspace_id") +); + +-- CreateTable +CREATE TABLE "workspace_members" ( + "id" VARCHAR NOT NULL DEFAULT ('perm_' || md5(random()::text || clock_timestamp()::text)), + "workspace_id" VARCHAR NOT NULL, + "user_id" VARCHAR NOT NULL, + "role" TEXT NOT NULL, + "state" TEXT NOT NULL DEFAULT 'active', + "source" TEXT NOT NULL DEFAULT 'legacy', + "legacy_permission_id" VARCHAR, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_members_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workspace_members_role_check" CHECK ("role" IN ('owner', 'admin', 'member')), + CONSTRAINT "workspace_members_state_check" CHECK ("state" IN ('active', 'suspended', 'left')), + CONSTRAINT "workspace_members_source_check" CHECK ("source" IN ('legacy', 'email', 'link', 'system')) +); + +-- CreateTable +CREATE TABLE "workspace_invitations" ( + "id" VARCHAR NOT NULL DEFAULT ('perm_' || md5(random()::text || clock_timestamp()::text)), + "workspace_id" VARCHAR NOT NULL, + "invitee_user_id" VARCHAR, + "normalized_email" VARCHAR, + "inviter_user_id" VARCHAR, + "requested_role" TEXT NOT NULL DEFAULT 'member', + "status" TEXT NOT NULL, + "kind" TEXT NOT NULL DEFAULT 'email', + "token_hash" TEXT, + "legacy_permission_id" VARCHAR, + "expires_at" TIMESTAMPTZ(3), + "accepted_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_invitations_pkey" PRIMARY KEY ("id"), + CONSTRAINT "workspace_invitations_requested_role_check" CHECK ("requested_role" IN ('admin', 'member')), + CONSTRAINT "workspace_invitations_status_check" CHECK ("status" IN ('pending', 'waiting_review', 'waiting_seat', 'accepted', 'declined', 'revoked', 'expired')), + CONSTRAINT "workspace_invitations_kind_check" CHECK ("kind" IN ('email', 'link')), + CONSTRAINT "workspace_invitations_invitee_check" CHECK ("invitee_user_id" IS NOT NULL OR "normalized_email" IS NOT NULL OR "kind" = 'link') +); + +-- CreateTable +CREATE TABLE "workspace_access_policies" ( + "workspace_id" VARCHAR NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'private', + "sharing_enabled" BOOLEAN NOT NULL DEFAULT true, + "url_preview_enabled" BOOLEAN NOT NULL DEFAULT false, + "member_default_doc_role" TEXT NOT NULL DEFAULT 'manager', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspace_access_policies_pkey" PRIMARY KEY ("workspace_id"), + CONSTRAINT "workspace_access_policies_visibility_check" CHECK ("visibility" IN ('private', 'public')), + CONSTRAINT "workspace_access_policies_member_default_doc_role_check" CHECK ("member_default_doc_role" IN ('none', 'reader', 'commenter', 'editor', 'manager')) +); + +-- CreateTable +CREATE TABLE "doc_access_policies" ( + "workspace_id" VARCHAR NOT NULL, + "doc_id" VARCHAR NOT NULL, + "visibility" TEXT NOT NULL DEFAULT 'private', + "public_role" TEXT, + "member_default_role" TEXT, + "url_preview_enabled" BOOLEAN NOT NULL DEFAULT false, + "published_at" TIMESTAMPTZ(3), + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "doc_access_policies_pkey" PRIMARY KEY ("workspace_id", "doc_id"), + CONSTRAINT "doc_access_policies_visibility_check" CHECK ("visibility" IN ('private', 'public')), + CONSTRAINT "doc_access_policies_public_role_check" CHECK ("public_role" IS NULL OR "public_role" = 'external'), + CONSTRAINT "doc_access_policies_member_default_role_check" CHECK ("member_default_role" IS NULL OR "member_default_role" IN ('none', 'reader', 'commenter', 'editor', 'manager')), + CONSTRAINT "doc_access_policies_public_consistency_check" CHECK ( + ("visibility" = 'public' AND "public_role" IS NOT NULL) OR + ("visibility" = 'private' AND "public_role" IS NULL) + ) +); + +-- CreateTable +CREATE TABLE "doc_grants" ( + "workspace_id" VARCHAR NOT NULL, + "doc_id" VARCHAR NOT NULL, + "principal_type" TEXT NOT NULL, + "principal_id" VARCHAR NOT NULL, + "role" TEXT NOT NULL, + "granted_by" VARCHAR, + "legacy_workspace_id" VARCHAR, + "legacy_doc_id" VARCHAR, + "legacy_user_id" VARCHAR, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "doc_grants_pkey" PRIMARY KEY ("workspace_id", "doc_id", "principal_type", "principal_id"), + CONSTRAINT "doc_grants_principal_type_check" CHECK ("principal_type" IN ('user', 'group')), + CONSTRAINT "doc_grants_role_check" CHECK ("role" IN ('owner', 'manager', 'editor', 'commenter', 'reader')) +); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_members_active_owner_key" ON "workspace_members"("workspace_id") WHERE "role" = 'owner' AND "state" = 'active'; + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_members_active_user_key" ON "workspace_members"("workspace_id", "user_id") WHERE "state" = 'active'; + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_members_legacy_permission_id_key" ON "workspace_members"("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL; + +-- CreateIndex +CREATE INDEX "workspace_members_user_id_state_idx" ON "workspace_members"("user_id", "state"); + +-- CreateIndex +CREATE INDEX "workspace_members_workspace_id_role_state_idx" ON "workspace_members"("workspace_id", "role", "state"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_members_workspace_id_user_id_state_key" ON "workspace_members"("workspace_id", "user_id", "state"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_invitations_legacy_permission_id_key" ON "workspace_invitations"("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_invitations_token_hash_key" ON "workspace_invitations"("token_hash") WHERE "token_hash" IS NOT NULL; + +-- CreateIndex +CREATE INDEX "workspace_invitations_workspace_id_status_idx" ON "workspace_invitations"("workspace_id", "status"); + +-- CreateIndex +CREATE INDEX "workspace_invitations_invitee_user_id_status_idx" ON "workspace_invitations"("invitee_user_id", "status"); + +-- CreateIndex +CREATE INDEX "workspace_invitations_email_status_idx" ON "workspace_invitations"("workspace_id", "normalized_email", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "workspace_invitations_workspace_id_invitee_user_id_key" ON "workspace_invitations"("workspace_id", "invitee_user_id"); + +-- CreateIndex +CREATE INDEX "workspace_access_policies_visibility_idx" ON "workspace_access_policies"("visibility"); + +-- CreateIndex +CREATE INDEX "workspace_access_policies_preview_idx" ON "workspace_access_policies"("url_preview_enabled", "sharing_enabled"); + +-- CreateIndex +CREATE INDEX "doc_access_policies_public_idx" ON "doc_access_policies"("workspace_id", "visibility", "published_at") WHERE "visibility" = 'public'; + +-- CreateIndex +CREATE INDEX "doc_access_policies_doc_idx" ON "doc_access_policies"("workspace_id", "doc_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "doc_grants_owner_key" ON "doc_grants"("workspace_id", "doc_id") WHERE "principal_type" = 'user' AND "role" = 'owner'; + +-- CreateIndex +CREATE UNIQUE INDEX "doc_grants_legacy_key" ON "doc_grants"("legacy_workspace_id", "legacy_doc_id", "legacy_user_id") WHERE "legacy_workspace_id" IS NOT NULL AND "legacy_doc_id" IS NOT NULL AND "legacy_user_id" IS NOT NULL; + +-- CreateIndex +CREATE INDEX "doc_grants_principal_idx" ON "doc_grants"("principal_type", "principal_id", "role"); + +-- CreateIndex +CREATE INDEX "doc_grants_doc_role_idx" ON "doc_grants"("workspace_id", "doc_id", "role"); + +-- AddForeignKey +ALTER TABLE "workspace_runtime_states" ADD CONSTRAINT "workspace_runtime_states_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_invitee_user_id_fkey" FOREIGN KEY ("invitee_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_invitations" ADD CONSTRAINT "workspace_invitations_inviter_user_id_fkey" FOREIGN KEY ("inviter_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "workspace_access_policies" ADD CONSTRAINT "workspace_access_policies_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "doc_access_policies" ADD CONSTRAINT "doc_access_policies_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "doc_grants" ADD CONSTRAINT "doc_grants_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Role mapping helpers used by projection/backfill SQL. They return NULL for +-- legacy dirty data so callers can reject or report the row explicitly. +CREATE OR REPLACE FUNCTION affine_permission_legacy_workspace_role(role_value INTEGER) +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE role_value + WHEN 99 THEN 'owner' + WHEN 10 THEN 'admin' + WHEN 1 THEN 'member' + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_legacy_doc_role(role_value INTEGER) +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE role_value + WHEN 99 THEN 'owner' + WHEN 30 THEN 'manager' + WHEN 20 THEN 'editor' + WHEN 15 THEN 'commenter' + WHEN 10 THEN 'reader' + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_legacy_default_doc_role(role_value INTEGER) +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE role_value + WHEN 30 THEN 'manager' + WHEN 20 THEN 'editor' + WHEN 15 THEN 'commenter' + WHEN 10 THEN 'reader' + WHEN -32768 THEN 'none' + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_workspace_invitation_state(status_value "WorkspaceMemberStatus") +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE status_value + WHEN 'Pending'::"WorkspaceMemberStatus" THEN 'pending' + WHEN 'UnderReview'::"WorkspaceMemberStatus" THEN 'waiting_review' + WHEN 'AllocatingSeat'::"WorkspaceMemberStatus" THEN 'waiting_seat' + WHEN 'NeedMoreSeat'::"WorkspaceMemberStatus" THEN 'waiting_seat' + WHEN 'NeedMoreSeatAndReview'::"WorkspaceMemberStatus" THEN 'waiting_seat' + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_projection_enabled() +RETURNS BOOLEAN +LANGUAGE SQL +STABLE +AS $$ + SELECT COALESCE(current_setting('affine.permission_projection.enabled', true), 'on') = 'on' +$$; + +CREATE OR REPLACE FUNCTION affine_permission_sync_origin() +RETURNS TEXT +LANGUAGE SQL +STABLE +AS $$ + SELECT NULLIF(current_setting('affine.permission_sync_origin', true), '') +$$; + +CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy() +RETURNS BOOLEAN +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + origin TEXT; +BEGIN + IF NOT affine_permission_projection_enabled() THEN + RETURN FALSE; + END IF; + + origin := affine_permission_sync_origin(); + IF origin IS NULL THEN + PERFORM set_config('affine.permission_sync_origin', 'legacy', true); + RETURN TRUE; + END IF; + + IF origin = 'legacy' THEN + RETURN TRUE; + END IF; + + IF origin = 'new' THEN + RETURN FALSE; + END IF; + + RAISE EXCEPTION 'Invalid affine.permission_sync_origin %', origin; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new() +RETURNS BOOLEAN +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + origin TEXT; +BEGIN + IF NOT affine_permission_projection_enabled() THEN + RETURN FALSE; + END IF; + + origin := affine_permission_sync_origin(); + IF origin IS NULL THEN + PERFORM set_config('affine.permission_sync_origin', 'new', true); + RETURN TRUE; + END IF; + + IF origin = 'new' THEN + RETURN TRUE; + END IF; + + IF origin = 'legacy' THEN + RETURN FALSE; + END IF; + + RAISE EXCEPTION 'Invalid affine.permission_sync_origin %', origin; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_new_workspace_role(role_value TEXT) +RETURNS SMALLINT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE role_value + WHEN 'owner' THEN 99::SMALLINT + WHEN 'admin' THEN 10::SMALLINT + WHEN 'member' THEN 1::SMALLINT + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_new_workspace_source(source_value TEXT) +RETURNS "WorkspaceMemberSource" +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE source_value + WHEN 'email' THEN 'Email'::"WorkspaceMemberSource" + WHEN 'link' THEN 'Link'::"WorkspaceMemberSource" + ELSE 'Email'::"WorkspaceMemberSource" + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_new_invitation_status(state_value TEXT) +RETURNS "WorkspaceMemberStatus" +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE state_value + WHEN 'pending' THEN 'Pending'::"WorkspaceMemberStatus" + WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus" + WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus" + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_new_doc_role(role_value TEXT) +RETURNS SMALLINT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE role_value + WHEN 'owner' THEN 99::SMALLINT + WHEN 'manager' THEN 30::SMALLINT + WHEN 'editor' THEN 20::SMALLINT + WHEN 'commenter' THEN 15::SMALLINT + WHEN 'reader' THEN 10::SMALLINT + WHEN 'external' THEN 0::SMALLINT + WHEN 'none' THEN (-32767 - 1)::SMALLINT + ELSE NULL + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_projection_error_category( + sql_state TEXT, + message TEXT +) +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +AS $$ + SELECT CASE + WHEN sql_state = '23505' AND ( + message LIKE '%workspace_members_active_owner_key%' OR + message LIKE '%doc_grants_owner_key%' + ) THEN 'owner_conflict' + WHEN sql_state = '23503' THEN 'foreign_key_missing' + WHEN sql_state = 'P0001' AND message LIKE 'Cannot project unknown%' THEN 'invalid_legacy_role' + WHEN message LIKE '%affine.permission_sync_origin%' THEN 'projection_recursion_guard_missing' + ELSE 'unknown' + END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_lock_workspace(workspace_id VARCHAR) +RETURNS VOID +LANGUAGE SQL +VOLATILE +AS $$ + SELECT pg_advisory_xact_lock(hashtextextended(workspace_id, 16)) +$$; + +CREATE OR REPLACE FUNCTION affine_permission_lock_doc(workspace_id VARCHAR, doc_id VARCHAR) +RETURNS VOID +LANGUAGE SQL +VOLATILE +AS $$ + SELECT pg_advisory_xact_lock(hashtextextended(workspace_id || ':' || doc_id, 16)) +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_workspace_user_permission() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + projected_role TEXT; + projected_state TEXT; + projected_source TEXT; +BEGIN + IF NOT affine_permission_should_project_from_legacy() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM workspace_members WHERE legacy_permission_id = OLD.id; + DELETE FROM workspace_invitations WHERE legacy_permission_id = OLD.id; + RETURN OLD; + END IF; + + projected_role := affine_permission_legacy_workspace_role(NEW.type); + projected_source := CASE NEW.source + WHEN 'Email'::"WorkspaceMemberSource" THEN 'email' + WHEN 'Link'::"WorkspaceMemberSource" THEN 'link' + ELSE 'legacy' + END; + + IF NEW.type = -99 THEN + DELETE FROM workspace_members WHERE legacy_permission_id = NEW.id; + DELETE FROM workspace_invitations WHERE legacy_permission_id = NEW.id; + RETURN NEW; + END IF; + + IF NEW.status = 'Accepted'::"WorkspaceMemberStatus" THEN + IF projected_role IS NULL THEN + RAISE EXCEPTION 'Cannot project unknown workspace role % for workspace permission %', NEW.type, NEW.id; + END IF; + + DELETE FROM workspace_invitations WHERE legacy_permission_id = NEW.id; + INSERT INTO workspace_members ( + workspace_id, + user_id, + role, + state, + source, + legacy_permission_id, + created_at, + updated_at + ) + VALUES ( + NEW.workspace_id, + NEW.user_id, + projected_role, + 'active', + projected_source, + NEW.id, + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL + DO UPDATE SET + user_id = EXCLUDED.user_id, + role = EXCLUDED.role, + state = EXCLUDED.state, + source = EXCLUDED.source, + updated_at = EXCLUDED.updated_at; + + RETURN NEW; + END IF; + + projected_state := affine_permission_workspace_invitation_state(NEW.status); + IF projected_state IS NULL THEN + RETURN NEW; + END IF; + + IF projected_role IS NULL THEN + RAISE EXCEPTION 'Cannot project unknown workspace role % for %.%', NEW.type, NEW.workspace_id, NEW.user_id; + END IF; + + DELETE FROM workspace_members WHERE legacy_permission_id = NEW.id; + INSERT INTO workspace_invitations ( + workspace_id, + invitee_user_id, + inviter_user_id, + requested_role, + status, + kind, + legacy_permission_id, + created_at, + updated_at + ) + VALUES ( + NEW.workspace_id, + NEW.user_id, + NEW.inviter_id, + CASE WHEN projected_role = 'admin' THEN 'admin' ELSE 'member' END, + projected_state, + CASE WHEN projected_source = 'link' THEN 'link' ELSE 'email' END, + NEW.id, + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL + DO UPDATE SET + invitee_user_id = EXCLUDED.invitee_user_id, + inviter_user_id = EXCLUDED.inviter_user_id, + requested_role = EXCLUDED.requested_role, + status = EXCLUDED.status, + kind = EXCLUDED.kind, + updated_at = EXCLUDED.updated_at; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_workspace_page() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + default_role TEXT; +BEGIN + IF NOT affine_permission_should_project_from_legacy() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM doc_access_policies + WHERE workspace_id = OLD.workspace_id AND doc_id = OLD.page_id; + RETURN OLD; + END IF; + + default_role := affine_permission_legacy_default_doc_role(NEW."defaultRole"); + IF default_role IS NULL THEN + RAISE EXCEPTION 'Cannot project unknown default doc role % for %.%', NEW."defaultRole", NEW.workspace_id, NEW.page_id; + END IF; + + INSERT INTO doc_access_policies ( + workspace_id, + doc_id, + visibility, + public_role, + member_default_role, + published_at, + updated_at + ) + VALUES ( + NEW.workspace_id, + NEW.page_id, + CASE WHEN NEW.public THEN 'public' ELSE 'private' END, + CASE WHEN NEW.public THEN 'external' ELSE NULL END, + default_role, + NEW.published_at, + now() + ) + ON CONFLICT (workspace_id, doc_id) + DO UPDATE SET + visibility = EXCLUDED.visibility, + public_role = EXCLUDED.public_role, + member_default_role = EXCLUDED.member_default_role, + published_at = EXCLUDED.published_at, + updated_at = now(); + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_workspace_page_user_permission() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + projected_role TEXT; +BEGIN + IF NOT affine_permission_should_project_from_legacy() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM doc_grants + WHERE workspace_id = OLD.workspace_id + AND doc_id = OLD.page_id + AND principal_type = 'user' + AND principal_id = OLD.user_id; + RETURN OLD; + END IF; + + IF NEW.type IN (-32768, 0) THEN + DELETE FROM doc_grants + WHERE workspace_id = NEW.workspace_id + AND doc_id = NEW.page_id + AND principal_type = 'user' + AND principal_id = NEW.user_id; + RETURN NEW; + END IF; + + projected_role := affine_permission_legacy_doc_role(NEW.type); + IF projected_role IS NULL THEN + RAISE EXCEPTION 'Cannot project unknown doc role % for %.% user %', NEW.type, NEW.workspace_id, NEW.page_id, NEW.user_id; + END IF; + + INSERT INTO doc_grants ( + workspace_id, + doc_id, + principal_type, + principal_id, + role, + legacy_workspace_id, + legacy_doc_id, + legacy_user_id, + created_at, + updated_at + ) + VALUES ( + NEW.workspace_id, + NEW.page_id, + 'user', + NEW.user_id, + projected_role, + NEW.workspace_id, + NEW.page_id, + NEW.user_id, + NEW.created_at, + now() + ) + ON CONFLICT (workspace_id, doc_id, principal_type, principal_id) + DO UPDATE SET + role = EXCLUDED.role, + updated_at = now(); + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_workspace_policy() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT affine_permission_should_project_from_legacy() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM workspace_access_policies WHERE workspace_id = OLD.id; + RETURN OLD; + END IF; + + INSERT INTO workspace_access_policies ( + workspace_id, + visibility, + sharing_enabled, + url_preview_enabled, + updated_at + ) + VALUES ( + NEW.id, + CASE WHEN NEW.public THEN 'public' ELSE 'private' END, + NEW.enable_sharing, + NEW.enable_url_preview, + now() + ) + ON CONFLICT (workspace_id) + DO UPDATE SET + visibility = EXCLUDED.visibility, + sharing_enabled = EXCLUDED.sharing_enabled, + url_preview_enabled = EXCLUDED.url_preview_enabled, + updated_at = now(); + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_new_workspace_member() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + legacy_role SMALLINT; + projected_legacy_id VARCHAR; +BEGIN + IF NOT affine_permission_should_project_from_new() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM workspace_user_permissions + WHERE workspace_id = OLD.workspace_id AND user_id = OLD.user_id; + RETURN OLD; + END IF; + + IF NEW.state <> 'active' THEN + DELETE FROM workspace_user_permissions + WHERE workspace_id = NEW.workspace_id AND user_id = NEW.user_id; + RETURN NEW; + END IF; + + legacy_role := affine_permission_new_workspace_role(NEW.role); + IF legacy_role IS NULL THEN + RAISE EXCEPTION 'Cannot project unknown workspace member role % for %.%', NEW.role, NEW.workspace_id, NEW.user_id; + END IF; + + DELETE FROM workspace_invitations + WHERE workspace_id = NEW.workspace_id AND invitee_user_id = NEW.user_id; + + INSERT INTO workspace_user_permissions ( + id, + workspace_id, + user_id, + type, + status, + source, + created_at, + updated_at + ) + VALUES ( + COALESCE(NEW.legacy_permission_id, 'perm_' || md5(random()::text || clock_timestamp()::text)), + NEW.workspace_id, + NEW.user_id, + legacy_role, + 'Accepted'::"WorkspaceMemberStatus", + affine_permission_new_workspace_source(NEW.source), + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (workspace_id, user_id) + DO UPDATE SET + type = EXCLUDED.type, + status = EXCLUDED.status, + source = EXCLUDED.source, + updated_at = EXCLUDED.updated_at + RETURNING id INTO projected_legacy_id; + + IF NEW.legacy_permission_id IS DISTINCT FROM projected_legacy_id THEN + PERFORM set_config('affine.permission_sync_origin', 'legacy', true); + UPDATE workspace_members + SET legacy_permission_id = projected_legacy_id + WHERE id = NEW.id; + PERFORM set_config('affine.permission_sync_origin', 'new', true); + END IF; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_new_workspace_invitation() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + legacy_status "WorkspaceMemberStatus"; + legacy_role SMALLINT; + projected_legacy_id VARCHAR; +BEGIN + IF NOT affine_permission_should_project_from_new() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + IF OLD.invitee_user_id IS NULL THEN + RETURN OLD; + END IF; + DELETE FROM workspace_user_permissions + WHERE workspace_id = OLD.workspace_id + AND user_id = OLD.invitee_user_id + AND status <> 'Accepted'::"WorkspaceMemberStatus"; + RETURN OLD; + END IF; + + IF NEW.invitee_user_id IS NULL THEN + RETURN NEW; + END IF; + + legacy_status := affine_permission_new_invitation_status(NEW.status); + legacy_role := CASE WHEN NEW.requested_role = 'admin' THEN 10::SMALLINT ELSE 1::SMALLINT END; + IF legacy_status IS NULL THEN + DELETE FROM workspace_user_permissions + WHERE workspace_id = NEW.workspace_id + AND user_id = NEW.invitee_user_id + AND status <> 'Accepted'::"WorkspaceMemberStatus"; + RETURN NEW; + END IF; + + DELETE FROM workspace_members + WHERE workspace_id = NEW.workspace_id AND user_id = NEW.invitee_user_id AND state = 'active'; + + INSERT INTO workspace_user_permissions ( + id, + workspace_id, + user_id, + inviter_id, + type, + status, + source, + created_at, + updated_at + ) + VALUES ( + COALESCE(NEW.legacy_permission_id, 'perm_' || md5(random()::text || clock_timestamp()::text)), + NEW.workspace_id, + NEW.invitee_user_id, + NEW.inviter_user_id, + legacy_role, + legacy_status, + CASE WHEN NEW.kind = 'link' THEN 'Link'::"WorkspaceMemberSource" ELSE 'Email'::"WorkspaceMemberSource" END, + NEW.created_at, + NEW.updated_at + ) + ON CONFLICT (workspace_id, user_id) + DO UPDATE SET + inviter_id = EXCLUDED.inviter_id, + type = EXCLUDED.type, + status = EXCLUDED.status, + source = EXCLUDED.source, + updated_at = EXCLUDED.updated_at + RETURNING id INTO projected_legacy_id; + + IF NEW.legacy_permission_id IS DISTINCT FROM projected_legacy_id THEN + PERFORM set_config('affine.permission_sync_origin', 'legacy', true); + UPDATE workspace_invitations + SET legacy_permission_id = projected_legacy_id + WHERE id = NEW.id; + PERFORM set_config('affine.permission_sync_origin', 'new', true); + END IF; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_new_workspace_access_policy() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NOT affine_permission_should_project_from_new() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + + UPDATE workspaces + SET + public = NEW.visibility = 'public', + enable_sharing = NEW.sharing_enabled, + enable_url_preview = NEW.url_preview_enabled + WHERE id = NEW.workspace_id; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_new_doc_access_policy() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + legacy_default_role SMALLINT; +BEGIN + IF NOT affine_permission_should_project_from_new() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + legacy_default_role := affine_permission_new_doc_role( + COALESCE( + (SELECT member_default_doc_role FROM workspace_access_policies WHERE workspace_id = OLD.workspace_id), + 'manager' + ) + ); + IF legacy_default_role IS NULL OR legacy_default_role = 99 THEN + legacy_default_role := 30::SMALLINT; + END IF; + + UPDATE workspace_pages + SET + public = FALSE, + "defaultRole" = legacy_default_role, + published_at = NULL + WHERE workspace_id = OLD.workspace_id AND page_id = OLD.doc_id; + RETURN OLD; + END IF; + + legacy_default_role := affine_permission_new_doc_role( + COALESCE( + NEW.member_default_role, + (SELECT member_default_doc_role FROM workspace_access_policies WHERE workspace_id = NEW.workspace_id), + 'manager' + ) + ); + IF legacy_default_role IS NULL OR legacy_default_role = 99 THEN + RAISE EXCEPTION 'Cannot project unsupported doc default role % for %.%', NEW.member_default_role, NEW.workspace_id, NEW.doc_id; + END IF; + + INSERT INTO workspace_pages ( + workspace_id, + page_id, + public, + "defaultRole", + published_at + ) + VALUES ( + NEW.workspace_id, + NEW.doc_id, + NEW.visibility = 'public', + legacy_default_role, + NEW.published_at + ) + ON CONFLICT (workspace_id, page_id) + DO UPDATE SET + public = EXCLUDED.public, + "defaultRole" = EXCLUDED."defaultRole", + published_at = EXCLUDED.published_at; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE OR REPLACE FUNCTION affine_permission_project_new_doc_grant() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + legacy_role SMALLINT; +BEGIN + IF NOT affine_permission_should_project_from_new() THEN + IF TG_OP = 'DELETE' THEN + RETURN OLD; + END IF; + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + IF OLD.principal_type <> 'user' THEN + RETURN OLD; + END IF; + DELETE FROM workspace_page_user_permissions + WHERE workspace_id = OLD.workspace_id + AND page_id = OLD.doc_id + AND user_id = OLD.principal_id; + RETURN OLD; + END IF; + + IF NEW.principal_type <> 'user' THEN + RETURN NEW; + END IF; + + legacy_role := affine_permission_new_doc_role(NEW.role); + IF legacy_role IS NULL OR legacy_role IN (0, -32768) THEN + RAISE EXCEPTION 'Cannot project unsupported doc grant role % for %.%', NEW.role, NEW.workspace_id, NEW.doc_id; + END IF; + + INSERT INTO workspace_page_user_permissions ( + workspace_id, + page_id, + user_id, + type, + created_at + ) + VALUES ( + NEW.workspace_id, + NEW.doc_id, + NEW.principal_id, + legacy_role, + NEW.created_at + ) + ON CONFLICT (workspace_id, page_id, user_id) + DO UPDATE SET + type = EXCLUDED.type; + + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + RAISE EXCEPTION 'permission_projection_error:%:%', affine_permission_projection_error_category(SQLSTATE, SQLERRM), SQLERRM; +END +$$; + +CREATE TRIGGER "affine_permission_project_workspace_user_permission" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_user_permissions" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_workspace_user_permission(); + +CREATE TRIGGER "affine_permission_project_workspace_page" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_pages" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_workspace_page(); + +CREATE TRIGGER "affine_permission_project_workspace_page_user_permission" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_page_user_permissions" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_workspace_page_user_permission(); + +CREATE TRIGGER "affine_permission_project_workspace_policy" +AFTER INSERT OR UPDATE OR DELETE ON "workspaces" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_workspace_policy(); + +CREATE TRIGGER "affine_permission_project_new_workspace_member" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_members" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_new_workspace_member(); + +CREATE TRIGGER "affine_permission_project_new_workspace_invitation" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_invitations" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_new_workspace_invitation(); + +CREATE TRIGGER "affine_permission_project_new_workspace_access_policy" +AFTER INSERT OR UPDATE OR DELETE ON "workspace_access_policies" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_new_workspace_access_policy(); + +CREATE TRIGGER "affine_permission_project_new_doc_access_policy" +AFTER INSERT OR UPDATE OR DELETE ON "doc_access_policies" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_new_doc_access_policy(); + +CREATE TRIGGER "affine_permission_project_new_doc_grant" +AFTER INSERT OR UPDATE OR DELETE ON "doc_grants" +FOR EACH ROW EXECUTE FUNCTION affine_permission_project_new_doc_grant(); diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 8854802dbe..d9e4c62edc 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -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. diff --git a/packages/backend/server/src/native.ts b/packages/backend/server/src/native.ts index 6424370be8..e18da3df03 100644 --- a/packages/backend/server/src/native.ts +++ b/packages/backend/server/src/native.ts @@ -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;