Skip to main content

didbot_policy_cedar/
request.rs

1//! One request to judge, and how it becomes Cedar's principal, action,
2//! resource, context and entity store.
3
4use std::iter::once;
5
6use cedar_policy::{Context, Entities};
7use serde_json::{json, Map, Value};
8use time::format_description::well_known::Rfc3339;
9use time::{OffsetDateTime, UtcOffset};
10
11use crate::load::PrincipalNeed;
12use crate::schema::{uid, ActionName, FieldType, Shape, Shapes, NAMESPACE};
13
14/// What a `record.write` does to the record.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Operation {
17    /// The record did not exist. A create over a tombstone still arrives
18    /// with `existing` set.
19    Create,
20    /// The record existed and changes.
21    Update,
22    /// The record is removed.
23    Delete,
24}
25
26impl Operation {
27    /// The value `context.operation` carries.
28    #[must_use]
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Operation::Create => "create",
32            Operation::Update => "update",
33            Operation::Delete => "delete",
34        }
35    }
36}
37
38/// What the gate knows about the account beyond its DID. Each is absent
39/// when the gate does not carry it, and a statement guards it with `has`.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct AccountAttributes<'a> {
42    /// The account's kind, as its registration spells it.
43    pub kind: Option<&'a str>,
44    /// The account's handle.
45    pub handle: Option<&'a str>,
46    /// Edges between the account and the deployment's operator: 1 for an
47    /// account the operator runs directly.
48    pub depth: Option<u32>,
49}
50
51/// Who is asking, as much of it as the gate knows.
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Principal<'a> {
54    /// The gate does not identify the account. A statement whose scope
55    /// says anything about `principal` cannot be judged and refuses.
56    Unknown,
57    /// The account, and the accounts that admitted it, nearest first, when
58    /// the gate carries them. A statement scoped with `principal in`
59    /// refuses without the chain rather than silently not matching.
60    Account {
61        /// The account's DID.
62        did: &'a str,
63        /// The admission chain above the account, nearest first.
64        admitted_by: Option<&'a [&'a str]>,
65        /// The account's own attributes; `principal.kind` and the rest.
66        attributes: AccountAttributes<'a>,
67    },
68}
69
70impl Principal<'_> {
71    pub(crate) fn provides(&self) -> PrincipalNeed {
72        match self {
73            Principal::Unknown => PrincipalNeed::Nothing,
74            Principal::Account {
75                admitted_by: None, ..
76            } => PrincipalNeed::Identity,
77            Principal::Account {
78                admitted_by: Some(_),
79                ..
80            } => PrincipalNeed::Chain,
81        }
82    }
83}
84
85/// The action being asked about, with its resource and context.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Action<'a> {
88    /// A record write. `value` is the record after the write and
89    /// `existing` the record before it; each is absent when there is none.
90    RecordWrite {
91        /// The record's collection NSID.
92        collection: &'a str,
93        /// The record key, when the gate carries it.
94        rkey: Option<&'a str>,
95        /// The space the record lives in; absent for the public repository.
96        space: Option<&'a str>,
97        /// What the write does.
98        operation: Operation,
99        /// The client whose OAuth token presented the write; absent when
100        /// the account wrote with its own credential. `context.client`, a
101        /// `Client` entity like the resource of the two OAuth actions.
102        client: Option<&'a str>,
103        /// The record after the write.
104        value: Option<&'a Value>,
105        /// The record before the write.
106        existing: Option<&'a Value>,
107    },
108    /// A blob upload.
109    BlobWrite {
110        /// The blob's declared MIME type.
111        mime_type: &'a str,
112        /// The MIME type read from the blob's own bytes.
113        sniffed: &'a str,
114        /// The blob's size in bytes.
115        size: u64,
116        /// The space the blob lives in; absent for the public repository.
117        space: Option<&'a str>,
118    },
119    /// An authorization request from a client.
120    OauthAuthorize {
121        /// The client id, a URL.
122        client_id: &'a str,
123        /// The scopes the client asked for.
124        scopes: &'a [&'a str],
125    },
126    /// A token request from a client.
127    OauthToken {
128        /// The client id, a URL.
129        client_id: &'a str,
130        /// The grant type presented, when the gate carries it.
131        grant: Option<&'a str>,
132        /// The scopes the client asked for.
133        scopes: &'a [&'a str],
134    },
135    /// Where a new account lands: one pool and zone a create could take.
136    AccountCreate {
137        /// What the new account is; `context.kind`.
138        kind: &'a str,
139        /// How the create asked for its name: `name`, `pool` or `none`;
140        /// `context.asked`.
141        asked: &'a str,
142        /// The pool the name comes from.
143        pool: &'a str,
144        /// The zone it sits under.
145        zone: &'a str,
146        /// The whole name, when the create gave one.
147        name: Option<&'a str>,
148        /// Its label under `zone`, when the create gave a name.
149        label: Option<&'a str>,
150    },
151}
152
153impl Action<'_> {
154    /// Which action this is.
155    #[must_use]
156    pub fn name(&self) -> ActionName {
157        match self {
158            Action::RecordWrite { .. } => ActionName::RecordWrite,
159            Action::BlobWrite { .. } => ActionName::BlobWrite,
160            Action::OauthAuthorize { .. } => ActionName::OauthAuthorize,
161            Action::OauthToken { .. } => ActionName::OauthToken,
162            Action::AccountCreate { .. } => ActionName::AccountCreate,
163        }
164    }
165}
166
167/// One request for a document to judge.
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct Request<'a> {
170    /// Who is asking.
171    pub principal: Principal<'a>,
172    /// The instant the gate read its clock; `context.now`.
173    pub now: OffsetDateTime,
174    /// What is being asked.
175    pub action: Action<'a>,
176}
177
178/// The request could not be turned into something Cedar can judge. The
179/// caller refuses: nothing was evaluated, so nothing was permitted.
180#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
181#[error("{0}")]
182pub struct RequestError(String);
183
184pub(crate) struct Built {
185    pub(crate) request: cedar_policy::Request,
186    pub(crate) entities: Entities,
187}
188
189/// A request ready for any number of statements to judge.
190pub(crate) struct Prepared {
191    pub(crate) action: ActionName,
192    pub(crate) provides: PrincipalNeed,
193    pub(crate) built: Result<Built, RequestError>,
194}
195
196impl Request<'_> {
197    pub(crate) fn prepare(&self, shapes: &Shapes) -> Prepared {
198        Prepared {
199            action: self.action.name(),
200            provides: self.principal.provides(),
201            built: self.build(shapes),
202        }
203    }
204
205    fn build(&self, shapes: &Shapes) -> Result<Built, RequestError> {
206        let mut entities = Vec::new();
207        let principal = match self.principal {
208            Principal::Unknown => uid("Account", ""),
209            Principal::Account {
210                did,
211                admitted_by,
212                attributes,
213            } => {
214                let chain: Vec<&str> = once(did)
215                    .chain(admitted_by.unwrap_or_default().iter().copied())
216                    .collect();
217                for (index, member) in chain.iter().enumerate() {
218                    let parents: Vec<Value> = chain
219                        .get(index + 1)
220                        .map(|parent| vec![entity_ref("Account", parent)])
221                        .unwrap_or_default();
222                    let mut attrs = Map::new();
223                    if index == 0 {
224                        if let Some(kind) = attributes.kind {
225                            attrs.insert("kind".into(), kind.into());
226                        }
227                        if let Some(handle) = attributes.handle {
228                            attrs.insert("handle".into(), handle.into());
229                        }
230                        if let Some(depth) = attributes.depth {
231                            attrs.insert("depth".into(), depth.into());
232                        }
233                    }
234                    entities.push(json!({
235                        "uid": entity_ref("Account", member),
236                        "attrs": attrs,
237                        "parents": parents,
238                    }));
239                }
240                uid("Account", did)
241            }
242        };
243
244        let now = datetime(self.now);
245        let (resource, attrs, context) = match self.action {
246            Action::RecordWrite {
247                collection,
248                rkey,
249                space,
250                operation,
251                client,
252                value,
253                existing,
254            } => {
255                let id =
256                    rkey.map_or_else(|| collection.to_owned(), |r| format!("{collection}/{r}"));
257                let mut attrs = Map::new();
258                attrs.insert("collection".into(), collection.into());
259                if let Some(rkey) = rkey {
260                    attrs.insert("rkey".into(), rkey.into());
261                }
262                if let Some(space) = space {
263                    attrs.insert("space".into(), space.into());
264                }
265                let shape = shapes.shape_of(collection);
266                let mut context = Map::new();
267                context.insert("operation".into(), operation.as_str().into());
268                if let Some(client) = client {
269                    entities.push(client_entity(client));
270                    context.insert(
271                        "client".into(),
272                        json!({ "__entity": entity_ref("Client", client) }),
273                    );
274                }
275                context.insert("now".into(), now);
276                if let Some(value) = value {
277                    context.insert("value".into(), typed(value, shape));
278                }
279                if let Some(existing) = existing {
280                    context.insert("existing".into(), typed(existing, shape));
281                }
282                (uid("Record", &id), attrs, context)
283            }
284            Action::BlobWrite {
285                mime_type,
286                sniffed,
287                size,
288                space,
289            } => {
290                let size = i64::try_from(size).map_err(|_| {
291                    RequestError(format!(
292                        "a blob of {size} bytes is larger than Cedar's Long"
293                    ))
294                })?;
295                let mut attrs = Map::new();
296                attrs.insert("mimeType".into(), mime_type.into());
297                attrs.insert("sniffed".into(), sniffed.into());
298                attrs.insert("size".into(), size.into());
299                if let Some(space) = space {
300                    attrs.insert("space".into(), space.into());
301                }
302                let mut context = Map::new();
303                context.insert("now".into(), now);
304                (uid("Blob", "blob"), attrs, context)
305            }
306            Action::OauthAuthorize { client_id, scopes } => {
307                let mut attrs = Map::new();
308                attrs.insert("id".into(), client_id.into());
309                let mut context = Map::new();
310                context.insert("scopes".into(), json!(scopes));
311                context.insert("now".into(), now);
312                (uid("Client", client_id), attrs, context)
313            }
314            Action::OauthToken {
315                client_id,
316                grant,
317                scopes,
318            } => {
319                let mut attrs = Map::new();
320                attrs.insert("id".into(), client_id.into());
321                let mut context = Map::new();
322                if let Some(grant) = grant {
323                    context.insert("grant".into(), grant.into());
324                }
325                context.insert("scopes".into(), json!(scopes));
326                context.insert("now".into(), now);
327                (uid("Client", client_id), attrs, context)
328            }
329            Action::AccountCreate {
330                kind,
331                asked,
332                pool,
333                zone,
334                name,
335                label,
336            } => {
337                let mut attrs = Map::new();
338                if let Some(name) = name {
339                    attrs.insert("name".into(), name.into());
340                }
341                if let Some(label) = label {
342                    attrs.insert("label".into(), label.into());
343                }
344                attrs.insert("zone".into(), zone.into());
345                attrs.insert("pool".into(), pool.into());
346                let mut context = Map::new();
347                context.insert("kind".into(), kind.into());
348                context.insert("asked".into(), asked.into());
349                context.insert("now".into(), now);
350                let id = name.map_or_else(|| format!("{pool}/{zone}"), str::to_owned);
351                (uid("Name", &id), attrs, context)
352            }
353        };
354        entities.push(json!({
355            "uid": { "type": resource.type_name().to_string(), "id": resource.id().unescaped() },
356            "attrs": attrs,
357            "parents": [],
358        }));
359
360        let context = Context::from_json_value(Value::Object(context), None)
361            .map_err(|error| RequestError(format!("the context is not a Cedar record: {error}")))?;
362        let entities =
363            Entities::from_json_value(Value::Array(entities), None).map_err(|error| {
364                RequestError(format!("the entities are not Cedar entities: {error}"))
365            })?;
366        let request = cedar_policy::Request::new(
367            principal,
368            self.action.name().uid(),
369            resource,
370            context,
371            None,
372        )
373        .map_err(|error| RequestError(error.to_string()))?;
374        Ok(Built { request, entities })
375    }
376}
377
378fn entity_ref(type_name: &str, id: &str) -> Value {
379    json!({ "type": format!("{NAMESPACE}::{type_name}"), "id": id })
380}
381
382/// The `Client` entity a write's `context.client` refers to, with the
383/// same `id` attribute the OAuth actions' resource carries.
384fn client_entity(client_id: &str) -> Value {
385    json!({
386        "uid": entity_ref("Client", client_id),
387        "attrs": { "id": client_id },
388        "parents": [],
389    })
390}
391
392/// A Cedar `datetime` value in the JSON form the context parser reads.
393pub(crate) fn datetime(at: OffsetDateTime) -> Value {
394    let at = at.to_offset(UtcOffset::UTC);
395    let arg = format!(
396        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
397        at.year(),
398        u8::from(at.month()),
399        at.day(),
400        at.hour(),
401        at.minute(),
402        at.second(),
403        at.millisecond()
404    );
405    json!({ "__extn": { "fn": "datetime", "arg": arg } })
406}
407
408/// A record as Cedar sees it: the fields the shape types as `datetime`
409/// become datetime values, everything else passes through as JSON. With no
410/// shape, everything passes through.
411fn typed(value: &Value, shape: Option<&Shape>) -> Value {
412    match (value, shape) {
413        (Value::Object(map), Some(shape)) => Value::Object(
414            map.iter()
415                .map(|(name, field)| {
416                    let field = match shape.get(name) {
417                        Some(known) => typed_field(field, &known.ty),
418                        None => field.clone(),
419                    };
420                    (name.clone(), field)
421                })
422                .collect(),
423        ),
424        _ => value.clone(),
425    }
426}
427
428fn typed_field(value: &Value, ty: &FieldType) -> Value {
429    match (ty, value) {
430        (FieldType::Datetime, Value::String(text)) => {
431            OffsetDateTime::parse(text, &Rfc3339).map_or_else(|_| value.clone(), datetime)
432        }
433        (FieldType::Set(element), Value::Array(items)) => Value::Array(
434            items
435                .iter()
436                .map(|item| typed_field(item, element))
437                .collect(),
438        ),
439        (FieldType::Record(shape), Value::Object(_)) => typed(value, Some(shape)),
440        _ => value.clone(),
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    /// A sub-second, offset instant lands in the one form Cedar's
449    /// `datetime` parser accepts: UTC, millisecond precision, `Z`.
450    #[test]
451    fn datetimes_are_written_the_way_cedar_reads_them() {
452        let at = time::macros::datetime!(2026-09-10 23:15:42.123456 +02:00);
453        assert_eq!(
454            datetime(at),
455            json!({ "__extn": { "fn": "datetime", "arg": "2026-09-10T21:15:42.123Z" } })
456        );
457    }
458
459    /// Only the fields a shape types as `datetime` are converted, nested
460    /// records and sets included; a string that is not a datetime stays a
461    /// string so the document's own arithmetic on it fails loudly.
462    #[test]
463    fn a_shape_converts_exactly_its_datetime_fields() {
464        let shape = Shape::new()
465            .field("createdAt", FieldType::Datetime)
466            .field("text", FieldType::String)
467            .optional(
468                "replies",
469                FieldType::Set(Box::new(FieldType::Record(
470                    Shape::new().field("at", FieldType::Datetime),
471                ))),
472            );
473        let record = json!({
474            "createdAt": "2026-01-01T00:00:00Z",
475            "text": "2026-01-01T00:00:00Z",
476            "replies": [{ "at": "2026-01-02T00:00:00Z" }, { "at": "not a time" }],
477            "extra": "2026-01-03T00:00:00Z",
478        });
479        let converted = typed(&record, Some(&shape));
480        assert_eq!(converted["createdAt"]["__extn"]["fn"], "datetime");
481        assert_eq!(converted["text"], "2026-01-01T00:00:00Z");
482        assert_eq!(converted["replies"][0]["at"]["__extn"]["fn"], "datetime");
483        assert_eq!(converted["replies"][1]["at"], "not a time");
484        assert_eq!(converted["extra"], "2026-01-03T00:00:00Z");
485        assert_eq!(typed(&record, None), record);
486    }
487}