From 22bbbcc481e653d542524cfc945e76738ca9d29f Mon Sep 17 00:00:00 2001 From: Clement Delafargue Date: Wed, 23 Apr 2025 16:00:19 +0200 Subject: [PATCH 1/2] wip: demo structured data in exceptions --- biscuit_test.py | 12 ++++++++++- src/lib.rs | 56 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/biscuit_test.py b/biscuit_test.py index 8d49186..2d8a0f9 100644 --- a/biscuit_test.py +++ b/biscuit_test.py @@ -4,7 +4,7 @@ import pytest -from biscuit_auth import Algorithm, KeyPair, Authorizer, AuthorizerBuilder, Biscuit, BiscuitBuilder, BlockBuilder, Check, Fact, KeyPair, Policy, PrivateKey, PublicKey, Rule, UnverifiedBiscuit +from biscuit_auth import Algorithm, KeyPair, Authorizer, AuthorizerBuilder, Biscuit, BiscuitBuilder, BlockBuilder, Check, Fact, KeyPair, Policy, PrivateKey, PublicKey, Rule, UnverifiedBiscuit, AuthorizationError def test_fact(): fact = Fact('fact(1, true, "", "Test", hex:aabbcc, 2023-04-29T01:00:00Z)') @@ -237,6 +237,16 @@ def choose_key(kid): except: pass +def test_authorizer_exception(): + authorizer = AuthorizerBuilder("check if true; reject if true; allow if false; deny if true;").build_unauthenticated() + try: + authorizer.authorize() + assert False + except AuthorizationError as e: + (args,) = e.args + assert args['matched_policy'] == {'code': 'deny if true', 'policy_id': 1} + assert args['checks'] == [{'authorizer_check': True, 'block_id': None, 'check_id': 1, 'code': 'reject if true'}] + def test_complete_lifecycle(): private_key = PrivateKey("ed25519-private/473b5189232f3f597b5c2f3f9b0d5e28b1ee4e7cce67ec6b7fbf5984157a6b97") root = KeyPair.from_private_key(private_key) diff --git a/src/lib.rs b/src/lib.rs index cf049dd..d115076 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ #![allow(clippy::useless_conversion)] use ::biscuit_auth::builder::MapKey; use ::biscuit_auth::datalog::ExternFunc; +use ::biscuit_auth::error::MatchedPolicy; use ::biscuit_auth::AuthorizerBuilder; use ::biscuit_auth::RootKeyProvider; use ::biscuit_auth::UnverifiedBiscuit; @@ -38,6 +39,27 @@ create_exception!( AuthorizationError, pyo3::exceptions::PyException ); + +#[derive(IntoPyObject)] +struct AuthorizationErrorData { + matched_policy: Option, + checks: Vec, +} + +#[derive(IntoPyObject)] +struct MatchedPolicyData { + policy_id: usize, + code: String, +} + +#[derive(IntoPyObject)] +struct FailedCheckData { + authorizer_check: bool, + block_id: Option, + check_id: u32, + code: String, +} + create_exception!( biscuit_auth, BiscuitBuildError, @@ -786,9 +808,37 @@ impl PyAuthorizer { /// :return: the index of the matched allow rule /// :rtype: int pub fn authorize(&mut self) -> PyResult { - self.0 - .authorize() - .map_err(|error| AuthorizationError::new_err(error.to_string())) + self.0.authorize().map_err(|error| match error { + error::Token::FailedLogic(error::Logic::Unauthorized { + policy: MatchedPolicy::Deny(pid), + checks, + }) => AuthorizationError::new_err(AuthorizationErrorData { + matched_policy: Some(MatchedPolicyData { + policy_id: pid, + code: self.0.dump().3.get(pid).unwrap().to_string(), + }), + checks: checks + .into_iter() + .map(|c| match c { + error::FailedCheck::Block(failed_block_check) => FailedCheckData { + authorizer_check: false, + block_id: Some(failed_block_check.block_id), + check_id: failed_block_check.check_id, + code: failed_block_check.rule, + }, + error::FailedCheck::Authorizer(failed_authorizer_check) => { + FailedCheckData { + authorizer_check: true, + block_id: None, + check_id: failed_authorizer_check.check_id, + code: failed_authorizer_check.rule, + } + } + }) + .collect(), + }), + _ => AuthorizationError::new_err(error.to_string()), + }) } /// Query the authorizer by returning all the `Fact`s generated by the provided `Rule`. The generated facts won't be From 49cfbfd6627f85127b48ee8d47c09f4638ee1c34 Mon Sep 17 00:00:00 2001 From: Clement Delafargue Date: Thu, 22 May 2025 16:06:52 +0200 Subject: [PATCH 2/2] feat: return the matched allow policy in authorize() not just the policy id. this makes the API easier to understand (in many cases, the first policy is matched, so the return value is `0`, which is falsy) --- biscuit_test.py | 9 ++++----- docs/basic-use.rst | 4 ++-- src/lib.rs | 14 ++++++++++---- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/biscuit_test.py b/biscuit_test.py index 2d8a0f9..e3f6a09 100644 --- a/biscuit_test.py +++ b/biscuit_test.py @@ -267,7 +267,7 @@ def test_complete_lifecycle(): policy = authorizer.authorize() - assert policy == 0 + assert policy == {'code': 'allow if user("1234")', 'policy_id': 0} rule = Rule("u($id) <- user($id), $id == {id}", { 'id': "1234"}) facts = authorizer.query(rule) @@ -300,7 +300,7 @@ def test_snapshot(): policy = parsed.authorize() - assert policy == 0 + assert policy == {'code': 'allow if user("1234")', 'policy_id': 0} rule = Rule("u($id) <- user($id), $id == {id}", { 'id': "1234"}) facts = parsed.query(rule) @@ -315,7 +315,7 @@ def test_snapshot(): raw_policy = parsed_from_raw.authorize() - assert raw_policy == 0 + assert raw_policy == {'code': 'allow if user("1234")', 'policy_id': 0} rule = Rule("u($id) <- user($id), $id == {id}", { 'id': "1234"}) raw_facts = parsed_from_raw.query(rule) @@ -471,5 +471,4 @@ def test(left, right): 'other': lambda x : x == 2, }) policy = authorizer.build_unauthenticated().authorize() - assert policy == 0 - + assert policy == {'code': 'allow if 1.extern::test(1)', 'policy_id': 0} diff --git a/docs/basic-use.rst b/docs/basic-use.rst index abeda7f..130a301 100644 --- a/docs/basic-use.rst +++ b/docs/basic-use.rst @@ -74,7 +74,7 @@ Parse and authorize a biscuit token >>> token = Biscuit.from_base64("En0KEwoEMTIzNBgDIgkKBwgKEgMYgAgSJAgAEiCp8D9laR_CXmFmiUlo6zi8L63iapXDxX1evELp4HVaBRpAx3Mkwu2f2AcNq48IZwu-pxACq1stL76DSMGEugmiduuTVwMqLmgKZ4VFgzeydCrYY_Id3MkxgTgjXzEHUH4DDSIiCiB55I7ykL9wQXHRDqUnSgZwCdYNdO7c8LZEj0VH5sy3-Q==", public_key) >>> authorizer = AuthorizerBuilder( """ time({now}); allow if user($u); """, { 'now': datetime.now(tz = timezone.utc)} ).build(token) >>> authorizer.authorize() -0 +{'policy_id': 0, 'code': 'allow if user($u)'} In order to help with key rotation, biscuit tokens can optionally carry a root key identifier, helping the verifying party choose between several valid public keys. @@ -88,7 +88,7 @@ In order to help with key rotation, biscuit tokens can optionally carry a root k >>> token = Biscuit.from_base64("CAESfQoTCgQxMjM0GAMiCQoHCAoSAxiACBIkCAASII5WVsvM52T91C12wnzButmyzmtGSX_rbM6hCSIJihX2GkDwAcVxTnY8aeMLm-i2R_VzTfIMQZya49ogXO2h2Fg2TJsDcG3udIki9il5PA05lKUwrfPNroS7Qg5e04AyLLcHIiIKII5rh75jrCrgE6Rzw6GVYczMn1IOo287uO4Ef5wp7obY", public_key_fn) >>> authorizer = AuthorizerBuilder( """ time({now}); allow if user($u); """, { 'now': datetime.now(tz = timezone.utc)} ).build(token) >>> authorizer.authorize() -0 +{'policy_id': 0, 'code': 'allow if user($u)'} It is possible to parse a biscuit token without verifying its signatures,for instance to inspect its contents, extract revocation ids or append a block. diff --git a/src/lib.rs b/src/lib.rs index d115076..cb33d3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,7 +47,7 @@ struct AuthorizationErrorData { } #[derive(IntoPyObject)] -struct MatchedPolicyData { +pub struct MatchedPolicyData { policy_id: usize, code: String, } @@ -807,15 +807,16 @@ impl PyAuthorizer { /// /// :return: the index of the matched allow rule /// :rtype: int - pub fn authorize(&mut self) -> PyResult { - self.0.authorize().map_err(|error| match error { + pub fn authorize(&mut self) -> PyResult { + let all_policies = self.0.dump().3; + let policy_id = self.0.authorize().map_err(|error| match error { error::Token::FailedLogic(error::Logic::Unauthorized { policy: MatchedPolicy::Deny(pid), checks, }) => AuthorizationError::new_err(AuthorizationErrorData { matched_policy: Some(MatchedPolicyData { policy_id: pid, - code: self.0.dump().3.get(pid).unwrap().to_string(), + code: all_policies.get(pid).unwrap().to_string(), }), checks: checks .into_iter() @@ -838,6 +839,11 @@ impl PyAuthorizer { .collect(), }), _ => AuthorizationError::new_err(error.to_string()), + })?; + + Ok(MatchedPolicyData { + policy_id, + code: all_policies.get(policy_id).unwrap().to_string(), }) }