mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
fix(server): migrate old tables (#14954)
This commit is contained in:
Vendored
+6
@@ -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'|
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
+1077
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user