Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

acls-rs Bridge

Your service enforces two kinds of policy: field-level business rules on RFC 9396 authorization_details (“payments must not exceed 1000.00 EUR”) and classic role-based access control (“the payer role may initiate payments”). The composition module connects the two so they compose: it maps detail actions onto acls-rs permissions, models details as acls-rs resources, and combines an AuthzDetailsPolicy with an acls_rs::policy::RbacPolicy.

Concept mapping

authz-details conceptacls-rs typeBridge
AuthorizationDetailResourceAuthzDetailsResource
actions entriesAtomicPermissionrequired_permissions
AuthzDetailsPolicypolicy dimensionPolicyDimension<[AuthorizationDetail]>
Request subjectSubjectAuthzDetailsRequest
Evaluation timeTimestampevaluate_at / check_access_at

Mapping actions to permissions

required_permissions extracts the permissions a set of details requires. The mapping is namespace = the detail’s type, action = each string entry of the detail’s actions array:

#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;

let detail = AuthorizationDetail::new(
    "payment_initiation",
    serde_json::json!({
        "actions": ["initiate", "status"],
        "instructedAmount": {"currency": "EUR", "amount": "500.00"}
    }),
);

let perms = required_permissions(std::slice::from_ref(&detail));
assert!(perms.contains(&AtomicPermission::new("payment_initiation", "initiate")));
assert!(perms.contains(&AtomicPermission::new("payment_initiation", "status")));
}

Details without actions (or with non-string entries) contribute no permissions. RBAC has nothing to check for such a detail, so a composed policy never lets RBAC alone allow it: the RBAC dimension denies any request that is empty or contains a detail without actions.

Authorization details as resources

AuthzDetailsResource wraps an acls-rs Resource whose required permissions come from the detail, so can_access answers “does this subject hold the permissions the detail needs?”:

#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;

let detail = AuthorizationDetail::new(
    "invoice",
    serde_json::json!({"actions": ["send"], "number": 42}),
);
let resource = AuthzDetailsResource::new(&detail);

let mut alice = Subject::new("alice");
alice.grant(AtomicPermission::new("invoice", "send"));
assert!(resource.can_access(&alice));

// Eve holds no permissions at all
let eve = Subject::new("eve");
assert!(!resource.can_access(&eve));
assert_eq!(resource.missing_permissions(&eve).len(), 1);
}

Composing rules with RBAC

ComposedPolicy evaluates the rules and the RBAC dimension and combines them under a CompositionMode. The RBAC dimension checks the request’s required permissions against the subject’s effective permissions at the evaluation time, resolving role hierarchies through the RbacPolicy. If role resolution fails (a missing role, a cycle), access is denied fail-safe and the reason is recorded in the result’s warnings.

ModeOutcome
AndBoth dimensions must allow (strictest)
OrEither dimension may allow (most permissive)
DetailsFirstThe rules decide when any rule applies (verdict not NoRule); otherwise RBAC decides
RbacFirstRBAC decides when the subject holds direct permissions; otherwise the rules decide
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;

fn composed(mode: CompositionMode) -> ComposedPolicy {
    let mut rules = AuthzDetailsPolicy::new();
    rules
        .add_rule(
            AuthzDetailsRule::builder("payments-up-to-1000")
                .type_id("payment_initiation")
                .field("$.instructedAmount.amount").le(1000)
                .build()
                .unwrap(),
        )
        .unwrap();

    let mut rbac = RbacPolicy::new();
    rbac.add_role(Role::new(
        "payer",
        GrantDenialPair::new(
            PermissionSet::from([
                AtomicPermission::new("payment_initiation", "initiate"),
                AtomicPermission::new("invoice", "send"),
            ]),
            PermissionSet::new(),
        ),
    ));

    ComposedPolicy::new(rules, rbac, mode)
}

let payer = Subject::builder().id("alice").role("payer").build().unwrap();

let small = AuthorizationDetail::new(
    "payment_initiation",
    serde_json::json!({"actions": ["initiate"], "instructedAmount": {"amount": "500.00"}}),
);
let large = AuthorizationDetail::new(
    "payment_initiation",
    serde_json::json!({"actions": ["initiate"], "instructedAmount": {"amount": "1500.00"}}),
);
let invoice = AuthorizationDetail::new(
    "invoice",
    serde_json::json!({"actions": ["send"], "number": 42}),
);

