1use cedar_policy::{Context, Request, Schema};
18use clap::{ArgAction, Args};
19use miette::{miette, IntoDiagnostic, Result, WrapErr};
20use serde::Deserialize;
21
22#[derive(Args, Debug)]
24pub struct RequestArgs {
25 #[arg(short = 'l', long)]
27 pub principal: Option<String>,
28 #[arg(short, long)]
30 pub action: Option<String>,
31 #[arg(short, long)]
33 pub resource: Option<String>,
34 #[arg(short, long = "context", value_name = "FILE")]
37 pub context_json_file: Option<String>,
38 #[arg(long = "request-json", value_name = "FILE", conflicts_with_all = &["principal", "action", "resource", "context_json_file"])]
43 pub request_json_file: Option<String>,
44 #[arg(long = "request-validation", action = ArgAction::Set, default_value_t = true)]
47 pub request_validation: bool,
48}
49
50impl RequestArgs {
51 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#[derive(Clone, Debug, Deserialize)]
168pub(crate) struct RequestJSON {
169 #[serde(default)]
171 pub principal: Option<String>,
172 #[serde(default)]
174 pub action: Option<String>,
175 #[serde(default)]
177 pub resource: Option<String>,
178 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}