axess-core 0.8.1

Core implementation for the axess library. Session state machine, multi-factor authentication engine, Cedar Policy evaluation, and pluggable storage backends. Use the `axess` facade crate unless you need direct access to internals.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! Cedar policy store and the `PolicyEvaluator` trait.
//!
//! [`PolicyStore`] compiles and holds a Cedar policy set + schema at startup.
//! It implements [`PolicyEvaluator`], which is the injectable trait used by
//! [`AuthzSession`][super::session::AuthzSession]: swap it for
//! [`MockPolicyEvaluator`][crate::testing::mock_policy::MockPolicyEvaluator]
//! in tests to evaluate authz flows without any Cedar policy files.

use cedar_policy::{
    Authorizer, Context, Decision, Entities, EntityUid, PolicySet, Request, Schema, ValidationMode,
    Validator,
};
use std::str::FromStr;

use super::error::AuthzError;

// ── validate_policies ────────────────────────────────────────────────────────

/// Cross-check a Cedar policy set against a schema using Cedar's strict
/// validator.
///
/// Runs [`cedar_policy::Validator`] in [`ValidationMode::Strict`]: rejects
/// policies that reference undeclared entity types, undeclared actions,
/// wrong attribute types, or scope-variable misuse. Separate from
/// parse-time syntax checking ([`AuthzError::PolicyParse`],
/// [`AuthzError::SchemaParse`]); both are required.
///
/// Extracted as a free function so it is callable both at startup (from
/// [`PolicyStore::from_text`]) **and** at future policy hot-swap time,
/// where the invariant is **validate-before-swap**: never leave the
/// authorizer holding a policy set that fails validation.
///
/// All validation errors are collected and joined into a single
/// [`AuthzError::PolicyValidation`] payload so operators see every
/// mismatch, not just the first.
pub fn validate_policies(policies: &PolicySet, schema: &Schema) -> Result<(), AuthzError> {
    let validator = Validator::new(schema.clone());
    let result = validator.validate(policies, ValidationMode::Strict);
    if result.validation_passed() {
        return Ok(());
    }
    let errors: Vec<String> = result.validation_errors().map(|e| e.to_string()).collect();
    Err(AuthzError::PolicyValidation(errors.join("; ")))
}

// ── AuthzDecision ─────────────────────────────────────────────────────────────

/// The outcome of a Cedar policy evaluation.
///
/// Cedar uses three-valued logic internally: `Allow`, `Deny`, and no applicable
/// policy. When no policy applies, Axess maps the result to `Deny` (deny by
/// default). This enum exposes only the two externally meaningful outcomes.
///
/// If Cedar adds richer decision metadata in the future, extend this enum
/// with new variants and update consumers; exhaustive matches will catch
/// the missing arms at compile time. (Project policy bars the
/// future-proofing attribute that erases exhaustiveness.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthzDecision {
    /// At least one Cedar `permit` policy matched and no `forbid` policy applied.
    Allow,
    /// No `permit` policy matched, a `forbid` policy applied, or the request
    /// could not be evaluated: it failed schema validation, or any policy
    /// errored. Fail-closed default.
    Deny,
}

// ── PolicyEvaluator ───────────────────────────────────────────────────────────

/// Abstraction over Cedar policy evaluation.
///
/// The production implementation is [`PolicyStore`].
/// Tests use [`MockPolicyEvaluator`][crate::testing::mock_policy::MockPolicyEvaluator]
/// to control decisions without loading policy files.
pub trait PolicyEvaluator: Send + Sync {
    /// Evaluate whether `principal` may perform `action` on `resource`.
    ///
    /// `entities` must contain all entities referenced by the policy (principal,
    /// resource, roles, tenant, etc.). Always returns [`AuthzDecision::Deny`] on
    /// any evaluation error: fail-closed is the only correct default.
    fn is_authorized(
        &self,
        entities: &Entities,
        principal: EntityUid,
        action: EntityUid,
        resource: EntityUid,
        context: Context,
    ) -> AuthzDecision;

    /// Return the Cedar schema if available. Used to validate entity providers
    /// at startup via [`AuthzEntityProvider::validate_schema`][super::provider::AuthzEntityProvider::validate_schema].
    fn schema(&self) -> Option<&Schema> {
        None
    }
}

// ── PolicyStore ───────────────────────────────────────────────────────────────

/// Compiled, immutable Cedar policy set + schema + authorizer.
///
/// Construct once at startup and wrap in `Arc` for sharing across requests.
/// The namespace is intentionally NOT stored here; it lives on
/// [`AuthzStore`][super::session::AuthzStore] so that entity UID construction
/// and policy evaluation use the same configured namespace.
pub struct PolicyStore {
    policy_set: PolicySet,
    schema: Schema,
    authorizer: Authorizer,
}

