Skip to main content

cedar_policy_cli/utils/
request.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::{Context, Request, Schema};
18use clap::{ArgAction, Args};
19use miette::{miette, IntoDiagnostic, Result, WrapErr};
20use serde::Deserialize;
21
22/// This struct contains the arguments that together specify a request.
23#[derive(Args, Debug)]
24pub struct RequestArgs {
25    /// Principal for the request, e.g., User::"alice"
26    #[arg(short = 'l', long)]
27    pub principal: Option<String>,
28    /// Action for the request, e.g., Action::"view"
29    #[arg(short, long)]
30    pub action: Option<String>,
31    /// Resource for the request, e.g., File::"myfile.txt"
32    #[arg(short, long)]
33    pub resource: Option<String>,
34    /// File containing a JSON object representing the context for the request.
35    /// Should be a (possibly empty) map from keys to values.
36    #[arg(short, long = "context", value_name = "FILE")]
37    pub context_json_file: Option<String>,
38    /// File containing a JSON object representing the entire request. Must have
39    /// fields "principal", "action", "resource", and "context", where "context"
40    /// is a (possibly empty) map from keys to values. This option replaces
41    /// --principal, --action, etc.
42    #[arg(long = "request-json", value_name = "FILE", conflicts_with_all = &["principal", "action", "resource", "context_json_file"])]
43    pub request_json_file: Option<String>,
44    /// Whether to enable request validation. This has no effect if a schema is
45    /// not provided.
46    #[arg(long = "request-validation", action = ArgAction::Set, default_value_t = true)]
47    pub request_validation: bool,
48}
49
50impl RequestArgs {
51    /// Turn this `RequestArgs` into the appropriate `Request` object
52    ///
53    /// `schema` will be used for schema-based parsing of the context, and also
54    /// (if `self.request_validation` is `true`) for request validation.
55    ///
56    /// `self.request_validation` has no effect if `schema` is `None`.
57    pub(crate) fn get_request(&self, schema: Option<&Schema>) -> Result<Request> {
58        fn missing_req_var() -> miette::Report {
59            miette!("All three (`principal`, `action`, `resource`) variables must be specified")
60        }
61        match &self.request_json_file {
62            Some(jsonfile) => {
63                let jsonstring = std::fs::read_to_string(jsonfile)
64                    .into_diagnostic()
65                    .wrap_err_with(|| format!("failed to open request-json file {jsonfile}"))?;
66                let qjson: RequestJSON = serde_json::from_str(&jsonstring)
67                    .into_diagnostic()
68                    .wrap_err_with(|| format!("failed to parse request-json file {jsonfile}"))?;
69                let principal = qjson
70                    .principal
71                    .ok_or_else(missing_req_var)?
72                    .parse()
73                    .wrap_err_with(|| {
74                        format!("failed to parse principal in {jsonfile} as entity Uid")
75                    })?;
76                let action = qjson
77                    .action
78                    .ok_or_else(missing_req_var)?
79                    .parse()
80                    .wrap_err_with(|| {
81                        format!("failed to parse action in {jsonfile} as entity Uid")
82                    })?;
83                let resource = qjson
84                    .resource
85                    .ok_or_else(missing_req_var)?
86                    .parse()
87                    .wrap_err_with(|| {
88                        format!("failed to parse resource in {jsonfile} as entity Uid")
89                    })?;
90                let context = Context::from_json_value(qjson.context, schema.map(|s| (s, &action)))
91                    .wrap_err_with(|| format!("failed to create a context from {jsonfile}"))?;
92                Request::new(
93                    principal,
94                    action,
95                    resource,
96                    context,
97                    if self.request_validation {
98                        schema
99                    } else {
100                        None
101                    },
102                )
103                .map_err(|e| miette!("{e}"))
104            }
105            None => {
106                let principal = self
107                    .principal
108                    .as_ref()
109                    .map(|s| {
110                        s.parse().wrap_err_with(|| {
111                            format!("failed to parse principal {s} as entity Uid")
112                        })
113                    })
114                    .transpose()?;
115                let action = self
116                    .action
117                    .as_ref()
118                    .map(|s| {
119                        s.parse()
120                            .wrap_err_with(|| format!("failed to parse action {s} as entity Uid"))
121                    })
122                    .transpose()?;
123                let resource = self
124                    .resource
125                    .as_ref()
126                    .map(|s| {
127                        s.parse()
128                            .wrap_err_with(|| format!("failed to parse resource {s} as entity Uid"))
129                    })
130                    .transpose()?;
131                let context: Context = match &self.context_json_file {
132                    None => Context::empty(),
133                    Some(jsonfile) => match std::fs::OpenOptions::new().read(true).open(jsonfile) {
134                        Ok(f) => Context::from_json_file(
135                            f,
136                            schema.and_then(|s| Some((s, action.as_ref()?))),
137                        )
138                        .wrap_err_with(|| format!("failed to create a context from {jsonfile}"))?,
139                        Err(e) => Err(e).into_diagnostic().wrap_err_with(|| {
140                            format!("error while loading context from {jsonfile}")
141                        })?,
142                    },
143                };
144                match (principal, action, resource) {
145                    (Some(principal), Some(action), Some(resource)) => Request::new(
146                        principal,
147                        action,
148                        resource,
149                        context,
150                        if self.request_validation {
151                            schema
152                        } else {
153                            None
154                        },
155                    )
156                    .map_err(|e| miette!("{e}")),
157                    _ => Err(missing_req_var()),
158                }
159            }
160        }
161    }
162}
163
164/// This struct is the serde structure expected for --request-json
165#[derive(Clone, Debug, Deserialize)]
166pub(crate) struct RequestJSON {
167    /// Principal for the request
168    #[serde(default)]
169    pub principal: Option<String>,
170    /// Action for the request
171    #[serde(default)]
172    pub action: Option<String>,
173    /// Resource for the request
174    #[serde(default)]
175    pub resource: Option<String>,
176    /// Context for the request
177    pub context: serde_json::Value,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::utils::test_utils::{render_err, TEMPFILE_FILTER};
184    use std::io::Write;
185
186    fn mk_request(
187        principal: Option<&str>,
188        action: Option<&str>,
189        resource: Option<&str>,
190        context_file: Option<&str>,
191        request_json_file: Option<&str>,
192    ) -> RequestArgs {
193        RequestArgs {
194            principal: principal.map(String::from),
195            action: action.map(String::from),
196            resource: resource.map(String::from),
197            context_json_file: context_file.map(String::from),
198            request_json_file: request_json_file.map(String::from),
199            request_validation: false,
200        }
201    }
202
203    #[test]
204    fn request_missing_args() {
205        let args = mk_request(Some(r#"User::"alice""#), None, None, None, None);
206        let err = args.get_request(None).unwrap_err();
207        insta::assert_snapshot!(render_err(&err), @r"× All three (`principal`, `action`, `resource`) variables must be specified");
208    }
209
210    #[test]
211    fn request_bad_principal() {
212        let args = mk_request(
213            Some("not_an_euid"),
214            Some(r#"Action::"view""#),
215            Some(r#"Photo::"pic""#),
216            None,
217            None,
218        );
219        let err = args.get_request(None).unwrap_err();
220        insta::assert_snapshot!(render_err(&err), @r"
221         × failed to parse principal not_an_euid as entity Uid
222         ╰─▶ unexpected end of input
223          ╭────
224        1 │ not_an_euid
225          ╰────
226        ");
227    }
228
229    #[test]
230    fn request_from_json_file_invalid() {
231        let mut f = tempfile::NamedTempFile::new().unwrap();
232        f.write_all(
233            br#"{"principal":"User::\"alice\"", "resource":"Photo::\"pic\"","context":{}}"#,
234        )
235        .unwrap();
236        let args = mk_request(None, None, None, None, Some(f.path().to_str().unwrap()));
237        let err = args.get_request(None).unwrap_err();
238        insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
239            insta::assert_snapshot!(render_err(&err), @"  × All three (`principal`, `action`, `resource`) variables must be specified");
240        });
241    }
242
243    #[test]
244    fn request_from_missing_json_file() {
245        let args = mk_request(
246            None,
247            None,
248            None,
249            None,
250            Some("/tmp/nonexistent_request.json"),
251        );
252        let err = args.get_request(None).unwrap_err();
253        insta::assert_snapshot!(render_err(&err), @r"
254        × failed to open request-json file /tmp/nonexistent_request.json
255        ╰─▶ No such file or directory (os error 2)
256        ");
257    }
258
259    #[test]
260    fn request_with_missing_context_file() {
261        let args = mk_request(
262            Some(r#"User::"alice""#),
263            Some(r#"Action::"view""#),
264            Some(r#"Photo::"pic""#),
265            Some("/tmp/nonexistent_context.json"),
266            None,
267        );
268        let err = args.get_request(None).unwrap_err();
269        insta::assert_snapshot!(render_err(&err), @r"
270        × error while loading context from /tmp/nonexistent_context.json
271        ╰─▶ No such file or directory (os error 2)
272        ");
273    }
274}