Skip to main content

didbot_policy_cedar/
schema.rs

1//! The `Didbot` namespace a document is written against: the entity types,
2//! the four actions with their contexts, and the per-collection record
3//! shapes that give `context.value` and `context.existing` a type.
4//!
5//! One Cedar schema cannot type `context.value` differently per
6//! collection, since a context type belongs to an action. So there is one
7//! schema per described collection, each the same namespace with
8//! `record.write`'s context carrying that collection's record type, plus a
9//! base schema with no record type at all for statements that never touch
10//! `record.write`. [`crate::Document::load`] picks which to validate a
11//! statement against.
12
13use std::collections::BTreeMap;
14use std::fmt::Write as _;
15use std::str::FromStr;
16
17use cedar_policy::{EntityId, EntityTypeName, EntityUid, Schema, SchemaFragment, Validator};
18
19/// The Cedar namespace every type and action lives in.
20pub const NAMESPACE: &str = "Didbot";
21
22/// The actions a document can forbid, one per gate.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
24pub enum ActionName {
25    /// A record create, update or delete. Resource `Record`.
26    RecordWrite,
27    /// A blob upload. Resource `Blob`.
28    BlobWrite,
29    /// An authorization request from a client. Resource `Client`.
30    OauthAuthorize,
31    /// A token request from a client. Resource `Client`.
32    OauthToken,
33    /// Where a new account lands. Resource `Name`.
34    AccountCreate,
35}
36
37impl ActionName {
38    /// Every action, in the order the schema declares them.
39    pub const ALL: [ActionName; 5] = [
40        ActionName::RecordWrite,
41        ActionName::BlobWrite,
42        ActionName::OauthAuthorize,
43        ActionName::OauthToken,
44        ActionName::AccountCreate,
45    ];
46
47    /// The action's id inside `Didbot::Action`.
48    #[must_use]
49    pub fn as_str(self) -> &'static str {
50        match self {
51            ActionName::RecordWrite => "record.write",
52            ActionName::BlobWrite => "blob.write",
53            ActionName::OauthAuthorize => "oauth.authorize",
54            ActionName::OauthToken => "oauth.token",
55            ActionName::AccountCreate => "account.create",
56        }
57    }
58
59    /// The action for an id, if it is one of ours.
60    #[must_use]
61    pub fn parse(name: &str) -> Option<Self> {
62        Self::ALL.into_iter().find(|action| action.as_str() == name)
63    }
64
65    /// The action as a Cedar entity reference: `Didbot::Action::"record.write"`.
66    #[must_use]
67    pub(crate) fn uid(self) -> EntityUid {
68        uid("Action", self.as_str())
69    }
70
71    /// The action an entity reference names, if it is one of ours.
72    pub(crate) fn of_uid(uid: &EntityUid) -> Option<Self> {
73        let expected = EntityTypeName::from_str(&format!("{NAMESPACE}::Action"))
74            .expect("the action type name is a valid Cedar name");
75        if uid.type_name() != &expected {
76            return None;
77        }
78        Self::parse(uid.id().unescaped())
79    }
80}
81
82impl std::fmt::Display for ActionName {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88/// Build an entity reference in the `Didbot` namespace.
89pub(crate) fn uid(type_name: &str, id: &str) -> EntityUid {
90    let type_name = EntityTypeName::from_str(&format!("{NAMESPACE}::{type_name}"))
91        .expect("every type name this crate builds is a valid Cedar name");
92    EntityUid::from_type_name_and_id(type_name, EntityId::new(id))
93}
94
95/// The Cedar type of one record field.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub enum FieldType {
98    /// A Cedar `String`.
99    String,
100    /// A Cedar `Long`.
101    Long,
102    /// A Cedar `Bool`.
103    Bool,
104    /// A Cedar `datetime`. The record carries it as an RFC 3339 string;
105    /// the request builder converts it so a document can do arithmetic.
106    Datetime,
107    /// A Cedar `Set` of one element type. JSON arrays lose their order.
108    Set(Box<FieldType>),
109    /// A nested record.
110    Record(Shape),
111}
112
113impl FieldType {
114    fn write_cedar(&self, out: &mut String) {
115        match self {
116            FieldType::String => out.push_str("String"),
117            FieldType::Long => out.push_str("Long"),
118            FieldType::Bool => out.push_str("Bool"),
119            FieldType::Datetime => out.push_str("datetime"),
120            FieldType::Set(element) => {
121                out.push_str("Set<");
122                element.write_cedar(out);
123                out.push('>');
124            }
125            FieldType::Record(shape) => shape.write_cedar(out),
126        }
127    }
128}
129
130/// One field of a record shape.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct Field {
133    /// The field's type.
134    pub ty: FieldType,
135    /// Whether every record of the collection carries the field. An
136    /// optional field must be guarded with `has` before it is read, or the
137    /// document fails validation.
138    pub required: bool,
139}
140
141/// The fields a collection's records carry, as a closed Cedar record type.
142///
143/// Closed means a document may only read the fields listed here. A field
144/// the shape does not name is a validation error at load, which is the
145/// point: a typo in a field name surfaces on the dashboard, not as a
146/// forbid that never fires.
147#[derive(Debug, Clone, Default, PartialEq, Eq)]
148pub struct Shape {
149    fields: BTreeMap<String, Field>,
150}
151
152impl Shape {
153    /// A shape with no fields.
154    #[must_use]
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    /// Add a required field.
160    #[must_use]
161    pub fn field(mut self, name: &str, ty: FieldType) -> Self {
162        self.fields
163            .insert(name.to_owned(), Field { ty, required: true });
164        self
165    }
166
167    /// Add an optional field.
168    #[must_use]
169    pub fn optional(mut self, name: &str, ty: FieldType) -> Self {
170        self.fields.insert(
171            name.to_owned(),
172            Field {
173                ty,
174                required: false,
175            },
176        );
177        self
178    }
179
180    /// The field at `name`, if the shape has it.
181    #[must_use]
182    pub fn get(&self, name: &str) -> Option<&Field> {
183        self.fields.get(name)
184    }
185
186    fn write_cedar(&self, out: &mut String) {
187        out.push_str("{ ");
188        for (index, (name, field)) in self.fields.iter().enumerate() {
189            if index > 0 {
190                out.push_str(", ");
191            }
192            let _ = write!(out, "{}", serde_json::Value::String(name.clone()));
193            if !field.required {
194                out.push('?');
195            }
196            out.push_str(": ");
197            field.ty.write_cedar(out);
198        }
199        out.push_str(" }");
200    }
201}
202
203/// The schema text for the namespace, with `record.write`'s context typed
204/// for `record` when there is one.
205#[must_use]
206pub fn schema_text(record: Option<&Shape>) -> String {
207    let mut record_context = String::new();
208    if let Some(shape) = record {
209        let mut ty = String::new();
210        shape.write_cedar(&mut ty);
211        let _ = write!(record_context, r#", "value"?: {ty}, "existing"?: {ty}"#);
212    }
213    format!(
214        r#"namespace {NAMESPACE} {{
215  entity Account in [Account] {{ "kind"?: String, "handle"?: String, "depth"?: Long }};
216  entity Record {{ "collection": String, "rkey": String, "space"?: String }};
217  entity Blob {{ "mimeType": String, "sniffed": String, "size": Long, "space"?: String }};
218  entity Client {{ "id": String }};
219  entity Name {{ "name"?: String, "label"?: String, "zone": String, "pool": String }};
220  action "record.write" appliesTo {{
221    principal: [Account],
222    resource: [Record],
223    context: {{ "operation": String, "client"?: Client, "now": datetime{record_context} }}
224  }};
225  action "blob.write" appliesTo {{
226    principal: [Account],
227    resource: [Blob],
228    context: {{ "now": datetime }}
229  }};
230  action "oauth.authorize" appliesTo {{
231    principal: [Account],
232    resource: [Client],
233    context: {{ "scopes": Set<String>, "now": datetime }}
234  }};
235  action "oauth.token" appliesTo {{
236    principal: [Account],
237    resource: [Client],
238    context: {{ "grant": String, "scopes": Set<String>, "now": datetime }}
239  }};
240  action "account.create" appliesTo {{
241    principal: [Account],
242    resource: [Name],
243    context: {{ "kind": String, "asked": String, "now": datetime }}
244  }};
245}}
246"#
247    )
248}
249
250/// The schema [`schema_text`] writes, in Cedar's JSON schema format: its
251/// entity types with their attributes, and its actions with what each
252/// applies to. For a tool that reads the schema's parts, not its text.
253#[must_use]
254pub fn schema_json(record: Option<&Shape>) -> serde_json::Value {
255    let (fragment, _) = SchemaFragment::from_cedarschema_str(&schema_text(record))
256        .expect("the schema is this crate's own text");
257    fragment
258        .to_json_value()
259        .expect("a parsed schema serializes")
260}
261
262/// A shape that does not make a Cedar schema.
263#[derive(Debug, thiserror::Error)]
264#[error("the shape for `{collection}` is not a Cedar record type: {reason}")]
265pub struct ShapeError {
266    /// The collection whose shape was refused.
267    pub collection: String,
268    /// What Cedar said about it.
269    pub reason: String,
270}
271
272struct Described {
273    shape: Shape,
274    validator: Validator,
275}
276
277/// The collections this engine knows the shape of, each with the schema
278/// that shape produces, plus the base schema for everything else.
279pub struct Shapes {
280    base: Validator,
281    described: BTreeMap<String, Described>,
282}
283
284impl Default for Shapes {
285    fn default() -> Self {
286        Self::new()
287    }
288}
289
290impl Shapes {
291    /// The base schema alone: no collection described.
292    #[must_use]
293    pub fn new() -> Self {
294        let (base, _) = Schema::from_cedarschema_str(&schema_text(None))
295            .expect("the base schema is this crate's own text");
296        Self {
297            base: Validator::new(base),
298            described: BTreeMap::new(),
299        }
300    }
301
302    /// Describe `collection`'s records, replacing any earlier shape.
303    pub fn describe(&mut self, collection: &str, shape: Shape) -> Result<(), ShapeError> {
304        let (schema, _) =
305            Schema::from_cedarschema_str(&schema_text(Some(&shape))).map_err(|error| {
306                ShapeError {
307                    collection: collection.to_owned(),
308                    reason: error.to_string(),
309                }
310            })?;
311        self.described.insert(
312            collection.to_owned(),
313            Described {
314                shape,
315                validator: Validator::new(schema),
316            },
317        );
318        Ok(())
319    }
320
321    /// The shape of `collection`'s records, if described.
322    #[must_use]
323    pub fn shape_of(&self, collection: &str) -> Option<&Shape> {
324        self.described.get(collection).map(|d| &d.shape)
325    }
326
327    /// Every described collection, in order.
328    pub fn collections(&self) -> impl Iterator<Item = &str> {
329        self.described.keys().map(String::as_str)
330    }
331
332    pub(crate) fn base(&self) -> &Validator {
333        &self.base
334    }
335
336    pub(crate) fn validator_for(&self, collection: &str) -> Option<&Validator> {
337        self.described.get(collection).map(|d| &d.validator)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    /// A shape with every field type, including a quoted name, produces a
346    /// schema Cedar accepts.
347    #[test]
348    fn every_field_type_makes_a_valid_schema() {
349        let shape = Shape::new()
350            .field("text", FieldType::String)
351            .field("createdAt", FieldType::Datetime)
352            .optional("$type", FieldType::String)
353            .optional("count", FieldType::Long)
354            .optional("pinned", FieldType::Bool)
355            .optional("langs", FieldType::Set(Box::new(FieldType::String)))
356            .optional(
357                "reply",
358                FieldType::Record(Shape::new().field("uri", FieldType::String)),
359            );
360        let mut shapes = Shapes::new();
361        shapes
362            .describe("app.bsky.feed.post", shape)
363            .expect("the shape is a valid record type");
364        assert!(shapes.shape_of("app.bsky.feed.post").is_some());
365    }
366
367    #[test]
368    fn the_json_schema_names_every_action() {
369        let json = schema_json(None);
370        for action in ActionName::ALL {
371            assert!(
372                json[NAMESPACE]["actions"][action.as_str()].is_object(),
373                "{action}"
374            );
375        }
376    }
377
378    #[test]
379    fn action_names_round_trip_through_entity_references() {
380        for action in ActionName::ALL {
381            assert_eq!(ActionName::of_uid(&action.uid()), Some(action));
382        }
383        assert_eq!(ActionName::of_uid(&uid("Action", "record.wrtie")), None);
384        assert_eq!(ActionName::of_uid(&uid("Client", "record.write")), None);
385    }
386}