Skip to main content

didbot_policy_records/
subject.rs

1//! The subjects a policy judges, and the diff a write carries to one.
2//!
3//! `didbot-pds`'s write and OAuth paths build every subject here, and
4//! `didbot-policy-check` builds the same ones in a browser, so a page judges
5//! a hypothetical request the way the server judges a real one. Nothing here
6//! reads a clock: the instant is the caller's, so the two agree on what "now"
7//! was.
8//!
9//! # The diff carries values, not just paths
10//!
11//! A rule that means to test a value cannot, from a path alone, tell a
12//! legitimate edit from a delete that removed the same path. [`Change`]
13//! carries both sides' values instead, with absence itself represented as a
14//! value (`None`), so an evaluator can test *what changed to what* rather
15//! than merely *that something at this path changed*. [`diff`] computes it
16//! completely: every path whose value differs between `before` and `after`,
17//! including every path in a wholly new record (a create, `before: None`
18//! throughout) and every path in a wholly removed one (a delete, `after:
19//! None` throughout). There is no "empty diff for a delete" case, and a gate
20//! is entitled to rely on that.
21//!
22//! Nothing here is a payload log. [`Change`] borrows from the two `Value`s a
23//! write already holds in memory for the length of one judgment, and nothing
24//! stores, logs, or returns a `Change` past that call.
25
26use didbot_policy::{
27    Asked, Change, ClientId, DeploymentAttributes, Diff, Lookup, Subject, SubjectAttributes,
28    SubjectKind, Universal, WriteAction,
29};
30use serde_json::Value;
31use time::OffsetDateTime;
32
33/// One field path whose value differs between before and after a write, as
34/// this crate computes and holds it.
35///
36/// `before`/`after` are `None` exactly when the path was absent on that
37/// side — absence is a value, not a missing [`OwnedChange`]. A path present
38/// with an identical value on both sides is not represented at all: this is
39/// a diff, and an unchanged path did not change.
40///
41/// `path` is an owned `String` here rather than a borrow into the record:
42/// the paths themselves are computed while walking the structure
43/// (`"foo.bar[2]"` is built, not found), and something has to own the
44/// result. [`Change`] is the borrowed view of this a gate actually receives
45/// — see [`OwnedChange::as_change`] — so the ownership lives here, once,
46/// rather than at every call site that needs one.
47#[derive(Debug, Clone, PartialEq)]
48pub struct OwnedChange<'a> {
49    /// Dot-and-bracket notation from the record's root: `"displayName"`,
50    /// `"labels.values[0].val"`.
51    pub path: String,
52    /// What this path held before the write. `None` if it was absent.
53    pub before: Option<&'a Value>,
54    /// What this path holds after the write. `None` if it is now absent.
55    pub after: Option<&'a Value>,
56}
57
58impl OwnedChange<'_> {
59    /// Borrows this as the shape a gate receives.
60    ///
61    /// A cheap reborrow, not a clone of the values: `path` is borrowed for
62    /// as long as this `OwnedChange` is, and `before`/`after` were already
63    /// borrowed from the record.
64    #[must_use]
65    pub fn as_change(&self) -> Change<'_> {
66        Change {
67            path: &self.path,
68            before: self.before,
69            after: self.after,
70        }
71    }
72}
73
74/// Borrows a whole diff at once. See [`OwnedChange::as_change`].
75#[must_use]
76pub fn borrow<'a>(owned: &'a [OwnedChange<'_>]) -> Vec<Change<'a>> {
77    owned.iter().map(OwnedChange::as_change).collect()
78}
79
80/// Computes the complete diff between `before` and `after`.
81///
82/// Walks both values together, path by path. An object's keys are the union
83/// of both sides'; an array is walked by index up to the longer side's
84/// length. A path recurses only while both sides at that path are "an object
85/// or absent" (respectively "an array or absent"); anywhere the two sides
86/// disagree in kind — a scalar replacing an object, an array replacing a
87/// scalar, one side absent and the other a leaf — the whole value at that
88/// path becomes one [`OwnedChange`], because there is no shared substructure
89/// left to walk into. An empty object or array on one side, absent on the
90/// other, is reported as a single change at its own path rather than as zero
91/// changes over zero children — the container itself is the thing that
92/// changed.
93///
94/// `before: None, after: None` (nothing to diff) produces no changes.
95/// `before: None, after: Some(record)` (a create) produces one change per
96/// leaf path in `record`, every one with `before: None`. `before:
97/// Some(record), after: None` (a delete) is the mirror: one change per leaf
98/// path in `record`, every one with `after: None`.
99#[must_use]
100pub fn diff<'a>(before: Option<&'a Value>, after: Option<&'a Value>) -> Vec<OwnedChange<'a>> {
101    let mut changes = Vec::new();
102    diff_into("", before, after, &mut changes);
103    changes
104}
105
106fn join(parent: &str, key: &str) -> String {
107    if parent.is_empty() {
108        key.to_owned()
109    } else {
110        format!("{parent}.{key}")
111    }
112}
113
114fn diff_into<'a>(
115    path: &str,
116    before: Option<&'a Value>,
117    after: Option<&'a Value>,
118    out: &mut Vec<OwnedChange<'a>>,
119) {
120    if before == after {
121        return;
122    }
123
124    let before_object_or_absent = before.is_none() || matches!(before, Some(Value::Object(_)));
125    let after_object_or_absent = after.is_none() || matches!(after, Some(Value::Object(_)));
126    if before_object_or_absent && after_object_or_absent {
127        let mut keys: Vec<&str> = Vec::new();
128        if let Some(Value::Object(map)) = before {
129            keys.extend(map.keys().map(String::as_str));
130        }
131        if let Some(Value::Object(map)) = after {
132            keys.extend(map.keys().map(String::as_str));
133        }
134        keys.sort_unstable();
135        keys.dedup();
136        if keys.is_empty() {
137            // Both sides are objects (or absent) but neither has a key —
138            // e.g. `{}` on one side and absent on the other. The container
139            // itself is the change; there is nothing under it to recurse
140            // into.
141            out.push(OwnedChange {
142                path: path.to_owned(),
143                before,
144                after,
145            });
146        } else {
147            for key in keys {
148                let child_before = before.and_then(|value| value.get(key));
149                let child_after = after.and_then(|value| value.get(key));
150                diff_into(&join(path, key), child_before, child_after, out);
151            }
152        }
153        return;
154    }
155
156    let before_array_or_absent = before.is_none() || matches!(before, Some(Value::Array(_)));
157    let after_array_or_absent = after.is_none() || matches!(after, Some(Value::Array(_)));
158    if before_array_or_absent && after_array_or_absent {
159        let before_len = before.and_then(Value::as_array).map_or(0, Vec::len);
160        let after_len = after.and_then(Value::as_array).map_or(0, Vec::len);
161        let len = before_len.max(after_len);
162        if len == 0 {
163            out.push(OwnedChange {
164                path: path.to_owned(),
165                before,
166                after,
167            });
168        } else {
169            for index in 0..len {
170                let child_before = before.and_then(|value| value.get(index));
171                let child_after = after.and_then(|value| value.get(index));
172                diff_into(&format!("{path}[{index}]"), child_before, child_after, out);
173            }
174        }
175        return;
176    }
177
178    // The two sides disagree in kind (object vs. scalar, array vs. object,
179    // and so on) with neither side absent, or one side is a leaf scalar
180    // that changed. Either way there is no shared substructure to walk
181    // into, so the whole value at this path is the change.
182    out.push(OwnedChange {
183        path: path.to_owned(),
184        before,
185        after,
186    });
187}
188
189/// Builds the [`Subject::Write`] a gate judges one write against.
190///
191/// `client_id` is the application whose OAuth token presented the write, or
192/// `None` when the account wrote with its own credential. `now` is the
193/// instant the whole request is judged at — see [`Universal::now`] on why an
194/// evaluator never reads its own clock.
195///
196/// `changes` and the two records are two views of one write, and both are
197/// passed rather than either derived: the diff is what the applicability
198/// index walks, and the records are what an evaluator reading fields reads.
199///
200/// `lookups` are what the policies applicable to this write declared they
201/// read, already read — see [`didbot_policy::Lookup`]. They arrive as an
202/// argument for the same reason `now` does: an evaluator judging this
203/// subject can reach nothing its arguments do not already hold.
204#[allow(clippy::too_many_arguments)]
205#[must_use]
206pub fn write_subject<'a>(
207    account: &'a str,
208    kind: SubjectKind<'a>,
209    handle: Option<&'a str>,
210    pds_did: &'a str,
211    pds_hostname: &'a str,
212    operator_did: Option<&'a str>,
213    client_id: Option<&'a ClientId>,
214    collection: &'a str,
215    action: WriteAction,
216    changes: &'a [Change<'a>],
217    before: Option<&'a serde_json::Value>,
218    after: Option<&'a serde_json::Value>,
219    lookups: &'a [Lookup<'a>],
220    now: OffsetDateTime,
221) -> Subject<'a> {
222    Subject::Write {
223        account,
224        attributes: SubjectAttributes {
225            kind,
226            handle,
227            depth: None,
228        },
229        universal: universal(pds_did, pds_hostname, operator_did, now),
230        client_id,
231        collection,
232        action,
233        diff: Diff { changes },
234        before,
235        after,
236        lookups,
237    }
238}
239
240/// Builds the [`Subject::Blob`] a gate judges one upload against.
241///
242/// `sniffed` and `size` are what is known when the judgment happens: an
243/// upload is judged on what the request declares, before a byte is read,
244/// again while the bytes arrive, and once they are in. `now` is the instant
245/// the request is judged at.
246#[allow(clippy::too_many_arguments)]
247#[must_use]
248pub fn blob_subject<'a>(
249    account: &'a str,
250    kind: SubjectKind<'a>,
251    handle: Option<&'a str>,
252    pds_did: &'a str,
253    pds_hostname: &'a str,
254    client_id: Option<&'a ClientId>,
255    mime_type: &'a str,
256    sniffed: Option<&'a str>,
257    size: Option<u64>,
258    now: OffsetDateTime,
259) -> Subject<'a> {
260    Subject::Blob {
261        account,
262        attributes: SubjectAttributes {
263            kind,
264            handle,
265            depth: None,
266        },
267        universal: universal(pds_did, pds_hostname, None, now),
268        client_id,
269        mime_type,
270        sniffed,
271        size,
272    }
273}
274
275/// Builds the [`Subject::Grant`] a gate judges one pushed authorization
276/// request against, before any account is known.
277///
278/// `requested_scopes` is borrowed as the wire strings the request carried,
279/// not as a parsed grammar: `didbot_policy` has no scope type, and a rule
280/// keyed on a scope matches the atom the client actually sent.
281#[must_use]
282pub fn grant_subject<'a>(
283    pds_did: &'a str,
284    pds_hostname: &'a str,
285    client_id: &'a ClientId,
286    requested_scopes: &'a [&'a str],
287    now: OffsetDateTime,
288) -> Subject<'a> {
289    Subject::Grant {
290        universal: universal(pds_did, pds_hostname, None, now),
291        client_id,
292        requested_scopes,
293    }
294}
295
296/// Builds the [`Subject::Token`] a gate judges before a token is issued to
297/// `client_id` for `account`: an authorization code exchanged, or a refresh
298/// token rotated. `scopes` are the wire strings the token would carry.
299#[allow(clippy::too_many_arguments)]
300#[must_use]
301pub fn token_subject<'a>(
302    account: &'a str,
303    kind: SubjectKind<'a>,
304    handle: Option<&'a str>,
305    pds_did: &'a str,
306    pds_hostname: &'a str,
307    client_id: &'a ClientId,
308    scopes: &'a [&'a str],
309    now: OffsetDateTime,
310) -> Subject<'a> {
311    Subject::Token {
312        account,
313        attributes: SubjectAttributes {
314            kind,
315            handle,
316            depth: None,
317        },
318        universal: universal(pds_did, pds_hostname, None, now),
319        client_id,
320        scopes,
321    }
322}
323
324/// Builds the [`Subject::Create`] a gate judges for one pool and zone a
325/// create could land in. `attributes` is the creating account's, when this
326/// server hosts it; `name` and `label` are set when the create gave a name.
327#[allow(clippy::too_many_arguments)]
328#[must_use]
329pub fn create_subject<'a>(
330    account: &'a str,
331    attributes: Option<SubjectAttributes<'a>>,
332    pds_did: &'a str,
333    pds_hostname: &'a str,
334    kind: SubjectKind<'a>,
335    asked: Asked,
336    (pool, zone): (&'a str, &'a str),
337    (name, label): (Option<&'a str>, Option<&'a str>),
338    now: OffsetDateTime,
339) -> Subject<'a> {
340    Subject::Create {
341        account,
342        attributes,
343        universal: universal(pds_did, pds_hostname, None, now),
344        kind,
345        asked,
346        pool,
347        zone,
348        name,
349        label,
350    }
351}
352
353/// A deployment's facts and the instant, as every subject carries them.
354fn universal<'a>(
355    pds_did: &'a str,
356    pds_hostname: &'a str,
357    operator_did: Option<&'a str>,
358    now: OffsetDateTime,
359) -> Universal<'a> {
360    Universal {
361        deployment: DeploymentAttributes {
362            pds_did,
363            pds_hostname,
364            operator_did,
365        },
366        now,
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use serde_json::json;
374
375    #[test]
376    fn a_create_lists_every_leaf_path_with_no_before() {
377        let after = json!({"text": "hi", "labels": {"values": [{"val": "x"}]}});
378        let changes = diff(None, Some(&after));
379        let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
380        paths.sort_unstable();
381        assert_eq!(paths, vec!["labels.values[0].val", "text"]);
382        assert!(changes.iter().all(|c| c.before.is_none()));
383    }
384
385    #[test]
386    fn a_delete_lists_every_leaf_path_with_no_after() {
387        let before = json!({"text": "hi", "tags": ["a", "b"]});
388        let changes = diff(Some(&before), None);
389        let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
390        paths.sort_unstable();
391        assert_eq!(paths, vec!["tags[0]", "tags[1]", "text"]);
392        assert!(
393            !changes.is_empty(),
394            "a delete of a non-empty record must never produce an empty diff"
395        );
396        assert!(changes.iter().all(|c| c.after.is_none()));
397    }
398
399    #[test]
400    fn an_update_reports_only_what_actually_changed() {
401        let before = json!({"text": "hi", "unrelated": "same"});
402        let after = json!({"text": "bye", "unrelated": "same"});
403        let changes = diff(Some(&before), Some(&after));
404        assert_eq!(changes.len(), 1);
405        assert_eq!(changes[0].path, "text");
406        assert_eq!(changes[0].before, Some(&json!("hi")));
407        assert_eq!(changes[0].after, Some(&json!("bye")));
408    }
409
410    #[test]
411    fn clearing_a_field_is_a_value_change_not_a_deletion_of_the_record() {
412        let before = json!({"text": "hi", "displayName": "Bot"});
413        let after = json!({"text": "hi", "displayName": ""});
414        let changes = diff(Some(&before), Some(&after));
415        assert_eq!(changes.len(), 1);
416        assert_eq!(changes[0].path, "displayName");
417        assert_eq!(changes[0].before, Some(&json!("Bot")));
418        assert_eq!(changes[0].after, Some(&json!("")));
419    }
420
421    #[test]
422    fn an_empty_object_replacing_absence_is_its_own_change() {
423        let after = json!({"metadata": {}});
424        let changes = diff(None, Some(&after));
425        let mut paths: Vec<&str> = changes.iter().map(|c| c.path.as_str()).collect();
426        paths.sort_unstable();
427        assert_eq!(paths, vec!["metadata"]);
428        assert_eq!(changes[0].after, Some(&json!({})));
429    }
430
431    #[test]
432    fn nothing_to_diff_produces_no_changes() {
433        assert!(diff(None, None).is_empty());
434        let same = json!({"a": 1});
435        assert!(diff(Some(&same), Some(&same)).is_empty());
436    }
437}