impl PolicyStore {
    /// Compile a policy store from Cedar policy text and a JSON schema string.
    ///
    /// Both strings are validated at construction time: any parse or schema
    /// error is returned immediately so misconfiguration fails at startup.
    pub fn from_text(policy_text: &str, schema_json: &str) -> Result<Self, AuthzError> {
        let policy_set = PolicySet::from_str(policy_text)
            .map_err(|e| AuthzError::PolicyParse(format!("{e:?}")))?;

        let schema = Schema::from_json_str(schema_json)
            .map_err(|e| AuthzError::SchemaParse(format!("{e:?}")))?;

        validate_policies(&policy_set, &schema)?;

        Ok(Self {
            policy_set,
            schema,
            authorizer: Authorizer::new(),
        })
    }

    /// Access the Cedar schema (e.g. for entity provider validation).
    pub fn schema(&self) -> &Schema {
        &self.schema
    }
}

impl PolicyEvaluator for PolicyStore {
    fn is_authorized(
        &self,
        entities: &Entities,
        principal: EntityUid,
        action: EntityUid,
        resource: EntityUid,
        context: Context,
    ) -> AuthzDecision {
        // Captured before move into Request for the audit event.
        let principal_str = principal.to_string();
        let action_str = action.to_string();
        let resource_str = resource.to_string();
        let started = std::time::Instant::now();

        let cedar_req = match Request::new(principal, action, resource, context, Some(&self.schema))
        {
            Ok(r) => r,
            Err(e) => {
                tracing::error!("Cedar request validation failed: {e:?}");
                tracing::info!(
                    target: "axess::authz::decision",
                    principal = %principal_str,
                    action = %action_str,
                    resource = %resource_str,
                    decision = "deny",
                    reason = "request_validation_failed",
                    latency_us = started.elapsed().as_micros() as u64,
                );
                return AuthzDecision::Deny;
            }
        };

        let response = self
            .authorizer
            .is_authorized(&cedar_req, &self.policy_set, entities);

        // Log diagnostics server-side for debugging (never in API response)
        let diagnostics = response.diagnostics();
        let reasons: Vec<String> = diagnostics.reason().map(|p| p.to_string()).collect();
        let errors: Vec<String> = diagnostics.errors().map(|e| e.to_string()).collect();

        let latency_us = started.elapsed().as_micros() as u64;

        // Fail closed on any evaluation error. Cedar skips a policy whose
        // evaluation errors, so a `forbid` that fails (an attribute of the
        // wrong type, an overflow) stops forbidding and the `permit` beside it
        // allows. Whatever the decision, a request any policy could not
        // evaluate is denied, and said so at error level.
        if !errors.is_empty() {
            tracing::error!(
                errors = ?errors,
                "Cedar policy evaluation errors: denied"
            );
            tracing::info!(
                target: "axess::authz::decision",
                principal = %principal_str,
                action = %action_str,
                resource = %resource_str,
                decision = "deny",
                reason = "policy_evaluation_error",
                latency_us = latency_us,
            );
            return AuthzDecision::Deny;
        }

        match response.decision() {
            Decision::Allow => {
                tracing::trace!(reasons = ?reasons, "Cedar: allowed");
                // Structured audit event; see `axess::authz::audit` module
                // docs for the documented schema and target.
                tracing::info!(
                    target: "axess::authz::decision",
                    principal = %principal_str,
                    action = %action_str,
                    resource = %resource_str,
                    decision = "allow",
                    reasons = ?reasons,
                    latency_us = latency_us,
                );
                AuthzDecision::Allow
            }
            Decision::Deny => {
                tracing::debug!(reasons = ?reasons, "Cedar: denied");
                tracing::info!(
                    target: "axess::authz::decision",
                    principal = %principal_str,
                    action = %action_str,
                    resource = %resource_str,
                    decision = "deny",
                    reasons = ?reasons,
                    latency_us = latency_us,
                );
                AuthzDecision::Deny
            }
        }
    }

    fn schema(&self) -> Option<&Schema> {
        Some(&self.schema)
    }
}

// ── UID builder helpers ───────────────────────────────────────────────────────

