Skip to main content

eui_core/host/
record_policy.rs

1//! eApps R3 (docs/spec/apps.md §3.4, §9): who may read, create, update and
2//! delete a kind's records, under the member's app access. A line-by-line
3//! port of `src/host/record-policy.ts`; `fixtures/record-policy.json` is
4//! the spec (`tests/record_policy.rs` replays it).
5
6use super::package::PackageIssue;
7use serde_json::Value;
8
9pub const RECORD_POLICY_OPS: &[&str] = &["read", "create", "update", "delete"];
10pub const RECORD_POLICY_LEVELS: &[&str] = &["member", "author", "owners"];
11pub const APP_ACCESS_LEVELS: &[&str] = &["none", "read", "write"];
12
13/// One op of a kind's policy.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum RecordOp {
16    Read,
17    Create,
18    Update,
19    Delete,
20}
21
22impl RecordOp {
23    pub fn parse(s: &str) -> Option<Self> {
24        Some(match s {
25            "read" => Self::Read,
26            "create" => Self::Create,
27            "update" => Self::Update,
28            "delete" => Self::Delete,
29            _ => return None,
30        })
31    }
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Read => "read",
35            Self::Create => "create",
36            Self::Update => "update",
37            Self::Delete => "delete",
38        }
39    }
40}
41
42/// A kind's effective policy: a level (`member`, `author`, `owners`) per op.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct RecordPolicy {
45    pub read: &'static str,
46    pub create: &'static str,
47    pub update: &'static str,
48    pub delete: &'static str,
49}
50
51pub const DEFAULT_RECORD_POLICY: RecordPolicy = RecordPolicy { read: "member", create: "member", update: "author", delete: "author" };
52
53impl RecordPolicy {
54    pub fn level(&self, op: RecordOp) -> &'static str {
55        match op {
56            RecordOp::Read => self.read,
57            RecordOp::Create => self.create,
58            RecordOp::Update => self.update,
59            RecordOp::Delete => self.delete,
60        }
61    }
62    fn set(&mut self, op: RecordOp, level: &'static str) {
63        match op {
64            RecordOp::Read => self.read = level,
65            RecordOp::Create => self.create = level,
66            RecordOp::Update => self.update = level,
67            RecordOp::Delete => self.delete = level,
68        }
69    }
70    pub fn to_json(&self) -> Value {
71        serde_json::json!({"read": self.read, "create": self.create, "update": self.update, "delete": self.delete})
72    }
73}
74
75pub const COPY_NONE: &str = "You have no access to this app";
76pub const COPY_READ_ONLY: &str = "You have read-only access to this app";
77pub const COPY_AUTHOR_READ: &str = "Only the record's author or a team owner can read it";
78pub const COPY_AUTHOR_CHANGE: &str = "Only the record's author or a team owner can change it";
79pub const COPY_OWNERS_READ: &str = "Only team owners can read these records";
80pub const COPY_OWNERS_CREATE: &str = "Only team owners can add these records";
81pub const COPY_OWNERS_CHANGE: &str = "Only team owners can change these records";
82
83/// The refusal texts as `record-policy.json` `copy` spells them.
84pub fn record_policy_copy() -> Value {
85    serde_json::json!({
86        "none": COPY_NONE,
87        "readOnly": COPY_READ_ONLY,
88        "author": {"read": COPY_AUTHOR_READ, "update": COPY_AUTHOR_CHANGE, "delete": COPY_AUTHOR_CHANGE},
89        "owners": {"read": COPY_OWNERS_READ, "create": COPY_OWNERS_CREATE, "update": COPY_OWNERS_CHANGE, "delete": COPY_OWNERS_CHANGE},
90    })
91}
92
93/// Who is asking: the member id (`None` = signed out), their team role
94/// (`None` = not a member of the app's team) and their access level for the
95/// app (`None` = write).
96#[derive(Clone, Copy, Debug, Default)]
97pub struct RecordViewer<'a> {
98    pub id: Option<&'a str>,
99    pub role: Option<&'a str>,
100    pub access: Option<&'a str>,
101}
102
103fn level_of(v: &str) -> Option<&'static str> {
104    RECORD_POLICY_LEVELS.iter().copied().find(|l| *l == v)
105}
106
107/// The issues of a kind's `policy` (each under `at`).
108pub fn validate_record_policy(policy: &Value, at: &str) -> Vec<PackageIssue> {
109    let Some(map) = policy.as_object() else {
110        return vec![PackageIssue { path: at.into(), message: "policy is {read?, create?, update?, delete?}".into() }];
111    };
112    let mut keys: Vec<&String> = map.keys().collect();
113    keys.sort_by(|a, b| super::utf16_cmp(a, b));
114    let mut issues = Vec::new();
115    for key in keys {
116        let value = &map[key];
117        let issue = |message: String| PackageIssue { path: format!("{at}/{key}"), message };
118        if RecordOp::parse(key).is_none() {
119            issues.push(issue(format!("unknown policy op {key}: read, create, update or delete")));
120        } else if key == "create" && value.as_str() == Some("author") {
121            issues.push(issue("create is member or owners: a new record has no author yet".into()));
122        } else if value.as_str().and_then(level_of).is_none() {
123            issues.push(issue(format!("{key} is member, author or owners")));
124        }
125    }
126    issues
127}
128
129/// A kind's effective policy: its `policy` over the defaults (an invalid
130/// entry keeps the default).
131pub fn record_policy(kind: Option<&Value>) -> RecordPolicy {
132    let mut out = DEFAULT_RECORD_POLICY;
133    let Some(policy) = kind.and_then(|k| k.get("policy")).and_then(Value::as_object) else { return out };
134    for name in RECORD_POLICY_OPS {
135        let op = RecordOp::parse(name).unwrap();
136        if let Some(level) = policy.get(*name).and_then(Value::as_str).and_then(level_of) {
137            if !(op == RecordOp::Create && level == "author") {
138                out.set(op, level);
139            }
140        }
141    }
142    out
143}
144
145/// A record's author (`authorId ?? createdBy`), or `None`.
146pub fn record_author_of(row: Option<&Value>) -> Option<&str> {
147    let row = row?;
148    row.get("authorId").and_then(Value::as_str).or_else(|| row.get("createdBy").and_then(Value::as_str))
149}
150
151/// Why `viewer` may NOT `op` a record of a kind with `policy` (`row` = the
152/// record for read/update/delete), or `None` when they may.
153pub fn record_refusal(policy: &RecordPolicy, op: RecordOp, viewer: &RecordViewer, row: Option<&Value>) -> Option<&'static str> {
154    let access = viewer.access.unwrap_or("write");
155    if viewer.id.is_none() || viewer.role.is_none() || access == "none" {
156        return Some(COPY_NONE);
157    }
158    if access == "read" && op != RecordOp::Read {
159        return Some(COPY_READ_ONLY);
160    }
161    if viewer.role == Some("owner") {
162        return None;
163    }
164    let level = policy.level(op);
165    if level == "member" {
166        return None;
167    }
168    if level == "owners" || op == RecordOp::Create {
169        return Some(match op {
170            RecordOp::Read => COPY_OWNERS_READ,
171            RecordOp::Create => COPY_OWNERS_CREATE,
172            _ => COPY_OWNERS_CHANGE,
173        });
174    }
175    match record_author_of(row) {
176        Some(author) if Some(author) == viewer.id => None,
177        _ => Some(if op == RecordOp::Read { COPY_AUTHOR_READ } else { COPY_AUTHOR_CHANGE }),
178    }
179}
180
181/// May `viewer` `op` the record?
182pub fn record_allowed(policy: &RecordPolicy, op: RecordOp, viewer: &RecordViewer, row: Option<&Value>) -> bool {
183    record_refusal(policy, op, viewer, row).is_none()
184}
185
186/// The rows (`{kind, authorId?, createdBy?, …}`) `viewer` may read, each by
187/// its kind's policy (`kinds` = the package's `records`), in order.
188pub fn readable_rows<'r>(rows: &'r [Value], kinds: Option<&Value>, viewer: &RecordViewer) -> Vec<&'r Value> {
189    rows.iter()
190        .filter(|r| {
191            let kind = r.get("kind").and_then(Value::as_str).unwrap_or("");
192            record_allowed(&record_policy(kinds.and_then(|k| k.get(kind))), RecordOp::Read, viewer, Some(r))
193        })
194        .collect()
195}