Repository: cedar-policy/cedar (rust)

Issue:
CLI command to convert between Human and JSON policy formats
### Category

CLI features/changes

### Describe the feature you'd like to request

CLI should let users convert policy to/from JSON.

### Describe alternatives you've considered

Doing it in Rust, as it's done today.

### Additional context

_No response_

### Is this something that you'd be interested in working on?

- [ ] 👋 I may be able to implement this feature request
- [ ] ⚠️ This feature might incur a breaking change

Pull request description:
Add `to_cedar` function for policies, templates, and policy sets
## Description of changes

fixes #461 

## Issue #, if available

## Checklist for requesting a review

The change in this PR is (choose one, and delete the other options):

- [ ] A breaking change requiring a major version bump to `cedar-policy` (e.g., changes to the signature of an existing API).
- [x] A backwards-compatible change requiring a minor version bump to `cedar-policy` (e.g., addition of a new API).
- [ ] A bug fix or other functionality change requiring a patch to `cedar-policy`.
- [ ] A change "invisible" to users (e.g., documentation, changes to "internal" crates like `cedar-policy-core`, `cedar-validator`, etc.)
- [ ] A change (breaking or otherwise) that only impacts unreleased or experimental code.

I confirm that this PR (choose one, and delete the other options):

- [x] Updates the "Unreleased" section of the CHANGELOG with a description of my change (required for major/minor version bumps).
- [ ] Does not update the CHANGELOG because my change does not significantly impact released code.

I confirm that [`cedar-spec`](https://github.com/cedar-policy/cedar-spec) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar formal model or DRT infrastructure.
- [ ] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in `cedar-spec`, and how you have tested that your updates are correct.)
- [ ] Requires updates, but I do not plan to make them in the near future. (Make sure that your changes are hidden behind a feature flag to mark them as experimental.)
- [ ] I'm not sure how my change impacts `cedar-spec`. (Post your PR anyways, and we'll discuss in the comments.)

