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(|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#[derive(Clone, Debug, Deserialize)]
166pub(crate) struct RequestJSON {
167 #[serde(default)]
169 pub principal: Option<String>,
170 #[serde(default)]
172 pub action: Option<String>,
173 #[serde(default)]
175 pub resource: Option<String>,
176 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}