/// Build a Cedar `EntityUid` from a type name, id, and namespace.
///
/// Produces `{namespace}::{type_name}::"id"`.
pub(super) fn make_uid(
    namespace: &str,
    type_name: &str,
    id: &str,
) -> Result<EntityUid, AuthzError> {
    EntityUid::from_str(&format!(r#"{namespace}::{type_name}::"{id}""#))
        .map_err(|e| AuthzError::InvalidEntityUid(format!("{type_name}/{id}: {e:?}")))
}

#[cfg(test)]
mod authz_store_tests {
    use super::*;

    const SCHEMA_JSON: &str = r#"{
        "TestApp": {
            "entityTypes": {
                "User": { "memberOfTypes": [] },
                "Resource": { "memberOfTypes": [] }
            },
            "actions": {
                "View": {
                    "appliesTo": {
                        "principalTypes": ["User"],
                        "resourceTypes": ["Resource"]
                    }
                }
            }
        }
    }"#;

    const POLICY_TEXT: &str = r#"permit(
        principal == TestApp::User::"alice",
        action == TestApp::Action::"View",
        resource == TestApp::Resource::"doc1"
    );"#;

    fn build_store() -> PolicyStore {
        PolicyStore::from_text(POLICY_TEXT, SCHEMA_JSON).unwrap()
    }

    /// A schema whose `User` carries a number, so a policy can do arithmetic
    /// on it that passes strict validation and fails at evaluation.
    const COUNTING_SCHEMA_JSON: &str = r#"{
        "TestApp": {
            "entityTypes": {
                "User": {
                    "memberOfTypes": [],
                    "shape": {
                        "type": "Record",
                        "attributes": { "n": { "type": "Long" } }
                    }
                },
                "Resource": { "memberOfTypes": [] }
            },
            "actions": {
                "View": {
                    "appliesTo": {
                        "principalTypes": ["User"],
                        "resourceTypes": ["Resource"]
                    }
                }
            }
        }
    }"#;

    fn alice_with(n: i64, store: &PolicyStore) -> Entities {
        let json = format!(
            r#"[{{"uid": {{"type": "TestApp::User", "id": "alice"}}, "attrs": {{"n": {n}}}, "parents": []}}]"#
        );
        Entities::from_json_str(&json, Some(store.schema())).unwrap()
    }

    fn view_doc1(store: &PolicyStore, entities: &Entities) -> AuthzDecision {
        store.is_authorized(
            entities,
            r#"TestApp::User::"alice""#.parse().unwrap(),
            r#"TestApp::Action::"View""#.parse().unwrap(),
            r#"TestApp::Resource::"doc1""#.parse().unwrap(),
            Context::empty(),
        )
    }

    /// A `forbid` that errors does not stop forbidding.
    ///
    /// Cedar skips a policy whose evaluation errors, so a `forbid` that fails
    /// (here on an overflow; in practice on an attribute an entity lacks)
    /// would stop applying and let the `permit` beside it allow. The decision
    /// must fail closed: any evaluation error is a deny.
    #[test]
    fn an_erroring_forbid_fails_closed() {
        let policies = r#"
            permit(principal, action == TestApp::Action::"View", resource);
            forbid(principal, action, resource)
                when { principal.n + 9223372036854775807 > 0 };
        "#;
        let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
        // The forbid evaluates without error and applies: denied.
        assert_eq!(
            view_doc1(&store, &alice_with(0, &store)),
            AuthzDecision::Deny
        );
        // The forbid overflows: Cedar would skip it and allow.
        assert_eq!(
            view_doc1(&store, &alice_with(1, &store)),
            AuthzDecision::Deny
        );
    }

    /// An erroring `permit` was already a deny; it stays one.
    #[test]
    fn an_erroring_permit_denies() {
        let policies = r#"
            permit(principal, action == TestApp::Action::"View", resource)
                when { principal.n + 9223372036854775807 > 0 };
        "#;
        let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
        assert_eq!(
            view_doc1(&store, &alice_with(1, &store)),
            AuthzDecision::Deny
        );
        assert_eq!(
            view_doc1(&store, &alice_with(-1, &store)),
            AuthzDecision::Allow
        );
    }

    /// Cedar would let the working `permit` allow; any evaluation error denies.
    #[test]
    fn a_working_permit_beside_an_erroring_one_denies() {
        let policies = r#"
            permit(principal, action == TestApp::Action::"View", resource);
            permit(principal, action == TestApp::Action::"View", resource)
                when { principal.n + 9223372036854775807 > 0 };
        "#;
        let store = PolicyStore::from_text(policies, COUNTING_SCHEMA_JSON).unwrap();
        assert_eq!(
            view_doc1(&store, &alice_with(1, &store)),
            AuthzDecision::Deny
        );
        assert_eq!(
            view_doc1(&store, &alice_with(-1, &store)),
            AuthzDecision::Allow
        );
    }

    #[test]
    fn policy_evaluator_schema_returns_some_when_loaded() {
        // pins `<impl PolicyEvaluator for PolicyStore>::schema
        // -> None` mutation. The trait default body returns `None`,
        // but `PolicyStore` overrides with `Some(&self.schema)`. The
        // mutation collapses the override back to the default; this
        // would silently disable schema-driven `validate_schema`
        // checks at startup.
        let store = build_store();
        assert!(
            <PolicyStore as PolicyEvaluator>::schema(&store).is_some(),
            "PolicyStore::schema must return Some, not None"
        );
    }

    #[test]
    fn policy_store_schema_accessor_is_borrow_of_inner() {
        // defends the inherent `schema()` accessor against
        // body replacement. Two consecutive calls must return refs
        // to the same Schema instance (verified via pointer equality).
        let store = build_store();
        let s1 = store.schema() as *const _;
        let s2 = store.schema() as *const _;
        assert_eq!(
            s1, s2,
            "schema() must borrow the stored Schema, not produce a new one"
        );
    }
}