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(Into::into)
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) => {
134                        let f = std::fs::OpenOptions::new()
135                            .read(true)
136                            .open(jsonfile)
137                            .into_diagnostic()
138                            .wrap_err_with(|| {
139                                format!("error while loading context from {jsonfile}")
140                            })?;
141                        Context::from_json_file(f, schema.zip(action.as_ref())).wrap_err_with(
142                            || format!("failed to create a context from {jsonfile}"),
143                        )?
144                    }
145                };
146                match (principal, action, resource) {
147                    (Some(principal), Some(action), Some(resource)) => Request::new(
148                        principal,
149                        action,
150                        resource,
151                        context,
152                        if self.request_validation {
153                            schema
154                        } else {
155                            None
156                        },
157                    )
158                    .map_err(Into::into),
159                    _ => Err(missing_req_var()),
160                }
161            }
162        }
163    }
164}
165
166/// This struct is the serde structure expected for --request-json
167#[derive(Clone, Debug, Deserialize)]
168pub(crate) struct RequestJSON {
169    /// Principal for the request
170    #[serde(default)]
171    pub principal: Option<String>,
172    /// Action for the request
173    #[serde(default)]
174    pub action: Option<String>,
175    /// Resource for the request
176    #[serde(default)]
177    pub resource: Option<String>,
178    /// Context for the request
179    pub context: serde_json::Value,
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::utils::test_utils::{render_err, TEMPFILE_FILTER};
186    use std::io::Write;
187
188    fn mk_request(
189        principal: Option<&str>,
190        action: Option<&str>,
191        resource: Option<&str>,
192        context_file: Option<&str>,
193        request_json_file: Option<&str>,
194    ) -> RequestArgs {
195        RequestArgs {
196            principal: principal.map(String::from),
197            action: action.map(String::from),
198            resource: resource.map(String::from),
199            context_json_file: context_file.map(String::from),
200            request_json_file: request_json_file.map(String::from),
201            request_validation: false,
202        }
203    }
204
205    #[test]
206    fn request_missing_args() {
207        let args = mk_request(Some(r#"User::"alice""#), None, None, None, None);
208        let err = args.get_request(None).unwrap_err();
209        insta::assert_snapshot!(render_err(&err), @r"× All three (`principal`, `action`, `resource`) variables must be specified");
210    }
211
212    #[test]
213    fn request_bad_principal() {
214        let args = mk_request(
215            Some("not_an_euid"),
216            Some(r#"Action::"view""#),
217            Some(r#"Photo::"pic""#),
218            None,
219            None,
220        );
221        let err = args.get_request(None).unwrap_err();
222        insta::assert_snapshot!(render_err(&err), @r"
223         × failed to parse principal not_an_euid as entity Uid
224         ╰─▶ unexpected end of input
225          ╭────
226        1 │ not_an_euid
227          ╰────
228        ");
229    }
230
231    #[test]
232    fn request_from_json_file_invalid() {
233        let mut f = tempfile::NamedTempFile::new().unwrap();
234        f.write_all(
235            br#"{"principal":"User::\"alice\"", "resource":"Photo::\"pic\"","context":{}}"#,
236        )
237        .unwrap();
238        let args = mk_request(None, None, None, None, Some(f.path().to_str().unwrap()));
239        let err = args.get_request(None).unwrap_err();
240        insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
241            insta::assert_snapshot!(render_err(&err), @"  × All three (`principal`, `action`, `resource`) variables must be specified");
242        });
243    }
244
245    #[test]
246    fn request_from_missing_json_file() {
247        let args = mk_request(
248            None,
249            None,
250            None,
251            None,
252            Some("/tmp/nonexistent_request.json"),
253        );
254        let err = args.get_request(None).unwrap_err();
255        insta::assert_snapshot!(render_err(&err), @r"
256        × failed to open request-json file /tmp/nonexistent_request.json
257        ╰─▶ No such file or directory (os error 2)
258        ");
259    }
260
261    #[test]
262    fn request_with_missing_context_file() {
263        let args = mk_request(
264            Some(r#"User::"alice""#),
265            Some(r#"Action::"view""#),
266            Some(r#"Photo::"pic""#),
267            Some("/tmp/nonexistent_context.json"),
268            None,
269        );
270        let err = args.get_request(None).unwrap_err();
271        insta::assert_snapshot!(render_err(&err), @r"
272        × error while loading context from /tmp/nonexistent_context.json
273        ╰─▶ No such file or directory (os error 2)
274        ");
275    }
276}