Skip to main content

cedar_policy_cli/utils/
policies.rs

1/*
2 * Copyright Cedar Contributors
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17use cedar_policy::{Policy, PolicySet, Template};
18use clap::{Args, ValueEnum};
19use miette::{miette, IntoDiagnostic, NamedSource, Report, Result, WrapErr};
20use std::{path::Path, str::FromStr};
21
22use crate::{add_template_links_to_set, read_from_file_or_stdin};
23
24/// This struct contains the arguments that together specify an input policy or policy set.
25#[derive(Args, Debug)]
26pub struct PoliciesArgs {
27    /// File containing the static Cedar policies and/or templates. If not provided, read policies from stdin.
28    #[arg(short, long = "policies", value_name = "FILE")]
29    pub policies_file: Option<String>,
30    /// Format of policies in the `--policies` file
31    #[arg(long = "policy-format", default_value_t, value_enum)]
32    pub policy_format: PolicyFormat,
33    /// File containing template-linked policies
34    #[arg(short = 'k', long = "template-linked", value_name = "FILE")]
35    pub template_linked_file: Option<String>,
36}
37
38impl PoliciesArgs {
39    /// Turn this `PoliciesArgs` into the appropriate `PolicySet` object
40    pub(crate) fn get_policy_set(&self) -> Result<PolicySet> {
41        let mut pset = match self.policy_format {
42            PolicyFormat::Cedar => read_cedar_policy_set(self.policies_file.as_ref()),
43            PolicyFormat::Json => read_json_policy_set(self.policies_file.as_ref()),
44        }?;
45        if let Some(links_filename) = self.template_linked_file.as_ref() {
46            add_template_links_to_set(links_filename, &mut pset)?;
47        }
48        Ok(pset)
49    }
50}
51
52/// This struct contains the arguments that together specify an input policy or policy set,
53/// for commands where policies are optional.
54#[derive(Args, Debug)]
55pub struct OptionalPoliciesArgs {
56    /// File containing static Cedar policies and/or templates
57    #[arg(short, long = "policies", value_name = "FILE")]
58    pub policies_file: Option<String>,
59    /// Format of policies in the `--policies` file
60    #[arg(long = "policy-format", default_value_t, value_enum)]
61    pub policy_format: PolicyFormat,
62    /// File containing template-linked policies. Ignored if `--policies` is not
63    /// present (because in that case there are no templates to link against)
64    #[arg(short = 'k', long = "template-linked", value_name = "FILE")]
65    pub template_linked_file: Option<String>,
66}
67
68impl OptionalPoliciesArgs {
69    /// Turn this `OptionalPoliciesArgs` into the appropriate `PolicySet`
70    /// object, or `None` if no policies were provided
71    pub(crate) fn get_policy_set(&self) -> Result<Option<PolicySet>> {
72        match &self.policies_file {
73            None => Ok(None),
74            Some(policies_file) => {
75                let pargs = PoliciesArgs {
76                    policies_file: Some(policies_file.clone()),
77                    policy_format: self.policy_format,
78                    template_linked_file: self.template_linked_file.clone(),
79                };
80                pargs.get_policy_set().map(Some)
81            }
82        }
83    }
84}
85
86#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
87pub enum PolicyFormat {
88    /// The standard Cedar policy format, documented at <https://docs.cedarpolicy.com/policies/syntax-policy.html>
89    #[default]
90    Cedar,
91    /// Cedar's JSON policy format, documented at <https://docs.cedarpolicy.com/policies/json-format.html>
92    Json,
93}
94
95/// Read a policy set, in Cedar syntax, from the file given in `filename`,
96/// or from stdin if `filename` is `None`.
97pub(crate) fn read_cedar_policy_set(
98    filename: Option<impl AsRef<Path> + std::marker::Copy>,
99) -> Result<PolicySet> {
100    let context = "policy set";
101    let ps_str = read_from_file_or_stdin(filename.as_ref(), context)?;
102    let ps = PolicySet::from_str(&ps_str)
103        .map_err(|err| {
104            let name = filename.map_or_else(
105                || "<stdin>".to_owned(),
106                |n| n.as_ref().display().to_string(),
107            );
108            Report::new(err).with_source_code(NamedSource::new(name, ps_str))
109        })
110        .wrap_err_with(|| format!("failed to parse {context}"))?;
111    rename_from_id_annotation(&ps)
112}
113
114/// Read a policy set, static policy or policy template, in Cedar JSON (EST) syntax, from the file given
115/// in `filename`, or from stdin if `filename` is `None`.
116pub(crate) fn read_json_policy_set(
117    filename: Option<impl AsRef<Path> + std::marker::Copy>,
118) -> Result<PolicySet> {
119    let context = "JSON policy";
120    let json_source = read_from_file_or_stdin(filename.as_ref(), context)?;
121    let json = serde_json::from_str::<serde_json::Value>(&json_source).into_diagnostic()?;
122    let policy_type = get_json_policy_type(&json)?;
123
124    let add_json_source = |report: Report| {
125        let name = filename.map_or_else(
126            || "<stdin>".to_owned(),
127            |n| n.as_ref().display().to_string(),
128        );
129        report.with_source_code(NamedSource::new(name, json_source.clone()))
130    };
131
132    match policy_type {
133        JsonPolicyType::SinglePolicy => match Policy::from_json(None, json.clone()) {
134            Ok(policy) => PolicySet::from_policies([policy])
135                .wrap_err_with(|| format!("failed to create policy set from {context}")),
136            Err(_) => match Template::from_json(None, json)
137                .map_err(|err| add_json_source(Report::new(err)))
138            {
139                Ok(template) => {
140                    let mut ps = PolicySet::new();
141                    ps.add_template(template)?;
142                    Ok(ps)
143                }
144                Err(err) => Err(err).wrap_err_with(|| format!("failed to parse {context}")),
145            },
146        },
147        JsonPolicyType::PolicySet => PolicySet::from_json_value(json)
148            .map_err(|err| add_json_source(Report::new(err)))
149            .wrap_err_with(|| format!("failed to create policy set from {context}")),
150    }
151}
152
153fn get_json_policy_type(json: &serde_json::Value) -> Result<JsonPolicyType> {
154    let policy_set_properties = ["staticPolicies", "templates", "templateLinks"];
155    let policy_properties = ["action", "effect", "principal", "resource", "conditions"];
156
157    let json_has_property = |p| json.get(p).is_some();
158    let has_any_policy_set_property = policy_set_properties.iter().any(json_has_property);
159    let has_any_policy_property = policy_properties.iter().any(json_has_property);
160
161    match (has_any_policy_set_property, has_any_policy_property) {
162        (false, false) => Err(miette!("cannot determine if json policy is a single policy or a policy set. Found no matching properties from either format")),
163        (true, true) => Err(miette!("cannot determine if json policy is a single policy or a policy set. Found matching properties from both formats")),
164        (true, _) => Ok(JsonPolicyType::PolicySet),
165        (_, true) => Ok(JsonPolicyType::SinglePolicy),
166    }
167}
168
169enum JsonPolicyType {
170    SinglePolicy,
171    PolicySet,
172}
173
174/// Renames policies and templates based on (@id("new_id") annotation.
175/// If no such annotation exists, it keeps the current id.
176///
177/// This will rename template-linked policies to the id of their template, which may
178/// cause id conflicts, so only call this function before instancing
179/// templates into the policy set.
180fn rename_from_id_annotation(ps: &PolicySet) -> Result<PolicySet> {
181    let mut new_ps = PolicySet::new();
182    let t_iter = ps.templates().map(|t| match t.annotation("id") {
183        None => Ok(t.clone()),
184        Some(anno) => anno.parse().map(|a| t.new_id(a)),
185    });
186    for t in t_iter {
187        let template = t.unwrap_or_else(|never| match never {});
188        new_ps
189            .add_template(template)
190            .wrap_err("failed to add template to policy set")?;
191    }
192    let p_iter = ps.policies().map(|p| match p.annotation("id") {
193        None => Ok(p.clone()),
194        Some(anno) => anno.parse().map(|a| p.new_id(a)),
195    });
196    for p in p_iter {
197        let policy = p.unwrap_or_else(|never| match never {});
198        new_ps
199            .add(policy)
200            .wrap_err("failed to add template to policy set")?;
201    }
202    Ok(new_ps)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::utils::test_utils::{render_err, TEMPFILE_FILTER};
209    use std::io::Write;
210
211    #[test]
212    fn cedar_policy_from_file_parse_error() {
213        let mut f = tempfile::NamedTempFile::new().unwrap();
214        f.write_all(b"not a valid policy").unwrap();
215        let err = read_cedar_policy_set(Some(f.path())).unwrap_err();
216        insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
217            insta::assert_snapshot!(render_err(&err), @r"
218             × failed to parse policy set
219             ╰─▶ unexpected token `a`
220              ╭────
221            1 │ not a valid policy
222              ·     ┬
223              ·     ╰── expected `(`
224              ╰────
225            ");
226        });
227    }
228
229    #[test]
230    fn json_policy_from_file_invalid_json() {
231        let mut f = tempfile::NamedTempFile::new().unwrap();
232        f.write_all(b"not json at all").unwrap();
233        let err = read_json_policy_set(Some(f.path())).unwrap_err();
234        insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
235            insta::assert_snapshot!(render_err(&err), @"  × expected ident at line 1 column 2");
236        });
237    }
238
239    #[test]
240    fn json_policy_from_file_bad_policy() {
241        let mut f = tempfile::NamedTempFile::new().unwrap();
242        // Valid JSON with policy properties, but invalid policy content —
243        // hits the Template::from_json fallback and the wrap_err "failed to parse" path
244        f.write_all(br#"{"effect":"permit","principal":{"op":"bogus"},"action":{"op":"All"},"resource":{"op":"All"},"conditions":[]}"#).unwrap();
245        let err = read_json_policy_set(Some(f.path())).unwrap_err();
246        insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
247            insta::assert_snapshot!(render_err(&err), @r#"
248            × failed to parse JSON policy
249            ├─▶ error deserializing a policy/template from JSON
250            ╰─▶ unknown variant `bogus`, expected one of `All`, `all`, `==`, `in`, `is`
251            "#);
252        });
253    }
254}