use crate::CedarExitCode;
use clap::Args;
use crate::{PoliciesArgs, SchemaArgs};
use cedar_policy::{
Context, Decision, EntityId, EntityUid, PartialEntities, PartialEntityUid, PartialRequest,
PolicySet, Schema,
};
use miette::{miette, IntoDiagnostic, Report, Result, WrapErr};
use serde::Deserialize;
use std::{path::Path, time::Instant};
#[derive(Args, Debug)]
pub struct TpeArgs {
#[command(flatten)]
pub request: TpeRequestArgs,
#[command(flatten)]
pub policies: PoliciesArgs,
#[command(flatten)]
pub schema: SchemaArgs,
#[arg(long = "entities", value_name = "FILE")]
pub entities_file: String,
#[arg(short, long)]
pub timing: bool,
}
#[derive(Args, Debug)]
pub struct TpeRequestArgs {
#[arg(long)]
pub principal_type: Option<String>,
#[arg(long)]
pub principal_eid: Option<String>,
#[arg(short, long)]
pub action: Option<String>,
#[arg(long)]
pub resource_type: Option<String>,
#[arg(long)]
pub resource_eid: Option<String>,
#[arg(short, long = "context", value_name = "FILE")]
pub context_json_file: Option<String>,
#[arg(long = "request-json", value_name = "FILE", conflicts_with_all = &["principal_type", "principal_eid", "action", "resource_type", "resource_eid", "context_json_file"])]
pub request_json_file: Option<String>,
}
#[derive(Deserialize)]
struct TpeRequestJSON {
pub(self) principal_type: String,
pub(self) principal_eid: Option<String>,
pub(self) action: String,
pub(self) resource_type: String,
pub(self) resource_eid: Option<String>,
pub(self) context: Option<serde_json::Value>,
}
impl TpeRequestArgs {
fn get_request(&self, schema: &Schema) -> Result<PartialRequest> {
let qjson: TpeRequestJSON = match self.request_json_file.as_ref() {
Some(jsonfile) => {
let jsonstring = std::fs::read_to_string(jsonfile)
.into_diagnostic()
.wrap_err_with(|| format!("failed to open request json file {jsonfile}"))?;
serde_json::from_str(&jsonstring)
.into_diagnostic()
.wrap_err_with(|| format!("failed to parse context-json file {jsonfile}"))?
}
None => TpeRequestJSON {
principal_type: self
.principal_type
.clone()
.ok_or_else(|| miette!("principal type must be specified"))?,
principal_eid: self.principal_eid.clone(),
action: self
.action
.clone()
.ok_or_else(|| miette!("action must be specified"))?,
resource_type: self
.resource_type
.clone()
.ok_or_else(|| miette!("resource type must be specified"))?,
resource_eid: self.resource_eid.clone(),
context: self
.context_json_file
.as_ref()
.map(|jsonfile| {
let jsonstring = std::fs::read_to_string(jsonfile)
.into_diagnostic()
.wrap_err_with(|| {
format!("failed to open context-json file {jsonfile}")
})?;
serde_json::from_str(&jsonstring)
.into_diagnostic()
.wrap_err_with(|| {
format!("failed to parse context-json file {jsonfile}")
})
})
.transpose()?,
},
};
let action: EntityUid = qjson
.action
.parse()
.wrap_err("failed to parse `action` as an entity UID")?;
Ok(PartialRequest::new(
PartialEntityUid::new(
qjson
.principal_type
.parse()
.wrap_err("failed to parse `principal_type` as an entity type name")?,
qjson.principal_eid.as_ref().map(EntityId::new),
),
action.clone(),
PartialEntityUid::new(
qjson
.resource_type
.parse()
.wrap_err("failed to parse `resource_type` as an entity type name")?,
qjson.resource_eid.as_ref().map(EntityId::new),
),
qjson
.context
.map(|val| {
Context::from_json_value(val, Some((schema, &action)))
.wrap_err("failed to parse request context")
})
.transpose()?,
schema,
)?)
}
}
pub fn tpe(args: &TpeArgs) -> CedarExitCode {
println!();
let ret = |errs| {
for err in errs {
println!("{err:?}");
}
CedarExitCode::Failure
};
let mut errs = vec![];
let policies = match args.policies.get_policy_set() {
Ok(pset) => pset,
Err(e) => {
errs.push(e);
PolicySet::new()
}
};
let schema: Schema = match args.schema.get_schema() {
Ok(opt) => opt,
Err(e) => {
errs.push(e);
return ret(errs);
}
};
let entities = match load_partial_entities(args.entities_file.clone(), &schema) {
Ok(entities) => entities,
Err(e) => {
errs.push(e);
PartialEntities::empty()
}
};
match args.request.get_request(&schema) {
Ok(request) if errs.is_empty() => {
let auth_start = Instant::now();
let ans = policies.tpe(&request, &entities, &schema);
let auth_dur = auth_start.elapsed();
match ans {
Ok(ans) => {
if args.timing {
println!(
"Authorization Time (micro seconds) : {}",
auth_dur.as_micros()
);
}
match ans.decision() {
Some(Decision::Allow) => {
println!("ALLOW");
CedarExitCode::Success
}
Some(Decision::Deny) => {
println!("DENY");
CedarExitCode::AuthorizeDeny
}
None => {
println!("UNKNOWN");
println!("All policy residuals:");
for p in ans.policies() {
println!("{p}");
}
CedarExitCode::Unknown
}
}
}
Err(err) => {
errs.push(Report::new(err));
ret(errs)
}
}
}
Ok(_) => ret(errs),
Err(e) => {
errs.push(e.wrap_err("failed to parse request"));
ret(errs)
}
}
}
fn load_partial_entities(
entities_filename: impl AsRef<Path>,
schema: &Schema,
) -> Result<PartialEntities> {
let f = std::fs::OpenOptions::new()
.read(true)
.open(entities_filename.as_ref())
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to open entities file {}",
entities_filename.as_ref().display()
)
})?;
let json = serde_json::from_reader(f)
.into_diagnostic()
.wrap_err_with(|| {
format!(
"failed to parse entities as JSON value from file {}",
entities_filename.as_ref().display()
)
})?;
PartialEntities::from_json_value(json, schema).wrap_err_with(|| {
format!(
"failed to parse entities from file {}",
entities_filename.as_ref().display()
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::test_utils::{render_err, TEMPFILE_FILTER};
use std::io::Write;
fn test_schema() -> Schema {
Schema::from_cedarschema_str(
r#"entity User; entity Photo; action view appliesTo { principal: User, resource: Photo };"#,
)
.unwrap()
.0
}
fn mk_tpe_request(
principal_type: Option<&str>,
action: Option<&str>,
resource_type: Option<&str>,
request_json_file: Option<&str>,
) -> TpeRequestArgs {
TpeRequestArgs {
principal_type: principal_type.map(String::from),
principal_eid: Some("alice".to_string()),
action: action.map(String::from),
resource_type: resource_type.map(String::from),
resource_eid: Some("pic".to_string()),
context_json_file: None,
request_json_file: request_json_file.map(String::from),
}
}
#[test]
fn tpe_request_bad_action() {
let args = mk_tpe_request(Some("User"), Some("not_an_action"), Some("Photo"), None);
let err = args.get_request(&test_schema()).unwrap_err();
insta::assert_snapshot!(render_err(&err), @"
× failed to parse `action` as an entity UID
╰─▶ unexpected end of input
╭────
1 │ not_an_action
╰────
");
}
#[test]
fn tpe_request_bad_principal_type() {
let args = mk_tpe_request(
Some("not a type!"),
Some(r#"Action::"view""#),
Some("Photo"),
None,
);
let err = args.get_request(&test_schema()).unwrap_err();
insta::assert_snapshot!(render_err(&err), @"
× failed to parse `principal_type` as an entity type name
╰─▶ unexpected token `a`
╭────
1 │ not a type!
· ┬
· ╰── expected `::`
╰────
");
}
#[test]
fn tpe_request_bad_resource_type() {
let args = mk_tpe_request(
Some("User"),
Some(r#"Action::"view""#),
Some("not a type!"),
None,
);
let err = args.get_request(&test_schema()).unwrap_err();
insta::assert_snapshot!(render_err(&err), @"
× failed to parse `resource_type` as an entity type name
╰─▶ unexpected token `a`
╭────
1 │ not a type!
· ┬
· ╰── expected `::`
╰────
");
}
#[test]
fn tpe_request_bad_context() {
let mut f = tempfile::NamedTempFile::new().unwrap();
f.write_all(
br#"{"principal_type":"User","principal_eid":"alice","action":"Action::\"view\"","resource_type":"Photo","resource_eid":"pic","context":123}"#,
)
.unwrap();
let args = mk_tpe_request(None, None, None, Some(f.path().to_str().unwrap()));
let err = args.get_request(&test_schema()).unwrap_err();
insta::with_settings!({filters => vec![TEMPFILE_FILTER]}, {
insta::assert_snapshot!(render_err(&err), @"
× failed to parse request context
╰─▶ while parsing context, type mismatch: value was expected to have type { }, but it actually has type long: `123`
");
});
}
}