let req_small = AuthzDetailsRequest::new(payer.clone(), vec![small]);
let req_large = AuthzDetailsRequest::new(payer.clone(), vec![large.clone()]);
let req_invoice = AuthzDetailsRequest::new(payer.clone(), vec![invoice]);

// AND: both dimensions must pass
assert!(composed(CompositionMode::And).check_access(&req_small));
assert!(!composed(CompositionMode::And).check_access(&req_large));

// OR: either dimension may pass; the payer role grants initiate
assert!(composed(CompositionMode::Or).check_access(&req_large));

// Details-first: no rule applies to the invoice, so RBAC decides
let result = composed(CompositionMode::DetailsFirst).evaluate(&req_invoice);
assert!(result.allowed && result.verdict == Verdict::NoRule);
// ...but a rule that applied and denied is final
let result = composed(CompositionMode::DetailsFirst).evaluate(&req_large);
assert!(!result.allowed && result.verdict == Verdict::Deny);

// Rbac-first: a subject carrying only roles has no direct permissions,
// so the rules decide
assert!(composed(CompositionMode::RbacFirst).check_access(&req_small));
assert!(!composed(CompositionMode::RbacFirst).check_access(&req_large));

// ...while a subject with direct permissions is decided by RBAC alone
let mut bob = Subject::new("bob");
bob.grant(AtomicPermission::new("payment_initiation", "initiate"));
let req_bob = AuthzDetailsRequest::new(bob, vec![large]);
assert!(composed(CompositionMode::RbacFirst).check_access(&req_bob));
}

check_access is the boolean shortcut; evaluate returns the full AuthzDetailsEvaluationResult with the rules’ verdict, matched rules and warnings. evaluate_at(&request, timestamp) evaluates at a fixed instant: the rules’ time conditions and the subject’s temporal permissions both use it, which makes decisions replayable in audits and tests.

Shared composition primitives

The four modes are not specific to authz-details. hbac-rs (HbacFirst), abac-rs (AbacFirst) and authz-details-rs (DetailsFirst) all map their modes onto acls_rs::policy::composition:

ItemPurpose
VerdictAllow / Deny / NoRule
DimensionDecisionOne dimension’s allowed and applicable answers, plus warnings
CombineMode / combineAnd, Or, PreferFirst, PreferSecond over two decisions
rbac_decisionThe fail-safe RBAC dimension shared by all composed policies
PolicyDimension<R>A policy that decides requests of type R; implemented by AuthzDetailsPolicy and HbacPolicy

Use them to compose policies from different crates yourself; the HBAC Integration page shows authz-details rules combined with HBAC host access this way.

Python

The Python bindings expose the same surface. Subjects may be acls_rs.Subject or hbac_rs.Subject objects:

from acls_rs import AtomicPermission, RbacPolicy, Role, Subject
from authz_details import (
    AuthorizationDetail,
    AuthzDetailsPolicy,
    AuthzDetailsRule,
    ComposedPolicy,
    CompositionMode,
)

rules = AuthzDetailsPolicy([
    AuthzDetailsRule.builder("payments-up-to-1000")
    .type_id("payment_initiation")
    .field("$.instructedAmount.amount").le(1000)
    .build()
])

rbac = RbacPolicy()
rbac.add_role(Role("payer", [AtomicPermission("payment_initiation", "initiate")], []))

policy = ComposedPolicy(rules, rbac, CompositionMode.AND)

alice = Subject("alice")
alice.add_role("payer")

detail = AuthorizationDetail(
    "payment_initiation",
    actions=["initiate"],
    instructedAmount={"amount": "500.00", "currency": "EUR"},
)

assert policy.check_access(alice, [detail])

evaluate_at / check_access_at take a timestamp (epoch milliseconds or a timezone-aware datetime).

Next steps

Pin rules to validity windows and business hours with Temporal Rules, add host access with HBAC Integration, and see the Python bindings for the full Python surface.