1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Operation {
17 Create,
20 Update,
22 Delete,
24}
25
26impl Operation {
27 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct AccountAttributes<'a> {
42 pub kind: Option<&'a str>,
44 pub handle: Option<&'a str>,
46 pub depth: Option<u32>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
53pub enum Principal<'a> {
54 Unknown,
57 Account {
61 did: &'a str,
63 admitted_by: Option<&'a [&'a str]>,
65 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Action<'a> {
88 RecordWrite {
91 collection: &'a str,
93 rkey: Option<&'a str>,
95 space: Option<&'a str>,
97 operation: Operation,
99 client: Option<&'a str>,
103 value: Option<&'a Value>,
105 existing: Option<&'a Value>,
107 },
108 BlobWrite {
110 mime_type: &'a str,
112 sniffed: &'a str,
114 size: u64,
116 space: Option<&'a str>,
118 },
119 OauthAuthorize {
121 client_id: &'a str,
123 scopes: &'a [&'a str],
125 },
126 OauthToken {
128 client_id: &'a str,
130 grant: Option<&'a str>,
132 scopes: &'a [&'a str],
134 },
135 AccountCreate {
137 kind: &'a str,
139 asked: &'a str,
142 pool: &'a str,
144 zone: &'a str,
146 name: Option<&'a str>,
148 label: Option<&'a str>,
150 },
151}
152
153impl Action<'_> {
154 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub struct Request<'a> {
170 pub principal: Principal<'a>,
172 pub now: OffsetDateTime,
174 pub action: Action<'a>,
176}
177
178#[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
189pub(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
382fn 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
392pub(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
408fn 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 #[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 #[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}