I confirm that [`docs.cedarpolicy.com`](https://docs.cedarpolicy.com/) (choose one, and delete the other options):

- [x] Does not require updates because my change does not impact the Cedar language specification.
- [ ] Requires updates, and I have made / will make these updates myself. (Please include in your description a timeline or link to the relevant PR in [`cedar-docs`](https://github.com/cedar-policy/cedar-docs). PRs should be targeted at a `staging-X.Y` branch, not `main`.)
- [ ] I'm not sure how my change impacts the documentation. (Post your PR anyways, and we'll discuss in the comments.)


Patch:
diff --git a/cedar-policy-cli/CHANGELOG.md b/cedar-policy-cli/CHANGELOG.md
index e12416f1..90c48b04 100644
--- a/cedar-policy-cli/CHANGELOG.md
+++ b/cedar-policy-cli/CHANGELOG.md
@@ -7,6 +7,7 @@ Changes to the Cedar language, which are likely to affect users of the CLI, are
 
 ### Added
 
+- Added `json-to-cedar` direction to `translate-policy` command. (#1510, resolving #461)
 - Add `--level` option to the `validate` command, exposing the `level-validate`
   experimental feature through the CLI. (#1508, resolving #1501)
 
diff --git a/cedar-policy-cli/src/lib.rs b/cedar-policy-cli/src/lib.rs
index 16f6cf21..bade3774 100644
--- a/cedar-policy-cli/src/lib.rs
+++ b/cedar-policy-cli/src/lib.rs
@@ -126,6 +126,8 @@ pub struct TranslatePolicyArgs {
 pub enum PolicyTranslationDirection {
     /// Cedar policy syntax -> JSON
     CedarToJson,
+    /// JSON -> Cedar policy syntax
+    JsonToCedar,
 }
 
 #[derive(Args, Debug)]
@@ -949,8 +951,19 @@ pub fn format_policies(args: &FormatArgs) -> CedarExitCode {
     }
 }
 
-fn translate_policy_to_json(cedar_src: impl AsRef<str>) -> Result<String> {
-    let policy_set = PolicySet::from_str(cedar_src.as_ref())?;
+fn translate_policy_to_cedar(
+    json_src: Option<impl AsRef<Path> + std::marker::Copy>,
+) -> Result<String> {
+    let policy_set = read_json_policy_set(json_src)?;
+    policy_set.to_cedar().ok_or(miette!(
+        "Unable to translate policy set containing template linked policies."
+    ))
+}
+
+fn translate_policy_to_json(
+    cedar_src: Option<impl AsRef<Path> + std::marker::Copy>,
+) -> Result<String> {
+    let policy_set = read_cedar_policy_set(cedar_src)?;
     let output = policy_set.to_json()?.to_string();
     Ok(output)
 }
@@ -958,8 +971,9 @@ fn translate_policy_to_json(cedar_src: impl AsRef<str>) -> Result<String> {
 fn translate_policy_inner(args: &TranslatePolicyArgs) -> Result<String> {
     let translate = match args.direction {
         PolicyTranslationDirection::CedarToJson => translate_policy_to_json,
+        PolicyTranslationDirection::JsonToCedar => translate_policy_to_cedar,
     };
-    read_from_file_or_stdin(args.input_file.as_ref(), "policy").and_then(translate)
+    translate(args.input_file.as_ref())
 }
 
 pub fn translate_policy(args: &TranslatePolicyArgs) -> CedarExitCode {
diff --git a/cedar-policy/CHANGELOG.md b/cedar-policy/CHANGELOG.md
index a2a8d8b8..2ee7d99c 100644
--- a/cedar-policy/CHANGELOG.md
+++ b/cedar-policy/CHANGELOG.md
@@ -26,6 +26,9 @@ Cedar Language Version: TBD
 - Added `PolicySet::merge_policyset()` to merge a `PolicySet` into another `PolicySet` struct (resolving #610)
 - Implemented [RFC 53 (enumerated entity types)](https://github.com/cedar-policy/rfcs/blob/main/text/0053-enum-entities.md)  (#1377)
 - Added the experimental feature `tolerant-ast` which allows certain errors to be propogated in AST expressions as an `ExprKind::Error` (#1470)
+- Added `to_cedar` functions for `PolicySet`, `Policy`, and `Template` that
+  render the policy in the human-readable Cedar syntax. These functions can be used
+  to convert JSON formatted policies into the human-readable syntax.
 
 ## [4.3.3] - 2025-02-25
 
diff --git a/cedar-policy/src/api.rs b/cedar-policy/src/api.rs
index 58d40516..bd8247ef 100644
--- a/cedar-policy/src/api.rs
+++ b/cedar-policy/src/api.rs
@@ -2217,6 +2217,43 @@ impl PolicySet {
         Ok(est)
     }
 
+    /// Get the human-readable Cedar syntax representation of this policy set.
+    /// This function is primarily intended for rendering JSON policies in the
+    /// human-readable syntax, but it will also return the original policy text
+    /// (though possibly re-ordering policies within the policy set) when the
+    /// policy-set contains policies parsed from the human-readable syntax.
+    ///
+    /// This will return `None` if there are any linked policies in the policy
+    /// set because they cannot be directly rendered in Cedar syntax. It also
+    /// cannot record policy ids because these cannot be specified in the Cedar
+    /// syntax. The policies may be reordered, so parsing the resulting string
+    /// with [`PolicySet::from_str`] is likely to yield different policy id
+    /// assignments. For these reasons you should prefer serializing as JSON (or protobuf) and
+    /// only using this function to obtain a representation to display to human
+    /// users.
+    ///
+    /// This function does not format the policy according to any particular
+    /// rules.  Policy formatting can be done through the Cedar policy CLI or
+    /// the `cedar-policy-formatter` crate.
+    pub fn to_cedar(&self) -> Option<String> {
+        let policies = self
+            .policies
+            .values()
+            // We'd like to print policies in a deterministic order, so we sort
+            // before printing, hoping that the size of policy sets is fairly
+            // small.
+            .sorted_by_key(|p| AsRef::<str>::as_ref(p.id()))
+            .map(|p| p.to_cedar())
+            .collect::<Option<Vec<_>>>()?;
+        let templates = self
+            .templates
+            .values()
+            .sorted_by_key(|t| AsRef::<str>::as_ref(t.id()))
+            .map(|t| t.to_cedar());
+
+        Some(policies.into_iter().chain(templates).join("\n\n"))
+    }
+
     /// Create a fresh empty `PolicySet`
     pub fn new() -> Self {
         Self {
@@ -2901,6 +2938,21 @@ impl Template {
         serde_json::to_value(est).map_err(Into::into)
     }
 
+    /// Get the human-readable Cedar syntax representation of this template.
+    /// This function is primarily intended for rendering JSON policies in the
+    /// human-readable syntax, but it will also return the original policy text
+    /// when given a policy parsed from the human-readable syntax.
+    ///
+    /// It also does not format the policy according to any particular rules.
+    /// Policy formatting can be done through the Cedar policy CLI or
+    /// the `cedar-policy-formatter` crate.
+    pub fn to_cedar(&self) -> String {
+        match &self.lossless {
+            LosslessPolicy::Est(_) => self.ast.to_string(),
+            LosslessPolicy::Text { text, .. } => text.clone(),
+        }
+    }
+
     /// Get valid [`RequestEnv`]s.
     /// A [`RequestEnv`] is valid when the template type checks w.r.t requests
     /// that satisfy it.
@@ -3380,6 +3432,33 @@ impl Policy {
         serde_json::to_value(est).map_err(Into::into)
     }
 
+    /// Get the human-readable Cedar syntax representation of this policy. This
+    /// function is primarily intended for rendering JSON policies in the
+    /// human-readable syntax, but it will also return the original policy text
+    /// when given a policy parsed from the human-readable syntax.
+    ///
+    /// It will return `None` for linked policies because they cannot be
+    /// directly rendered in Cedar syntax. You can instead render the unlinked
+    /// template if you do not need to preserve links. If serializing links is
+    /// important, then you will need to serialize the whole policy set
+    /// containing the template and link to JSON (or protobuf).
+    ///
+    /// It also does not format the policy according to any particular rules.
+    /// Policy formatting can be done through the Cedar policy CLI or
+    /// the `cedar-policy-formatter` crate.
+    pub fn to_cedar(&self) -> Option<String> {
+        match &self.lossless {
+            LosslessPolicy::Est(_) => Some(self.ast.to_string()),
+            LosslessPolicy::Text { text, slots } => {
+                if slots.is_empty() {
+                    Some(text.clone())
+                } else {
+                    None
+                }
+            }
+        }
+    }
+
     /// Get all the unknown entities from the policy
     #[doc = include_str!("../experimental_warning.md")]
     #[cfg(feature = "partial-eval")]


Test patch:
diff --git a/cedar-policy-cli/tests/sample.rs b/cedar-policy-cli/tests/sample.rs
index 59a16bd5..6ccf4f8e 100644
--- a/cedar-policy-cli/tests/sample.rs
+++ b/cedar-policy-cli/tests/sample.rs
@@ -1194,7 +1194,7 @@ fn test_translate_policy() {
     let json_filename = "sample-data/tiny_sandboxes/translate-policy/policy.cedar.json";
     let cedar = std::fs::read_to_string(cedar_filename).unwrap();
     let json = std::fs::read_to_string(json_filename).unwrap();
-    let translate_cmd = assert_cmd::Command::cargo_bin("cedar")
+    let to_json_command = assert_cmd::Command::cargo_bin("cedar")
         .expect("bin exists")
         .arg("translate-policy")
         .arg("--direction")
@@ -1203,12 +1203,39 @@ fn test_translate_policy() {
         .arg(cedar_filename)
         .assert();
 
-    let translated = std::str::from_utf8(&translate_cmd.get_output().stdout)
+    let translated_json = std::str::from_utf8(&to_json_command.get_output().stdout)
         .expect("output should be decodable");
 
     assert_eq!(
-        translated, json,
-        "\noriginal:\n{cedar}\n\ttranslated:\n{translated}",
+        translated_json, json,
+        "\noriginal:\n{cedar}\n\ttranslated:\n{translated_json}",
+    );
+
+    let translate_to_cedar = assert_cmd::Command::cargo_bin("cedar")
+        .expect("bin exists")
+        .arg("translate-policy")
+        .arg("--direction")
+        .arg("json-to-cedar")
+        .arg("-p")
+        .arg(json_filename)
+        .assert();
+
+    let translated_cedar = std::str::from_utf8(&translate_to_cedar.get_output().stdout)
+        .expect("output should be decodable");
+
+    // Converting back from JSON adds an extra `when { true }`
+    let expected_translated_cedar = r#"permit(
+  principal == User::"alice",
+  action == Action::"update",
+  resource == Photo::"VacationPhoto94.jpg"
+) when {
+  true
+};
+"#;
+
+    assert_eq!(
+        translated_cedar, expected_translated_cedar,
+        "\noriginal:\n{cedar}\n\ttranslated:\n{translated_cedar}",
     );
 }
 
diff --git a/cedar-policy/src/tests.rs b/cedar-policy/src/tests.rs
index 69b03423..7bcdbf01 100644
--- a/cedar-policy/src/tests.rs
+++ b/cedar-policy/src/tests.rs
@@ -7089,3 +7089,137 @@ mod schema_annotations {
         );
     }
 }
+
+mod to_cedar {
+    use std::collections::HashMap;
+
+    use crate::{Policy, PolicyId, PolicySet, SlotId, Template};
+
+    #[test]
+    fn json_policy_to_cedar() {
+        let policy_json = serde_json::json!({
+            "effect": "permit",
+            "principal": { "op": "All" },
+            "action": { "op": "All" },
+            "resource": { "op": "All" },
+            "conditions": [
+                {
+                    "kind": "when",
+                    "body": {
+                        ".": {
+                            "left": {
+                                "Var": "context"
+                            },
+                            "attr": "is_frobnicated"
+                        }
+                    }
+                }
+            ]
+        });
+
+        let policy = Policy::from_json(None, policy_json).unwrap();
+
+        let policy_cedar = policy.to_cedar().unwrap();
+        let expected_policy_cedar = r#"permit(
+  principal,
+  action,
+  resource
+) when {
+  context["is_frobnicated"]
+};"#;
+
+        assert_eq!(policy_cedar, expected_policy_cedar);
+    }
+
+    #[test]
+    fn json_policy_set_to_cedar() {
+        let p1_json = serde_json::json!({
+            "effect": "permit",
+            "principal": { "op": "All" },
+            "action": { "op": "All" },
+            "resource": { "op": "All" },
+            "conditions": [
+                {
+                    "kind": "when",
+                    "body": {
+                        ".": {
+                            "left": {
+                                "Var": "context"
+                            },
+                            "attr": "is_frobnicated"
+                        }
+                    }
+                }
+            ]
+        });
+        let t1_json = serde_json::json!({
+            "effect": "permit",
+            "principal": {
+                "op": "==",
+                "slot": "?principal"
+            },
+            "action": { "op": "All" },
+            "resource": { "op": "All" },
+            "conditions": [ ]
+        });
+        let pset_json = serde_json::json!({
+            "staticPolicies": {
+                "p1": p1_json,
+            },
+            "templates" : {
+                "t1": t1_json,
+            },
+            "templateLinks" : []
+        });
+        let pset = PolicySet::from_json_value(pset_json).unwrap();
+        let expected = r#"permit(
+  principal,
+  action,
+  resource
+) when {
+  context["is_frobnicated"]
+};
+
+permit(
+  principal == ?principal,
+  action,
+  resource
+) when {
+  true
+};"#;
+        assert_eq!(pset.to_cedar().unwrap(), expected);
+    }
+
+    #[test]
+    fn cedar_to_cedar_is_lossless() {
+        let policy_cedar = "permit ( principal, action, resource );";
+        let policy = Policy::parse(None, policy_cedar).unwrap();
+        let lossless_cedar = policy.to_cedar().unwrap();
+        assert_eq!(policy_cedar, lossless_cedar);
+    }
+
+    #[test]
+    fn template_linked_is_none() {
+        let mut pset = PolicySet::new();
+        let template: Template =
+            r"permit(principal == ?principal, action, resource) when { principal.bar };"
+                .parse()
+                .unwrap();
+        pset.add_template(template.new_id(PolicyId::new("template")))
+            .unwrap();
+
+        pset.link(
+            PolicyId::new("template"),
+            PolicyId::new("Link1"),
+            HashMap::from_iter([(SlotId::principal(), r#"User::"Joe""#.parse().unwrap())]),
+        )
+        .unwrap();
+
+        // Linked policies can't convert to Cedar format
+        let linked_policy = pset.policies().next().unwrap();
+        assert_eq!(linked_policy.to_cedar(), None);
+
+        // Neither can the whole policy set containing the linked policy
+        assert_eq!(pset.to_cedar(), None);
+    }
+}