Skip to main content

cedar_policy/ffi/
is_authorized.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
17//! JSON FFI entry points for the Cedar authorizer. The Cedar Wasm authorizer
18//! is generated from the [`is_authorized()`] function in this file.
19
20#![allow(
21    clippy::module_name_repetitions,
22    reason = "the natural FFI function names"
23)]
24
25use super::check_parse::CheckParseAnswer;
26#[cfg(feature = "partial-eval")]
27use super::utils::JsonValueWithNoDuplicateKeys;
28use super::utils::{Context, DetailedError, Entities, EntityUid, PolicySet, Schema, WithWarnings};
29use crate::{Authorizer, Decision, PolicyId, Request};
30use cedar_policy_core::validator::cedar_schema::SchemaWarning;
31use serde::{Deserialize, Serialize};
32use serde_with::serde_as;
33use std::collections::HashMap;
34use std::collections::HashSet;
35#[cfg(feature = "wasm")]
36use wasm_bindgen::prelude::wasm_bindgen;
37
38#[cfg(feature = "wasm")]
39extern crate tsify;
40
41thread_local!(
42    /// Per-thread authorizer instance, initialized on first use
43    static AUTHORIZER: Authorizer = Authorizer::new();
44    /// Thread-local storage for preparsed policy sets
45    /// RefCell provides interior mutability - allows mutation through immutable references
46    /// from thread_local!.with(), enabling both read (.borrow()) and write (.borrow_mut()) access
47    static PREPARSED_POLICY_SETS: std::cell::RefCell<HashMap<String, crate::PolicySet>> =
48        std::cell::RefCell::new(HashMap::new());
49    /// Thread-local storage for preparsed schemas
50    /// RefCell provides interior mutability - allows mutation through immutable references
51    /// from thread_local!.with(), enabling both read (.borrow()) and write (.borrow_mut()) access
52    static PREPARSED_SCHEMAS: std::cell::RefCell<HashMap<String, crate::Schema>> =
53        std::cell::RefCell::new(HashMap::new());
54);
55
56/// Basic interface, using [`AuthorizationCall`] and [`AuthorizationAnswer`] types
57#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "isAuthorized"))]
58pub fn is_authorized(call: AuthorizationCall) -> AuthorizationAnswer {
59    match call.parse() {
60        WithWarnings {
61            t: Ok((request, policies, entities)),
62            warnings,
63        } => AuthorizationAnswer::Success {
64            response: AUTHORIZER.with(|authorizer| {
65                authorizer
66                    .is_authorized(&request, &policies, &entities)
67                    .into()
68            }),
69            warnings: warnings.into_iter().map(Into::into).collect(),
70        },
71        WithWarnings {
72            t: Err(errors),
73            warnings,
74        } => AuthorizationAnswer::Failure {
75            errors: errors.into_iter().map(Into::into).collect(),
76            warnings: warnings.into_iter().map(Into::into).collect(),
77        },
78    }
79}
80
81/// Input is a JSON encoding of [`AuthorizationCall`] and output is a JSON
82/// encoding of [`AuthorizationAnswer`]
83///
84/// # Errors
85///
86/// Will return `Err` if the input JSON cannot be deserialized as an
87/// [`AuthorizationCall`].
88pub fn is_authorized_json(json: serde_json::Value) -> Result<serde_json::Value, serde_json::Error> {
89    let ans = is_authorized(serde_json::from_value(json)?);
90    serde_json::to_value(ans)
91}
92
93/// Input and output are strings containing serialized JSON, in the shapes
94/// expected by [`is_authorized_json()`]
95///
96/// # Errors
97///
98/// Will return `Err` if the input cannot be converted to valid JSON or
99/// deserialized as an [`AuthorizationCall`].
100pub fn is_authorized_json_str(json: &str) -> Result<String, serde_json::Error> {
101    let ans = is_authorized(serde_json::from_str(json)?);
102    serde_json::to_string(&ans)
103}
104
105/// Preparse and cache a policy set in thread-local storage
106///
107/// # Errors
108///
109/// Will return `Err` if the input cannot be parsed. Side-effect free on error.
110#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "preparsePolicySet"))]
111pub fn preparse_policy_set(pset_id: String, policies: PolicySet) -> CheckParseAnswer {
112    use super::check_parse::CheckParseAnswer;
113
114    // Parse the policy set directly (check_parse_policy_set consumes the input)
115    match policies.parse() {
116        Ok(parsed_policies) => {
117            PREPARSED_POLICY_SETS.with(|cache| {
118                cache.borrow_mut().insert(pset_id, parsed_policies);
119            });
120            CheckParseAnswer::Success
121        }
122        Err(errors) => CheckParseAnswer::Failure {
123            errors: errors.into_iter().map(Into::into).collect(),
124        },
125    }
126}
127
128/// Preparse and cache a schema in thread-local storage
129///
130/// # Errors
131///
132/// Will return `Err` if the input cannot be parsed. Side-effect free on error.
133#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "preparseSchema"))]
134pub fn preparse_schema(schema_name: String, schema: Schema) -> CheckParseAnswer {
135    use super::check_parse::CheckParseAnswer;
136
137    // Parse the schema directly (check_parse_schema consumes the input)
138    match schema.parse() {
139        Ok((parsed_schema, _warnings)) => {
140            PREPARSED_SCHEMAS.with(|cache| {
141                cache.borrow_mut().insert(schema_name, parsed_schema);
142            });
143            CheckParseAnswer::Success
144        }
145        Err(error) => CheckParseAnswer::Failure {
146            errors: vec![error.into()],
147        },
148    }
149}
150
151/// Basic interface for partial evaluation, using [`AuthorizationCall`] and
152/// [`PartialAuthorizationAnswer`] types
153#[doc = include_str!("../../experimental_warning.md")]
154#[cfg(feature = "partial-eval")]
155#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "isAuthorizedPartial"))]
156pub fn is_authorized_partial(call: PartialAuthorizationCall) -> PartialAuthorizationAnswer {
157    match call.parse() {
158        WithWarnings {
159            t: Ok((request, policies, entities)),
160            warnings,
161        } => {
162            let response = AUTHORIZER.with(|authorizer| {
163                authorizer.is_authorized_partial(&request, &policies, &entities)
164            });
165            let warnings = warnings.into_iter().map(Into::into).collect();
166            match ResidualResponse::try_from(response) {
167                Ok(response) => PartialAuthorizationAnswer::Residuals {
168                    response: Box::new(response),
169                    warnings,
170                },
171                Err(e) => PartialAuthorizationAnswer::Failure {
172                    errors: vec![miette::Report::new_boxed(e).into()],
173                    warnings,
174                },
175            }
176        }
177        WithWarnings {
178            t: Err(errors),
179            warnings,
180        } => PartialAuthorizationAnswer::Failure {
181            errors: errors.into_iter().map(Into::into).collect(),
182            warnings: warnings.into_iter().map(Into::into).collect(),
183        },
184    }
185}
186
187/// Input is a JSON encoding of [`AuthorizationCall`] and output is a JSON
188/// encoding of [`PartialAuthorizationAnswer`]
189///
190/// # Errors
191///
192/// Will return `Err` if the input JSON cannot be deserialized as an
193/// [`AuthorizationCall`].
194#[doc = include_str!("../../experimental_warning.md")]
195#[cfg(feature = "partial-eval")]
196pub fn is_authorized_partial_json(
197    json: serde_json::Value,
198) -> Result<serde_json::Value, serde_json::Error> {
199    let ans = is_authorized_partial(serde_json::from_value(json)?);
200    serde_json::to_value(ans)
201}
202
203/// Input and output are strings containing serialized JSON, in the shapes
204/// expected by [`is_authorized_partial_json()`]
205///
206/// # Errors
207///
208/// Will return `Err` if the input cannot be converted to valid JSON or
209/// deserialized as an [`AuthorizationCall`].
210#[doc = include_str!("../../experimental_warning.md")]
211#[cfg(feature = "partial-eval")]
212pub fn is_authorized_partial_json_str(json: &str) -> Result<String, serde_json::Error> {
213    let ans = is_authorized_partial(serde_json::from_str(json)?);
214    serde_json::to_string(&ans)
215}
216
217/// Interface version of a `Response` that uses the interface version of `Diagnostics`
218#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
219#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
220#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
221#[serde(rename_all = "camelCase")]
222#[serde(deny_unknown_fields)]
223pub struct Response {
224    /// Authorization decision
225    decision: Decision,
226    /// Diagnostics providing more information on how this decision was reached
227    diagnostics: Diagnostics,
228}
229
230/// Interface version of `Diagnostics` that stores error messages and warnings
231/// in the `DetailedError` format
232#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
233#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
234#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
235#[serde(rename_all = "camelCase")]
236#[serde(deny_unknown_fields)]
237pub struct Diagnostics {
238    /// Ids of the policies that contributed to the decision.
239    /// If no policies applied to the request, this set will be empty.
240    reason: HashSet<PolicyId>,
241    /// Set of errors that occurred
242    errors: HashSet<AuthorizationError>,
243}
244
245impl Response {
246    /// Construct a `Response`
247    pub fn new(
248        decision: Decision,
249        reason: HashSet<PolicyId>,
250        errors: HashSet<AuthorizationError>,
251    ) -> Self {
252        Self {
253            decision,
254            diagnostics: Diagnostics { reason, errors },
255        }
256    }
257
258    /// Get the authorization decision
259    pub fn decision(&self) -> Decision {
260        self.decision
261    }
262
263    /// Get the authorization diagnostics
264    pub fn diagnostics(&self) -> &Diagnostics {
265        &self.diagnostics
266    }
267}
268
269impl From<crate::Response> for Response {
270    fn from(response: crate::Response) -> Self {
271        let (reason, errors) = response.diagnostics.into_components();
272        Self::new(
273            response.decision,
274            reason.collect(),
275            errors.map(Into::into).collect(),
276        )
277    }
278}
279
280#[cfg(feature = "partial-eval")]
281impl From<crate::PartialResponse> for Response {
282    fn from(partial_response: crate::PartialResponse) -> Self {
283        partial_response.concretize().into()
284    }
285}
286
287impl Diagnostics {
288    /// Get the policies that contributed to the decision
289    pub fn reason(&self) -> impl Iterator<Item = &PolicyId> {
290        self.reason.iter()
291    }
292
293    /// Get the errors
294    pub fn errors(&self) -> impl Iterator<Item = &AuthorizationError> + '_ {
295        self.errors.iter()
296    }
297}
298
299/// Error (or warning) which occurred in a particular policy during authorization
300#[derive(Debug, PartialEq, Eq, Clone, Hash, Serialize, Deserialize)]
301#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
302#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
303#[serde(rename_all = "camelCase")]
304#[serde(deny_unknown_fields)]
305pub struct AuthorizationError {
306    /// Id of the policy where the error (or warning) occurred
307    #[cfg_attr(feature = "wasm", tsify(type = "string"))]
308    pub policy_id: PolicyId,
309    /// Error (or warning).
310    /// You can look at the `severity` field to see whether it is actually an
311    /// error or a warning.
312    pub error: DetailedError,
313}
314
315impl AuthorizationError {
316    /// Create an `AuthorizationError` from a policy ID and any `miette` error
317    pub fn new(
318        policy_id: impl Into<PolicyId>,
319        error: impl miette::Diagnostic + Send + Sync + 'static,
320    ) -> Self {
321        Self::new_from_report(policy_id, miette::Report::new(error))
322    }
323
324    /// Create an `AuthorizationError` from a policy ID and a `miette::Report`
325    pub fn new_from_report(policy_id: impl Into<PolicyId>, report: miette::Report) -> Self {
326        Self {
327            policy_id: policy_id.into(),
328            error: report.into(),
329        }
330    }
331}
332
333impl From<crate::AuthorizationError> for AuthorizationError {
334    fn from(e: crate::AuthorizationError) -> Self {
335        match e {
336            crate::AuthorizationError::PolicyEvaluationError(e) => {
337                Self::new(e.policy_id().clone(), e.into_inner())
338            }
339        }
340    }
341}
342
343#[doc(hidden)]
344impl From<cedar_policy_core::authorizer::AuthorizationError> for AuthorizationError {
345    fn from(e: cedar_policy_core::authorizer::AuthorizationError) -> Self {
346        crate::AuthorizationError::from(e).into()
347    }
348}
349
350/// FFI version of a [`crate::PartialResponse`]
351#[doc = include_str!("../../experimental_warning.md")]
352#[cfg(feature = "partial-eval")]
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
354#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
355#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
356#[serde(rename_all = "camelCase")]
357#[serde(deny_unknown_fields)]
358pub struct ResidualResponse {
359    decision: Option<Decision>,
360    satisfied: HashSet<PolicyId>,
361    errored: HashSet<PolicyId>,
362    may_be_determining: HashSet<PolicyId>,
363    must_be_determining: HashSet<PolicyId>,
364    #[cfg_attr(feature = "wasm", tsify(type = "Record<string, PolicyJson>"))]
365    residuals: HashMap<PolicyId, JsonValueWithNoDuplicateKeys>,
366    nontrivial_residuals: HashSet<PolicyId>,
367}
368
369#[cfg(feature = "partial-eval")]
370impl ResidualResponse {
371    /// Tri-state decision
372    pub fn decision(&self) -> Option<Decision> {
373        self.decision
374    }
375
376    /// Set of all satisfied policy Ids
377    pub fn satisfied(&self) -> impl Iterator<Item = &PolicyId> {
378        self.satisfied.iter()
379    }
380
381    /// Set of all policy ids for policies that errored
382    pub fn errored(&self) -> impl Iterator<Item = &PolicyId> {
383        self.errored.iter()
384    }
385
386    /// Over approximation of policies that determine the auth decision
387    pub fn may_be_determining(&self) -> impl Iterator<Item = &PolicyId> {
388        self.may_be_determining.iter()
389    }
390
391    /// Under approximation of policies that determine the auth decision
392    pub fn must_be_determining(&self) -> impl Iterator<Item = &PolicyId> {
393        self.must_be_determining.iter()
394    }
395
396    /// (Borrowed) Iterator over the set of residual policies
397    pub fn residuals(&self) -> impl Iterator<Item = &JsonValueWithNoDuplicateKeys> {
398        self.residuals.values()
399    }
400
401    /// (Owned) Iterator over the set of residual policies
402    pub fn into_residuals(self) -> impl Iterator<Item = JsonValueWithNoDuplicateKeys> {
403        self.residuals.into_values()
404    }
405
406    /// Get the residual policy for a specified id if it exists
407    pub fn residual(&self, p: &PolicyId) -> Option<&JsonValueWithNoDuplicateKeys> {
408        self.residuals.get(p)
409    }
410
411    /// (Borrowed) Iterator over the set of non-trivial residual policies
412    pub fn nontrivial_residuals(&self) -> impl Iterator<Item = &JsonValueWithNoDuplicateKeys> {
413        self.residuals.iter().filter_map(|(id, policy)| {
414            if self.nontrivial_residuals.contains(id) {
415                Some(policy)
416            } else {
417                None
418            }
419        })
420    }
421
422    ///  Iterator over the set of non-trivial residual policy ids
423    pub fn nontrivial_residual_ids(&self) -> impl Iterator<Item = &PolicyId> {
424        self.nontrivial_residuals.iter()
425    }
426}
427
428#[cfg(feature = "partial-eval")]
429impl TryFrom<crate::PartialResponse> for ResidualResponse {
430    type Error = Box<dyn miette::Diagnostic + Send + Sync + 'static>;
431
432    fn try_from(partial_response: crate::PartialResponse) -> Result<Self, Self::Error> {
433        Ok(Self {
434            decision: partial_response.decision(),
435            satisfied: partial_response
436                .definitely_satisfied()
437                .map(|p| p.id().clone())
438                .collect(),
439            errored: partial_response.definitely_errored().cloned().collect(),
440            may_be_determining: partial_response
441                .may_be_determining()
442                .map(|p| p.id().clone())
443                .collect(),
444            must_be_determining: partial_response
445                .must_be_determining()
446                .map(|p| p.id().clone())
447                .collect(),
448            nontrivial_residuals: partial_response
449                .nontrivial_residuals()
450                .map(|p| p.id().clone())
451                .collect(),
452            residuals: partial_response
453                .all_residuals()
454                .map(|e| e.to_json().map(|json| (e.id().clone(), json.into())))
455                .collect::<Result<_, _>>()?,
456        })
457    }
458}
459
460/// Answer struct from authorization call
461#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
462#[serde(tag = "type")]
463#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
464#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
465#[serde(rename_all = "camelCase")]
466pub enum AuthorizationAnswer {
467    /// Represents a failure to parse or call the authorizer entirely
468    #[serde(rename_all = "camelCase")]
469    Failure {
470        /// Errors encountered
471        errors: Vec<DetailedError>,
472        /// Warnings encountered
473        warnings: Vec<DetailedError>,
474    },
475    /// Represents a successful authorization call (although individual policy
476    /// evaluation may still have errors)
477    #[serde(rename_all = "camelCase")]
478    Success {
479        /// Authorization decision and diagnostics, which may include policy
480        /// evaluation errors
481        response: Response,
482        /// Warnings encountered. These are all warnings not generated by
483        /// authorization itself -- e.g. general warnings about your schema,
484        /// entity data, etc. Warnings generated by authorization are part of
485        /// `response`.
486        warnings: Vec<DetailedError>,
487    },
488}
489
490/// Answer struct from partial-authorization call
491#[cfg(feature = "partial-eval")]
492#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
493#[serde(tag = "type")]
494#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
495#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
496#[serde(rename_all = "camelCase")]
497pub enum PartialAuthorizationAnswer {
498    /// Represents a failure to parse or call the authorizer entirely
499    #[serde(rename_all = "camelCase")]
500    Failure {
501        /// Errors encountered
502        errors: Vec<DetailedError>,
503        /// Warnings encountered
504        warnings: Vec<DetailedError>,
505    },
506    /// Represents a successful authorization call with either a partial or
507    /// concrete answer.  Individual policy evaluation may still have errors.
508    #[serde(rename_all = "camelCase")]
509    Residuals {
510        /// Information about the authorization decision and residuals
511        response: Box<ResidualResponse>,
512        /// Warnings encountered. These are all warnings not generated by
513        /// authorization itself -- e.g. general warnings about your schema,
514        /// entity data, etc. Warnings generated by authorization are part of
515        /// `response`.
516        warnings: Vec<DetailedError>,
517    },
518}
519
520/// Struct containing the input data for authorization
521#[serde_as]
522#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
523#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
524#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
525#[serde(rename_all = "camelCase")]
526#[serde(deny_unknown_fields)]
527pub struct AuthorizationCall {
528    /// The principal taking action
529    principal: EntityUid,
530    /// The action the principal is taking
531    action: EntityUid,
532    /// The resource being acted on by the principal
533    resource: EntityUid,
534    /// The context details specific to the request
535    context: Context,
536    /// Optional schema.
537    /// If present, this will inform the parsing: for instance, it will allow
538    /// `__entity` and `__extn` escapes to be implicit, and it will error if
539    /// attributes have the wrong types (e.g., string instead of integer).
540    #[cfg_attr(feature = "wasm", tsify(optional, type = "Schema"))]
541    schema: Option<Schema>,
542    /// If this is `true` and a schema is provided, perform request validation.
543    /// If this is `false`, the schema will only be used for schema-based
544    /// parsing of `context`, and not for request validation.
545    /// If a schema is not provided, this option has no effect.
546    #[serde(default = "constant_true")]
547    validate_request: bool,
548    /// The set of policies to use during authorization
549    policies: PolicySet,
550    /// The set of entities to use during authorization
551    entities: Entities,
552}
553
554/// Struct containing the input data for stateful authorization using preparsed schemas and policy sets
555#[serde_as]
556#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
557#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
558#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
559#[serde(rename_all = "camelCase")]
560#[serde(deny_unknown_fields)]
561pub struct StatefulAuthorizationCall {
562    /// The principal taking action
563    principal: EntityUid,
564    /// The action the principal is taking
565    action: EntityUid,
566    /// The resource being acted on by the principal
567    resource: EntityUid,
568    /// The context details specific to the request
569    context: Context,
570    /// Optional name of preparsed schema.
571    /// If present, this will inform the parsing: for instance, it will allow
572    /// `__entity` and `__extn` escapes to be implicit, and it will error if
573    /// attributes have the wrong types (e.g., string instead of integer).
574    #[cfg_attr(feature = "wasm", tsify(optional, type = "string"))]
575    preparsed_schema_name: Option<String>,
576    /// If this is `true` and a schema is provided, perform request validation.
577    /// If this is `false`, the schema will only be used for schema-based
578    /// parsing of `context`, and not for request validation.
579    /// If a schema is not provided, this option has no effect.
580    #[serde(default = "constant_true")]
581    validate_request: bool,
582    /// The name of the preparsed policy set to use during authorization
583    preparsed_policy_set_id: String,
584    /// The set of entities to use during authorization
585    entities: Entities,
586}
587
588/// Struct containing the input data for partial authorization
589#[cfg(feature = "partial-eval")]
590#[serde_as]
591#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
592#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
593#[cfg_attr(feature = "wasm", tsify(into_wasm_abi, from_wasm_abi))]
594#[serde(rename_all = "camelCase")]
595#[serde(deny_unknown_fields)]
596pub struct PartialAuthorizationCall {
597    /// The principal taking action. If this field is empty, then the principal is unknown.
598    principal: Option<EntityUid>,
599    /// The action the principal is taking. If this field is empty, then the action is unknown.
600    action: Option<EntityUid>,
601    /// The resource being acted on by the principal. If this field is empty, then the resource is unknown.
602    resource: Option<EntityUid>,
603    /// The context details specific to the request
604    context: Context,
605    /// Optional schema.
606    /// If present, this will inform the parsing: for instance, it will allow
607    /// `__entity` and `__extn` escapes to be implicit, and it will error if
608    /// attributes have the wrong types (e.g., string instead of integer).
609    #[cfg_attr(feature = "wasm", tsify(optional, type = "Schema"))]
610    schema: Option<Schema>,
611    /// If this is `true` and a schema is provided, perform request validation.
612    /// If this is `false`, the schema will only be used for schema-based
613    /// parsing of `context`, and not for request validation.
614    /// If a schema is not provided, this option has no effect.
615    #[serde(default = "constant_true")]
616    validate_request: bool,
617    /// The set of policies to use during authorization
618    policies: PolicySet,
619    /// The set of entities to use during authorization
620    entities: Entities,
621}
622
623/// Stateful authorization using preparsed schemas and policy sets.
624///
625/// This function works like [`is_authorized`] but retrieves schemas and policy sets
626/// from thread-local cache instead of parsing them on each call.
627#[cfg_attr(feature = "wasm", wasm_bindgen(js_name = "statefulIsAuthorized"))]
628pub fn stateful_is_authorized(call: StatefulAuthorizationCall) -> AuthorizationAnswer {
629    match call.parse() {
630        WithWarnings {
631            t: Ok((request, policies, entities)),
632            warnings,
633        } => AuthorizationAnswer::Success {
634            response: AUTHORIZER.with(|authorizer| {
635                authorizer
636                    .is_authorized(&request, &policies, &entities)
637                    .into()
638            }),
639            warnings: warnings.into_iter().map(Into::into).collect(),
640        },
641        WithWarnings {
642            t: Err(errors),
643            warnings,
644        } => AuthorizationAnswer::Failure {
645            errors: errors.into_iter().map(Into::into).collect(),
646            warnings: warnings.into_iter().map(Into::into).collect(),
647        },
648    }
649}
650
651fn constant_true() -> bool {
652    true
653}
654
655fn build_error<T>(
656    errs: Vec<miette::Report>,
657    warnings: Vec<SchemaWarning>,
658) -> WithWarnings<Result<T, Vec<miette::Report>>> {
659    WithWarnings {
660        t: Err(errs),
661        warnings: warnings.into_iter().map(Into::into).collect(),
662    }
663}
664
665impl AuthorizationCall {
666    fn parse(
667        self,
668    ) -> WithWarnings<Result<(Request, crate::PolicySet, crate::Entities), Vec<miette::Report>>>
669    {
670        let mut errs = vec![];
671        let mut warnings = vec![];
672        let maybe_schema = self
673            .schema
674            .map(|schema| {
675                schema.parse().map(|(schema, new_warnings)| {
676                    warnings.extend(new_warnings);
677                    schema
678                })
679            })
680            .transpose()
681            .map_err(|e| errs.push(e));
682        let maybe_principal = self
683            .principal
684            .parse(Some("principal"))
685            .map_err(|e| errs.push(e));
686        let maybe_action = self.action.parse(Some("action")).map_err(|e| errs.push(e));
687        let maybe_resource = self
688            .resource
689            .parse(Some("resource"))
690            .map_err(|e| errs.push(e));
691
692        let (Ok(schema), Ok(principal), Ok(action), Ok(resource)) =
693            (maybe_schema, maybe_principal, maybe_action, maybe_resource)
694        else {
695            // At least one of the `errs.push(e)` statements above must have been reached
696            return build_error(errs, warnings);
697        };
698
699        let context = match self.context.parse(schema.as_ref(), Some(&action)) {
700            Ok(context) => context,
701            Err(e) => {
702                return build_error(vec![e], warnings);
703            }
704        };
705
706        let schema_opt = if self.validate_request {
707            schema.as_ref()
708        } else {
709            None
710        };
711        let maybe_request = Request::new(principal, action, resource, context, schema_opt)
712            .map_err(|e| errs.push(e.into()));
713        let maybe_entities = self
714            .entities
715            .parse(schema.as_ref())
716            .map_err(|e| errs.push(e));
717        let maybe_policies = self.policies.parse().map_err(|es| errs.extend(es));
718
719        match (maybe_request, maybe_policies, maybe_entities) {
720            (Ok(request), Ok(policies), Ok(entities)) => WithWarnings {
721                t: Ok((request, policies, entities)),
722                warnings: warnings.into_iter().map(Into::into).collect(),
723            },
724            _ => {
725                // At least one of the `errs.push(e)` statements above must have been reached
726                build_error(errs, warnings)
727            }
728        }
729    }
730}
731
732impl StatefulAuthorizationCall {
733    /// Parse [`StatefulAuthorizationCall`] into components needed for authorization
734    /// Retrieves preparsed schema and policy set from thread-local storage
735    fn parse(
736        self,
737    ) -> WithWarnings<Result<(Request, crate::PolicySet, crate::Entities), Vec<miette::Report>>>
738    {
739        let mut errs = vec![];
740        let warnings = vec![];
741
742        // Retrieve preparsed schema from thread-local cache if specified
743        let maybe_schema: Result<Option<crate::Schema>, ()> =
744            self.preparsed_schema_name.map_or_else(
745                || Ok(None),
746                |schema_name| {
747                    PREPARSED_SCHEMAS
748                        .with(|cache| cache.borrow().get(&schema_name).cloned())
749                        .map_or_else(
750                            || {
751                                errs.push(miette::miette!(
752                                    "preparsed schema '{}' not found",
753                                    schema_name
754                                ));
755                                Ok(None)
756                            },
757                            |schema| Ok(Some(schema)),
758                        )
759                },
760            );
761
762        // Retrieve preparsed policy set from thread-local cache (required)
763        let maybe_policies: Result<crate::PolicySet, ()> = if let Some(policies) =
764            PREPARSED_POLICY_SETS
765                .with(|cache| cache.borrow().get(&self.preparsed_policy_set_id).cloned())
766        {
767            Ok(policies)
768        } else {
769            errs.push(miette::miette!(
770                "preparsed policy set '{}' not found",
771                self.preparsed_policy_set_id
772            ));
773            Err(())
774        };
775
776        // Parse principal, action, and resource (same as regular authorization)
777        let maybe_principal = self
778            .principal
779            .parse(Some("principal"))
780            .map_err(|e| errs.push(e));
781        let maybe_action = self.action.parse(Some("action")).map_err(|e| errs.push(e));
782        let maybe_resource = self
783            .resource
784            .parse(Some("resource"))
785            .map_err(|e| errs.push(e));
786
787        // Early return if any parsing failed
788        let (Ok(schema), Ok(policies), Ok(principal), Ok(action), Ok(resource)) = (
789            maybe_schema,
790            maybe_policies,
791            maybe_principal,
792            maybe_action,
793            maybe_resource,
794        ) else {
795            return build_error(errs, warnings);
796        };
797
798        // Parse context using schema if available
799        let context = match self.context.parse(schema.as_ref(), Some(&action)) {
800            Ok(context) => context,
801            Err(e) => {
802                return build_error(vec![e], warnings);
803            }
804        };
805
806        // Build request with optional schema validation
807        let schema_opt = if self.validate_request {
808            schema.as_ref()
809        } else {
810            None
811        };
812        let maybe_request = Request::new(principal, action, resource, context, schema_opt)
813            .map_err(|e| errs.push(e.into()));
814        let maybe_entities = self
815            .entities
816            .parse(schema.as_ref())
817            .map_err(|e| errs.push(e));
818
819        // Return final result or accumulated errors
820        match (maybe_request, maybe_entities) {
821            (Ok(request), Ok(entities)) => WithWarnings {
822                t: Ok((request, policies, entities)),
823                warnings: warnings.into_iter().map(Into::into).collect(),
824            },
825            _ => build_error(errs, warnings),
826        }
827    }
828}
829
830#[cfg(feature = "partial-eval")]
831impl PartialAuthorizationCall {
832    fn parse(
833        self,
834    ) -> WithWarnings<Result<(Request, crate::PolicySet, crate::Entities), Vec<miette::Report>>>
835    {
836        let mut errs = vec![];
837        let mut warnings = vec![];
838        let maybe_schema = self
839            .schema
840            .map(|schema| {
841                schema.parse().map(|(schema, new_warnings)| {
842                    warnings.extend(new_warnings);
843                    schema
844                })
845            })
846            .transpose()
847            .map_err(|e| errs.push(e));
848        let maybe_principal = self
849            .principal
850            .map(|uid| uid.parse(Some("principal")))
851            .transpose()
852            .map_err(|e| errs.push(e));
853        let maybe_action = self
854            .action
855            .map(|uid| uid.parse(Some("action")))
856            .transpose()
857            .map_err(|e| errs.push(e));
858        let maybe_resource = self
859            .resource
860            .map(|uid| uid.parse(Some("resource")))
861            .transpose()
862            .map_err(|e| errs.push(e));
863
864        let (Ok(schema), Ok(principal), Ok(action), Ok(resource)) =
865            (maybe_schema, maybe_principal, maybe_action, maybe_resource)
866        else {
867            // At least one of the `errs.push(e)` statements above must have been reached
868            return build_error(errs, warnings);
869        };
870
871        let context = match self.context.parse(schema.as_ref(), action.as_ref()) {
872            Ok(context) => context,
873            Err(e) => {
874                return build_error(vec![e], warnings);
875            }
876        };
877
878        let maybe_entities = self
879            .entities
880            .parse(schema.as_ref())
881            .map_err(|e| errs.push(e));
882        let maybe_policies = self.policies.parse().map_err(|es| errs.extend(es));
883
884        let mut b = Request::builder();
885        if let Some(p) = principal {
886            b = b.principal(p);
887        }
888        if let Some(a) = action {
889            b = b.action(a);
890        }
891        if let Some(r) = resource {
892            b = b.resource(r);
893        }
894        b = b.context(context);
895
896        let maybe_request = match schema {
897            Some(schema) if self.validate_request => {
898                b.schema(&schema).build().map_err(|e| errs.push(e.into()))
899            }
900            _ => Ok(b.build()),
901        };
902
903        match (maybe_request, maybe_policies, maybe_entities) {
904            (Ok(request), Ok(policies), Ok(entities)) => WithWarnings {
905                t: Ok((request, policies, entities)),
906                warnings: warnings.into_iter().map(Into::into).collect(),
907            },
908            _ => {
909                // At least one of the `errs.push(e)` statements above must have been reached
910                build_error(errs, warnings)
911            }
912        }
913    }
914}
915
916#[cfg(test)]
917#[expect(clippy::panic, clippy::indexing_slicing, reason = "unit tests")]
918mod test {
919    use super::*;
920
921    use crate::ffi::test_utils::*;
922    use cool_asserts::assert_matches;
923    use serde_json::json;
924
925    /// Assert that [`is_authorized_json()`] returns `Allow` with no errors
926    #[track_caller]
927    fn assert_is_authorized_json(json: serde_json::Value) {
928        let ans_val =
929            is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`");
930        let result: Result<AuthorizationAnswer, _> = serde_json::from_value(ans_val);
931        assert_matches!(result, Ok(AuthorizationAnswer::Success { response, .. }) => {
932            assert_eq!(response.decision(), Decision::Allow);
933            let errors: Vec<&AuthorizationError> = response.diagnostics().errors().collect();
934            assert_eq!(errors.len(), 0, "{errors:?}");
935        });
936    }
937
938    /// Assert that [`is_authorized_json()`] returns `Deny` with no errors
939    #[track_caller]
940    fn assert_is_not_authorized_json(json: serde_json::Value) {
941        let ans_val =
942            is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`");
943        let result: Result<AuthorizationAnswer, _> = serde_json::from_value(ans_val);
944        assert_matches!(result, Ok(AuthorizationAnswer::Success { response, .. }) => {
945            assert_eq!(response.decision(), Decision::Deny);
946            let errors: Vec<&AuthorizationError> = response.diagnostics().errors().collect();
947            assert_eq!(errors.len(), 0, "{errors:?}");
948        });
949    }
950
951    /// Assert that [`is_authorized_json_str()`] returns a `serde_json::Error`
952    /// error with a message that matches `msg`
953    #[track_caller]
954    fn assert_is_authorized_json_str_is_failure(call: &str, msg: &str) {
955        assert_matches!(is_authorized_json_str(call), Err(e) => {
956            assert_eq!(e.to_string(), msg);
957        });
958    }
959
960    /// Assert that [`is_authorized_json()`] returns [`AuthorizationAnswer::Failure`]
961    /// and return the enclosed errors
962    #[track_caller]
963    fn assert_is_authorized_json_is_failure(json: serde_json::Value) -> Vec<DetailedError> {
964        let ans_val =
965            is_authorized_json(json).expect("expected input to parse as an `AuthorizationCall`");
966        let result: Result<AuthorizationAnswer, _> = serde_json::from_value(ans_val);
967        assert_matches!(result, Ok(AuthorizationAnswer::Failure { errors, .. }) => errors)
968    }
969
970    #[test]
971    fn test_failure_on_invalid_syntax() {
972        assert_is_authorized_json_str_is_failure(
973            "iefjieoafiaeosij",
974            "expected value at line 1 column 1",
975        );
976    }
977
978    #[test]
979    fn test_not_authorized_on_empty_slice() {
980        let call = json!({
981            "principal": {
982             "type": "User",
983             "id": "alice"
984            },
985            "action": {
986             "type": "Photo",
987             "id": "view"
988            },
989            "resource": {
990             "type": "Photo",
991             "id": "door"
992            },
993            "context": {},
994            "policies": {},
995            "entities": []
996        });
997        assert_is_not_authorized_json(call);
998    }
999
1000    #[test]
1001    fn test_not_authorized_on_unspecified() {
1002        let call = json!({
1003            "principal": null,
1004            "action": {
1005             "type": "Photo",
1006             "id": "view"
1007            },
1008            "resource": {
1009             "type": "Photo",
1010             "id": "door"
1011            },
1012            "context": {},
1013            "policies": {
1014                "staticPolicies": {
1015                    "ID1": "permit(principal == User::\"alice\", action, resource);"
1016                }
1017            },
1018            "entities": []
1019        });
1020        // unspecified entities are no longer supported
1021        let errs = assert_is_authorized_json_is_failure(call);
1022        assert_exactly_one_error(
1023            &errs,
1024            "failed to parse principal: in uid field of <unknown entity>, expected a literal entity reference, but got `null`",
1025            Some("literal entity references can be made with `{ \"type\": \"SomeType\", \"id\": \"SomeId\" }`"),
1026        );
1027    }
1028
1029    #[test]
1030    fn test_authorized_on_simple_slice() {
1031        let call = json!({
1032            "principal": {
1033             "type": "User",
1034             "id": "alice"
1035            },
1036            "action": {
1037             "type": "Photo",
1038             "id": "view"
1039            },
1040            "resource": {
1041             "type": "Photo",
1042             "id": "door"
1043            },
1044            "context": {},
1045            "policies": {
1046                "staticPolicies": {
1047                    "ID1": "permit(principal == User::\"alice\", action, resource);"
1048                }
1049            },
1050            "entities": []
1051        });
1052        assert_is_authorized_json(call);
1053    }
1054
1055    #[test]
1056    fn test_authorized_on_simple_slice_with_string_policies() {
1057        let call = json!({
1058            "principal": {
1059             "type": "User",
1060             "id": "alice"
1061            },
1062            "action": {
1063             "type": "Photo",
1064             "id": "view"
1065            },
1066            "resource": {
1067             "type": "Photo",
1068             "id": "door"
1069            },
1070            "context": {},
1071            "policies": {
1072                "staticPolicies": "permit(principal == User::\"alice\", action, resource);"
1073            },
1074            "entities": []
1075        });
1076        assert_is_authorized_json(call);
1077    }
1078
1079    #[test]
1080    fn test_authorized_on_simple_slice_with_context() {
1081        let call = json!({
1082            "principal": {
1083             "type": "User",
1084             "id": "alice"
1085            },
1086            "action": {
1087             "type": "Photo",
1088             "id": "view"
1089            },
1090            "resource": {
1091             "type": "Photo",
1092             "id": "door"
1093            },
1094            "context": {
1095             "is_authenticated": true,
1096             "source_ip": {
1097                "__extn" : { "fn" : "ip", "arg" : "222.222.222.222" }
1098             }
1099            },
1100            "policies": {
1101                "staticPolicies": "permit(principal == User::\"alice\", action, resource) when { context.is_authenticated && context.source_ip.isInRange(ip(\"222.222.222.0/24\")) };"
1102            },
1103            "entities": []
1104        });
1105        assert_is_authorized_json(call);
1106    }
1107
1108    #[test]
1109    #[cfg(feature = "variadic-is-in-range")]
1110    fn test_authorized_on_simple_slice_with_context_variadic() {
1111        let call = json!({
1112            "principal": {
1113             "type": "User",
1114             "id": "alice"
1115            },
1116            "action": {
1117             "type": "Photo",
1118             "id": "view"
1119            },
1120            "resource": {
1121             "type": "Photo",
1122             "id": "door"
1123            },
1124            "context": {
1125             "is_authenticated": true,
1126             "source_ip": {
1127                "__extn" : { "fn" : "ip", "arg" : "222.222.222.222" }
1128             }
1129            },
1130            "policies": {
1131                "staticPolicies": "permit(principal == User::\"alice\", action, resource) when { context.is_authenticated && context.source_ip.isInRange(ip(\"192.167.0.1/24\"), ip(\"222.222.222.0/24\")) };"
1132            },
1133            "entities": []
1134        });
1135        assert_is_authorized_json(call);
1136    }
1137
1138    #[test]
1139    fn test_authorized_on_simple_slice_with_attrs_and_parents() {
1140        let call = json!({
1141            "principal": {
1142             "type": "User",
1143             "id": "alice"
1144            },
1145            "action": {
1146             "type": "Photo",
1147             "id": "view"
1148            },
1149            "resource": {
1150             "type": "Photo",
1151             "id": "door"
1152            },
1153            "context": {},
1154            "policies": {
1155                "staticPolicies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };"
1156            },
1157             "entities": [
1158              {
1159               "uid": {
1160                "__entity": {
1161                 "type": "User",
1162                 "id": "alice"
1163                }
1164               },
1165               "attrs": {},
1166               "parents": []
1167              },
1168              {
1169               "uid": {
1170                "__entity": {
1171                 "type": "Photo",
1172                 "id": "door"
1173                }
1174               },
1175               "attrs": {
1176                "owner": {
1177                 "__entity": {
1178                  "type": "User",
1179                  "id": "alice"
1180                 }
1181                }
1182               },
1183               "parents": [
1184                {
1185                 "__entity": {
1186                  "type": "Folder",
1187                  "id": "house"
1188                 }
1189                }
1190               ]
1191              },
1192              {
1193               "uid": {
1194                "__entity": {
1195                 "type": "Folder",
1196                 "id": "house"
1197                }
1198               },
1199               "attrs": {},
1200               "parents": []
1201              }
1202             ]
1203        });
1204        assert_is_authorized_json(call);
1205    }
1206
1207    #[test]
1208    fn test_authorized_on_multi_policy_slice() {
1209        let call = json!({
1210            "principal": {
1211             "type": "User",
1212             "id": "alice"
1213            },
1214            "action": {
1215             "type": "Photo",
1216             "id": "view"
1217            },
1218            "resource": {
1219             "type": "Photo",
1220             "id": "door"
1221            },
1222            "context": {},
1223            "policies": {
1224                "staticPolicies": {
1225                    "ID0": "permit(principal == User::\"jerry\", action, resource == Photo::\"doorx\");",
1226                    "ID1": "permit(principal == User::\"tom\", action, resource == Photo::\"doory\");",
1227                    "ID2": "permit(principal == User::\"alice\", action, resource == Photo::\"door\");"
1228                }
1229            },
1230            "entities": []
1231        });
1232        assert_is_authorized_json(call);
1233    }
1234
1235    #[test]
1236    fn test_authorized_on_multi_policy_slice_with_string_policies() {
1237        let call = json!({
1238            "principal": {
1239             "type": "User",
1240             "id": "alice"
1241            },
1242            "action": {
1243             "type": "Photo",
1244             "id": "view"
1245            },
1246            "resource": {
1247             "type": "Photo",
1248             "id": "door"
1249            },
1250            "context": {},
1251            "policies": {
1252                "staticPolicies": "permit(principal, action, resource in Folder::\"house\") when { resource.owner == principal };"
1253            },
1254             "entities": [
1255              {
1256               "uid": {
1257                "__entity": {
1258                 "type": "User",
1259                 "id": "alice"
1260                }
1261               },
1262               "attrs": {},
1263               "parents": []
1264              },
1265              {
1266               "uid": {
1267                "__entity": {
1268                 "type": "Photo",
1269                 "id": "door"
1270                }
1271               },
1272               "attrs": {
1273                "owner": {
1274                 "__entity": {
1275                  "type": "User",
1276                  "id": "alice"
1277                 }
1278                }
1279               },
1280               "parents": [
1281                {
1282                 "__entity": {
1283                  "type": "Folder",
1284                  "id": "house"
1285                 }
1286                }
1287               ]
1288              },
1289              {
1290               "uid": {
1291                "__entity": {
1292                 "type": "Folder",
1293                 "id": "house"
1294                }
1295               },
1296               "attrs": {},
1297               "parents": []
1298              }
1299             ]
1300        });
1301        assert_is_authorized_json(call);
1302    }
1303
1304    #[test]
1305    fn test_authorized_on_multi_policy_slice_denies_when_expected() {
1306        let call = json!({
1307            "principal": {
1308             "type": "User",
1309             "id": "alice"
1310            },
1311            "action": {
1312             "type": "Photo",
1313             "id": "view"
1314            },
1315            "resource": {
1316             "type": "Photo",
1317             "id": "door"
1318            },
1319            "context": {},
1320            "policies": {
1321                "staticPolicies": {
1322                    "ID0": "permit(principal, action, resource);",
1323                    "ID1": "forbid(principal == User::\"alice\", action, resource == Photo::\"door\");"
1324                }
1325            },
1326             "entities": []
1327        });
1328        assert_is_not_authorized_json(call);
1329    }
1330
1331    #[test]
1332    fn test_authorized_on_multi_policy_slice_with_string_policies_denies_when_expected() {
1333        let call = json!({
1334            "principal": {
1335             "type": "User",
1336             "id": "alice"
1337            },
1338            "action": {
1339             "type": "Photo",
1340             "id": "view"
1341            },
1342            "resource": {
1343             "type": "Photo",
1344             "id": "door"
1345            },
1346            "context": {},
1347            "policies": {
1348                "staticPolicies": "permit(principal, action, resource);\nforbid(principal == User::\"alice\", action, resource);"
1349            },
1350             "entities": []
1351        });
1352        assert_is_not_authorized_json(call);
1353    }
1354
1355    #[test]
1356    fn test_authorized_with_template_as_policy_should_fail() {
1357        let call = json!({
1358            "principal": {
1359             "type": "User",
1360             "id": "alice"
1361            },
1362            "action": {
1363             "type": "Photo",
1364             "id": "view"
1365            },
1366            "resource": {
1367             "type": "Photo",
1368             "id": "door"
1369            },
1370            "context": {},
1371            "policies": {
1372                "staticPolicies": "permit(principal == ?principal, action, resource);"
1373            },
1374            "entities": []
1375        });
1376        let errs = assert_is_authorized_json_is_failure(call);
1377        assert_exactly_one_error(&errs, "static policy set includes a template", None);
1378    }
1379
1380    #[test]
1381    fn test_authorized_with_template_should_fail() {
1382        let call = json!({
1383            "principal": {
1384             "type": "User",
1385             "id": "alice"
1386            },
1387            "action": {
1388             "type": "Photo",
1389             "id": "view"
1390            },
1391            "resource": {
1392             "type": "Photo",
1393             "id": "door"
1394            },
1395            "context": {},
1396            "policies": {
1397                "templates": {
1398                    "ID0": "permit(principal == ?principal, action, resource);"
1399                }
1400            },
1401            "entities": [],
1402        });
1403        assert_is_not_authorized_json(call);
1404    }
1405
1406    #[test]
1407    fn test_authorized_with_template_link() {
1408        let call = json!({
1409            "principal": {
1410             "type": "User",
1411             "id": "alice"
1412            },
1413            "action": {
1414             "type": "Photo",
1415             "id": "view"
1416            },
1417            "resource": {
1418             "type": "Photo",
1419             "id": "door"
1420            },
1421            "context": {},
1422            "policies": {
1423                "templates": {
1424                    "ID0": "permit(principal == ?principal, action, resource);"
1425                },
1426                "templateLinks": [
1427                    {
1428                        "templateId": "ID0",
1429                        "newId": "ID0_User_alice",
1430                        "values": {
1431                            "?principal": { "type": "User", "id": "alice" }
1432                        }
1433                    }
1434                ]
1435            },
1436            "entities": []
1437        });
1438        assert_is_authorized_json(call);
1439    }
1440
1441    #[test]
1442    fn test_authorized_fails_on_policy_collision_with_template() {
1443        let call = json!({
1444            "principal" : {
1445                "type" : "User",
1446                "id" : "alice"
1447            },
1448            "action" : {
1449                "type" : "Action",
1450                "id" : "view"
1451            },
1452            "resource" : {
1453                "type" : "Photo",
1454                "id" : "door"
1455            },
1456            "context" : {},
1457            "policies": {
1458                "staticPolicies": {
1459                    "ID0": "permit(principal, action, resource);"
1460                },
1461                "templates": {
1462                    "ID0": "permit(principal == ?principal, action, resource);"
1463                }
1464            },
1465            "entities" : []
1466        });
1467        let errs = assert_is_authorized_json_is_failure(call);
1468        assert_exactly_one_error(
1469            &errs,
1470            "failed to add template with id `ID0` to policy set: duplicate template or policy id `ID0`",
1471            None,
1472        );
1473    }
1474
1475    #[test]
1476    fn test_authorized_fails_on_duplicate_link_ids() {
1477        let call = json!({
1478            "principal" : {
1479                "type" : "User",
1480                "id" : "alice"
1481            },
1482            "action" : {
1483                "type" : "Action",
1484                "id" : "view"
1485            },
1486            "resource" : {
1487                "type" : "Photo",
1488                "id" : "door"
1489            },
1490            "context" : {},
1491            "policies" : {
1492                "templates": {
1493                    "ID0": "permit(principal == ?principal, action, resource);"
1494                },
1495                "templateLinks" : [
1496                    {
1497                        "templateId" : "ID0",
1498                        "newId" : "ID1",
1499                        "values" : { "?principal": { "type" : "User", "id" : "alice" } }
1500                    },
1501                    {
1502                        "templateId" : "ID0",
1503                        "newId" : "ID1",
1504                        "values" : { "?principal": { "type" : "User", "id" : "alice" } }
1505                    }
1506                ]
1507            },
1508            "entities" : [],
1509        });
1510        let errs = assert_is_authorized_json_is_failure(call);
1511        assert_exactly_one_error(
1512            &errs,
1513            "unable to link template: template-linked policy id `ID1` conflicts with an existing policy id",
1514            None,
1515        );
1516    }
1517
1518    #[test]
1519    fn test_authorized_fails_on_template_link_collision_with_template() {
1520        let call = json!({
1521            "principal" : {
1522                "type" : "User",
1523                "id" : "alice"
1524            },
1525            "action" : {
1526                "type" : "Action",
1527                "id" : "view"
1528            },
1529            "resource" : {
1530                "type" : "Photo",
1531                "id" : "door"
1532            },
1533            "context" : {},
1534            "policies" : {
1535                "templates": {
1536                    "ID0": "permit(principal == ?principal, action, resource);"
1537                },
1538                "templateLinks" : [
1539                    {
1540                        "templateId" : "ID0",
1541                        "newId" : "ID0",
1542                        "values" : { "?principal": { "type" : "User", "id" : "alice" } }
1543                    }
1544                ]
1545            },
1546            "entities" : []
1547
1548        });
1549        let errs = assert_is_authorized_json_is_failure(call);
1550        assert_exactly_one_error(
1551            &errs,
1552            "unable to link template: template-linked policy id `ID0` conflicts with an existing policy id",
1553            None,
1554        );
1555    }
1556
1557    #[test]
1558    fn test_authorized_fails_on_template_link_collision_with_policy() {
1559        let call = json!({
1560            "principal" : {
1561                "type" : "User",
1562                "id" : "alice"
1563            },
1564            "action" : {
1565                "type" : "Action",
1566                "id" : "view"
1567            },
1568            "resource" : {
1569                "type" : "Photo",
1570                "id" : "door"
1571            },
1572            "context" : {},
1573            "policies" : {
1574                "staticPolicies" : {
1575                    "ID1": "permit(principal, action, resource);"
1576                },
1577                "templates": {
1578                    "ID0": "permit(principal == ?principal, action, resource);"
1579                },
1580                "templateLinks" : [
1581                    {
1582                        "templateId" : "ID0",
1583                        "newId" : "ID1",
1584                        "values" : { "?principal": { "type" : "User", "id" : "alice" } }
1585                    }
1586                ]
1587            },
1588            "entities" : []
1589        });
1590        let errs = assert_is_authorized_json_is_failure(call);
1591        assert_exactly_one_error(
1592            &errs,
1593            "unable to link template: template-linked policy id `ID1` conflicts with an existing policy id",
1594            None,
1595        );
1596    }
1597
1598    #[test]
1599    fn test_authorized_fails_on_duplicate_policy_ids() {
1600        let call = r#"{
1601            "principal" : {
1602                "type" : "User",
1603                "id" : "alice"
1604            },
1605            "action" : {
1606                "type" : "Action",
1607                "id" : "view"
1608            },
1609            "resource" : {
1610                "type" : "Photo",
1611                "id" : "door"
1612            },
1613            "context" : {},
1614            "policies" : {
1615                "staticPolicies" : {
1616                    "ID0": "permit(principal, action, resource);",
1617                    "ID0": "permit(principal, action, resource);"
1618                }
1619            },
1620            "entities" : [],
1621        }"#;
1622        assert_is_authorized_json_str_is_failure(
1623            call,
1624            "expected a static policy set represented by a string, JSON array, or JSON object (with no duplicate keys) at line 20 column 13",
1625        );
1626    }
1627
1628    #[test]
1629    fn test_authorized_fails_on_duplicate_template_ids() {
1630        let call = r#"{
1631            "principal" : {
1632                "type" : "User",
1633                "id" : "alice"
1634            },
1635            "action" : {
1636                "type" : "Action",
1637                "id" : "view"
1638            },
1639            "resource" : {
1640                "type" : "Photo",
1641                "id" : "door"
1642            },
1643            "context" : {},
1644            "policies" : {
1645                "templates" : {
1646                    "ID0": "permit(principal == ?principal, action, resource);",
1647                    "ID0": "permit(principal == ?principal, action, resource);"
1648                }
1649            },
1650            "entities" : []
1651        }"#;
1652        assert_is_authorized_json_str_is_failure(
1653            call,
1654            "invalid entry: found duplicate key at line 19 column 17",
1655        );
1656    }
1657
1658    #[test]
1659    fn test_authorized_fails_on_duplicate_slot_link() {
1660        let call = r#"{
1661            "principal" : {
1662                "type" : "User",
1663                "id" : "alice"
1664            },
1665            "action" : {
1666                "type" : "Action",
1667                "id" : "view"
1668            },
1669            "resource" : {
1670                "type" : "Photo",
1671                "id" : "door"
1672            },
1673            "context" : {},
1674            "policies" : {
1675                "templates" : {
1676                    "ID0": "permit(principal == ?principal, action, resource);"
1677                },
1678                "templateLinks" : [{
1679                    "templateId" : "ID0",
1680                    "newId" : "ID1",
1681                    "values" : {
1682                        "?principal": { "type" : "User", "id" : "alice" },
1683                        "?principal": { "type" : "User", "id" : "alice" }
1684                    }
1685                }]
1686            },
1687            "entities" : [],
1688        }"#;
1689        assert_is_authorized_json_str_is_failure(
1690            call,
1691            "invalid entry: found duplicate key at line 25 column 21",
1692        );
1693    }
1694
1695    #[test]
1696    fn test_authorized_fails_inconsistent_duplicate_entity_uid() {
1697        let call = json!({
1698            "principal" : {
1699                "type" : "User",
1700                "id" : "alice"
1701            },
1702            "action" : {
1703                "type" : "Photo",
1704                "id" : "view"
1705            },
1706            "resource" : {
1707                "type" : "Photo",
1708                "id" : "door"
1709            },
1710            "context" : {},
1711            "policies" : {},
1712            "entities" : [
1713                {
1714                    "uid": {
1715                        "type" : "User",
1716                        "id" : "alice"
1717                    },
1718                    "attrs": {"location": "Greenland"},
1719                    "parents": []
1720                },
1721                {
1722                    "uid": {
1723                        "type" : "User",
1724                        "id" : "alice"
1725                    },
1726                    "attrs": {},
1727                    "parents": []
1728                }
1729            ]
1730        });
1731        let errs = assert_is_authorized_json_is_failure(call);
1732        assert_exactly_one_error(&errs, r#"duplicate entity entry `User::"alice"`"#, None);
1733    }
1734
1735    #[test]
1736    fn test_authorized_fails_duplicate_context_key() {
1737        let call = r#"{
1738            "principal" : {
1739                "type" : "User",
1740                "id" : "alice"
1741            },
1742            "action" : {
1743                "type" : "Photo",
1744                "id" : "view"
1745            },
1746            "resource" : {
1747                "type" : "Photo",
1748                "id" : "door"
1749            },
1750            "context" : {
1751                "is_authenticated": true,
1752                "is_authenticated": false
1753            },
1754            "policies" : {},
1755            "entities" : [],
1756        }"#;
1757        assert_is_authorized_json_str_is_failure(
1758            call,
1759            "the key `is_authenticated` occurs two or more times in the same JSON object at line 17 column 13",
1760        );
1761    }
1762
1763    #[test]
1764    fn test_request_validation() {
1765        let good_call = json!({
1766            "principal" : {
1767                "type": "User",
1768                "id": "alice",
1769            },
1770            "action": {
1771                "type": "Action",
1772                "id": "view",
1773            },
1774            "resource": {
1775                "type": "Photo",
1776                "id": "door",
1777            },
1778            "context": {},
1779            "policies": {
1780                "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);"
1781            },
1782            "entities": [],
1783            "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };"
1784        });
1785        let bad_call = json!({
1786            "principal" : {
1787                "type": "User",
1788                "id": "alice",
1789            },
1790            "action": {
1791                "type": "Action",
1792                "id": "view",
1793            },
1794            "resource": {
1795                "type": "User",
1796                "id": "bob",
1797            },
1798            "context": {},
1799            "policies": {
1800                "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);"
1801            },
1802            "entities": [],
1803            "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };"
1804        });
1805        let bad_call_req_validation_disabled = json!({
1806            "principal" : {
1807                "type": "User",
1808                "id": "alice",
1809            },
1810            "action": {
1811                "type": "Action",
1812                "id": "view",
1813            },
1814            "resource": {
1815                "type": "User",
1816                "id": "bob",
1817            },
1818            "context": {},
1819            "policies": {
1820                "staticPolicies": "permit(principal == User::\"alice\", action == Action::\"view\", resource);"
1821            },
1822            "entities": [],
1823            "schema": "entity User, Photo; action view appliesTo { principal: User, resource: Photo };",
1824            "validateRequest": false,
1825        });
1826
1827        assert_is_authorized_json(good_call);
1828        let errs = assert_is_authorized_json_is_failure(bad_call);
1829        assert_exactly_one_error(
1830            &errs,
1831            "resource type `User` is not valid for `Action::\"view\"`",
1832            Some("valid resource types for `Action::\"view\"`: `Photo`"),
1833        );
1834        assert_is_authorized_json(bad_call_req_validation_disabled);
1835    }
1836
1837    #[test]
1838    fn test_preparse_policy_set_success() {
1839        let policies = json!({
1840            "staticPolicies": "permit(principal == User::\"alice\", action, resource);"
1841        });
1842        let policy_set: PolicySet = serde_json::from_value(policies).unwrap();
1843
1844        let result = preparse_policy_set("test_policy_set".to_string(), policy_set);
1845        assert_matches!(result, CheckParseAnswer::Success);
1846    }
1847
1848    #[test]
1849    fn test_preparse_policy_set_failure() {
1850        let policies = json!({
1851            "staticPolicies": "invalid policy syntax"
1852        });
1853        let policy_set: PolicySet = serde_json::from_value(policies).unwrap();
1854
1855        let result = preparse_policy_set("test_policy_set".to_string(), policy_set);
1856        assert_matches!(result, CheckParseAnswer::Failure { .. });
1857    }
1858
1859    #[test]
1860    fn test_preparse_schema_success() {
1861        let schema =
1862            json!("entity User; action view appliesTo { principal: User, resource: User };");
1863        let schema_obj: Schema = serde_json::from_value(schema).unwrap();
1864
1865        let result = preparse_schema("test_schema".to_string(), schema_obj);
1866        assert_matches!(result, CheckParseAnswer::Success);
1867    }
1868
1869    #[test]
1870    fn test_preparse_schema_failure() {
1871        let schema = json!("invalid schema syntax");
1872        let schema_obj: Schema = serde_json::from_value(schema).unwrap();
1873
1874        let result = preparse_schema("test_schema".to_string(), schema_obj);
1875        assert_matches!(result, CheckParseAnswer::Failure { .. });
1876    }
1877
1878    #[test]
1879    fn test_stateful_is_authorized_success() {
1880        // First preparse policy set and schema
1881        let policies = json!({
1882            "staticPolicies": "permit(principal == User::\"alice\", action, resource);"
1883        });
1884        let policy_set: PolicySet = serde_json::from_value(policies).unwrap();
1885        preparse_policy_set("test_policies".to_string(), policy_set);
1886
1887        let schema =
1888            json!("entity User; action view appliesTo { principal: User, resource: User };");
1889        let schema_obj: Schema = serde_json::from_value(schema).unwrap();
1890        preparse_schema("test_schema".to_string(), schema_obj);
1891
1892        // Now test stateful authorization
1893        let call = json!({
1894            "principal": {
1895                "type": "User",
1896                "id": "alice"
1897            },
1898            "action": {
1899                "type": "Action",
1900                "id": "view"
1901            },
1902            "resource": {
1903                "type": "User",
1904                "id": "bob"
1905            },
1906            "context": {},
1907            "preparsedSchemaName": "test_schema",
1908            "preparsedPolicySetId": "test_policies",
1909            "entities": []
1910        });
1911
1912        let stateful_call: StatefulAuthorizationCall = serde_json::from_value(call).unwrap();
1913        let result = stateful_is_authorized(stateful_call);
1914
1915        assert_matches!(result, AuthorizationAnswer::Success { response, .. } => {
1916            assert_eq!(response.decision(), Decision::Allow);
1917        });
1918    }
1919
1920    #[test]
1921    fn test_stateful_is_authorized_missing_policy_set() {
1922        let call = json!({
1923            "principal": {
1924                "type": "User",
1925                "id": "alice"
1926            },
1927            "action": {
1928                "type": "Action",
1929                "id": "view"
1930            },
1931            "resource": {
1932                "type": "User",
1933                "id": "bob"
1934            },
1935            "context": {},
1936            "preparsedPolicySetId": "nonexistent_policies",
1937            "entities": []
1938        });
1939
1940        let stateful_call: StatefulAuthorizationCall = serde_json::from_value(call).unwrap();
1941        let result = stateful_is_authorized(stateful_call);
1942
1943        assert_matches!(result, AuthorizationAnswer::Failure { errors, .. } => {
1944            assert!(!errors.is_empty());
1945            assert!(errors[0].message.contains("preparsed policy set 'nonexistent_policies' not found"));
1946        });
1947    }
1948
1949    #[test]
1950    fn test_stateful_is_authorized_without_schema() {
1951        // Preparse policy set but don't specify schema
1952        let policies = json!({
1953            "staticPolicies": "permit(principal == User::\"alice\", action, resource);"
1954        });
1955        let policy_set: PolicySet = serde_json::from_value(policies).unwrap();
1956        preparse_policy_set("test_policies".to_string(), policy_set);
1957
1958        let call = json!({
1959            "principal": {
1960                "type": "User",
1961                "id": "alice"
1962            },
1963            "action": {
1964                "type": "Action",
1965                "id": "view"
1966            },
1967            "resource": {
1968                "type": "User",
1969                "id": "bob"
1970            },
1971            "context": {},
1972            "preparsedPolicySetId": "test_policies",
1973            "entities": []
1974        });
1975
1976        let stateful_call: StatefulAuthorizationCall = serde_json::from_value(call).unwrap();
1977        let result = stateful_is_authorized(stateful_call);
1978
1979        // Should succeed without schema
1980        assert_matches!(result, AuthorizationAnswer::Success { response, .. } => {
1981            assert_eq!(response.decision(), Decision::Allow);
1982        });
1983    }
1984}
1985
1986#[cfg(feature = "partial-eval")]
1987#[cfg(test)]
1988mod partial_test {
1989    use super::*;
1990    use cool_asserts::assert_matches;
1991    use serde_json::json;
1992
1993    #[track_caller]
1994    fn assert_is_authorized_json_partial(call: serde_json::Value) {
1995        let ans_val = is_authorized_partial_json(call).unwrap();
1996        let result: Result<PartialAuthorizationAnswer, _> = serde_json::from_value(ans_val);
1997        assert_matches!(result, Ok(PartialAuthorizationAnswer::Residuals { response, .. }) => {
1998            assert_eq!(response.decision(), Some(Decision::Allow));
1999            let errors: Vec<_> = response.errored().collect();
2000            assert_eq!(errors.len(), 0, "{errors:?}");
2001        });
2002    }
2003
2004    #[track_caller]
2005    fn assert_is_not_authorized_json_partial(call: serde_json::Value) {
2006        let ans_val = is_authorized_partial_json(call).unwrap();
2007        let result: Result<PartialAuthorizationAnswer, _> = serde_json::from_value(ans_val);
2008        assert_matches!(result, Ok(PartialAuthorizationAnswer::Residuals { response, .. }) => {
2009            assert_eq!(response.decision(), Some(Decision::Deny));
2010            let errors: Vec<_> = response.errored().collect();
2011            assert_eq!(errors.len(), 0, "{errors:?}");
2012        });
2013    }
2014
2015    #[track_caller]
2016    fn assert_is_residual(call: serde_json::Value, expected_residuals: &HashSet<&str>) {
2017        let ans_val = is_authorized_partial_json(call).unwrap();
2018        let result: Result<PartialAuthorizationAnswer, _> = serde_json::from_value(ans_val);
2019        assert_matches!(result, Ok(PartialAuthorizationAnswer::Residuals { response, .. }) => {
2020            assert_eq!(response.decision(), None);
2021            let errors: Vec<_> = response.errored().collect();
2022            assert_eq!(errors.len(), 0, "{errors:?}");
2023            let actual_residuals: HashSet<_> = response.nontrivial_residual_ids().collect();
2024            for id in expected_residuals {
2025                assert!(actual_residuals.contains(&PolicyId::new(id)), "expected nontrivial residual for {id}, but it's missing");
2026            }
2027            for id in &actual_residuals {
2028                assert!(expected_residuals.contains(id.to_string().as_str()),"found unexpected nontrivial residual for {id}");
2029            }
2030        });
2031    }
2032
2033    #[test]
2034    fn test_authorized_partial_no_resource() {
2035        let call = json!({
2036            "principal": {
2037                "type": "User",
2038                "id": "alice"
2039            },
2040            "action": {
2041                "type": "Photo",
2042                "id": "view"
2043            },
2044            "context": {},
2045            "policies": {
2046                "staticPolicies": {
2047                    "ID1": "permit(principal == User::\"alice\", action, resource);"
2048                }
2049            },
2050            "entities": []
2051        });
2052
2053        assert_is_authorized_json_partial(call);
2054    }
2055
2056    #[test]
2057    fn test_authorized_partial_not_authorized_no_resource() {
2058        let call = json!({
2059            "principal": {
2060                "type": "User",
2061                "id": "john"
2062            },
2063            "action": {
2064                "type": "Photo",
2065                "id": "view"
2066            },
2067            "context": {},
2068            "policies": {
2069                "staticPolicies": {
2070                    "ID1": "permit(principal == User::\"alice\", action, resource);"
2071                }
2072            },
2073            "entities": []
2074        });
2075
2076        assert_is_not_authorized_json_partial(call);
2077    }
2078
2079    #[test]
2080    fn test_authorized_partial_residual_no_principal_scope() {
2081        let call = json!({
2082            "action": {
2083                "type": "Photo",
2084                "id": "view"
2085            },
2086            "resource" : {
2087                "type" : "Photo",
2088                "id" : "door"
2089            },
2090            "context": {},
2091            "policies": {
2092                "staticPolicies": {
2093                    "ID1": "permit(principal == User::\"alice\", action, resource);"
2094                }
2095            },
2096            "entities": []
2097        });
2098
2099        assert_is_residual(call, &HashSet::from(["ID1"]));
2100    }
2101
2102    #[test]
2103    fn test_authorized_partial_residual_no_principal_when() {
2104        let call = json!({
2105            "action": {
2106                "type": "Photo",
2107                "id": "view"
2108            },
2109            "resource" : {
2110                "type" : "Photo",
2111                "id" : "door"
2112            },
2113            "context": {},
2114            "policies" : {
2115                "staticPolicies" : {
2116                    "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };"
2117                }
2118            },
2119            "entities": []
2120        });
2121
2122        assert_is_residual(call, &HashSet::from(["ID1"]));
2123    }
2124
2125    #[test]
2126    fn test_authorized_partial_residual_no_principal_ignored_forbid() {
2127        let call = json!({
2128            "action": {
2129                "type": "Photo",
2130                "id": "view"
2131            },
2132            "resource" : {
2133                "type" : "Photo",
2134                "id" : "door"
2135            },
2136            "context": {},
2137            "policies" : {
2138                "staticPolicies" : {
2139                    "ID1": "permit(principal, action, resource) when { principal == User::\"alice\" };",
2140                    "ID2": "forbid(principal, action, resource) unless { resource == Photo::\"door\" };"
2141                }
2142            },
2143            "entities": []
2144        });
2145
2146        assert_is_residual(call, &HashSet::from(["ID1"]));
2147    }
2148}