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 concept | acls-rs type | Bridge |
|---|---|---|
AuthorizationDetail | Resource | AuthzDetailsResource |
actions entries | AtomicPermission | required_permissions |
AuthzDetailsPolicy | policy dimension | PolicyDimension<[AuthorizationDetail]> |
| Request subject | Subject | AuthzDetailsRequest |
| Evaluation time | Timestamp | evaluate_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.
| Mode | Outcome |
|---|---|
And | Both dimensions must allow (strictest) |
Or | Either dimension may allow (most permissive) |
DetailsFirst | The rules decide when any rule applies (verdict not NoRule); otherwise RBAC decides |
RbacFirst | RBAC 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:
| Item | Purpose |
|---|---|
Verdict | Allow / Deny / NoRule |
DimensionDecision | One dimension’s allowed and applicable answers, plus warnings |
CombineMode / combine | And, Or, PreferFirst, PreferSecond over two decisions |
rbac_decision | The 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.