Rules and Policies
Validation only checks the shape of a request. Your bank’s policy is about
its contents: payment_initiation details must use EUR, must never exceed
10,000.00 in a single instructed amount, and must request the initiate
action. The authorization_details object is the attribute set such a
policy speaks about.
authz-details-rs packages such policies as named AuthzDetailsRules held in
an AuthzDetailsPolicy, the same way hbac-rs packages HbacRules in an
HbacPolicy.
Building a rule
A rule has a unique name, the type it applies to, and conditions combined
with a MatchMode. The builder validates the rule (type present, JSONPaths
well-formed, times parseable) when you call build():
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let rule = AuthzDetailsRule::builder("eur-payments-up-to-10000")
.type_id("payment_initiation")
.field("$.instructedAmount.currency").eq("EUR")
.field("$.instructedAmount.amount").le(10_000)
.field("$.actions").contains("initiate")
.build()
.unwrap();
assert_eq!(rule.conditions.len(), 3);
}
field(path) starts a condition and an operator method finishes it: eq,
ne, lt, le, gt, ge, is_in, not_in, contains, op (any
ComparisonOp), and matches with the regex-matchers feature. Time
conditions are added with during, during_on, on_days, within and
outside (see Temporal Rules).
MatchMode::All (the default) requires every condition to hold;
.any() switches to MatchMode::Any, the right mode for “the request may
satisfy any one of these”:
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let small_or_usd = AuthzDetailsRule::builder("small-or-usd")
.type_id("payment_initiation")
.any()
.field("$.instructedAmount.amount").le(100)
.field("$.instructedAmount.currency").eq("USD")
.build()
.unwrap();
}
Rules are plain data: they serialize with serde, so a policy can be loaded from configuration and shared between the authorization server and the resource servers.
Evaluating details with a policy
Every requested detail must pass at least one enabled rule of its type. The result says which rules matched, so decisions can be audited:
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
use serde_json::json;
let mut policy = AuthzDetailsPolicy::new();
for (name, currency) in [("eur-only", "EUR"), ("usd-only", "USD")] {
policy
.add_rule(
AuthzDetailsRule::builder(name)
.type_id("payment_initiation")
.field("$.instructedAmount.currency").eq(currency)
.build()
.unwrap(),
)
.unwrap();
}
let eur = [AuthorizationDetail::new(
"payment_initiation",
json!({"actions": ["initiate"], "instructedAmount": {"currency": "EUR", "amount": "250.00"}}),
)];
let result = policy.evaluate(&eur);
assert!(result.is_allowed());
assert_eq!(result.verdict, Verdict::Allow);
assert_eq!(result.matched_rules, ["eur-only"]);
assert_eq!(result.not_matched_rules, ["usd-only"]);
// A GBP detail passes neither rule
let gbp = [AuthorizationDetail::new(
"payment_initiation",
json!({"actions": ["initiate"], "instructedAmount": {"currency": "GBP", "amount": "250.00"}}),
)];
let result = policy.evaluate(&gbp);
assert!(result.is_denied());
assert_eq!(result.verdict, Verdict::Deny);
assert!(result.has_warnings());
}
| Verdict | When |
|---|---|
Verdict::Allow | Every detail passed at least one rule of its type |
Verdict::Deny | Some detail passed no rule – including a detail whose type has no rule, once any rule applies to the request |
Verdict::NoRule | No rule applies to any requested type (or the request is empty) |
NoRule is what lets composed policies fall back to
another dimension; on its own it is not an allow.
evaluate uses the current time; evaluate_at(details, timestamp) pins it
(an acls-rs Timestamp, milliseconds since the Unix epoch), and
check_access / check_access_at return just the boolean. The policy
accepts anything that is AsRef<[AuthorizationDetail]>: a slice, a Vec,
AuthorizationDetails, or an AuthzDetailsRequest.
Managing rules
Policies mirror HbacPolicy: rules are addressed by name.
#![allow(unused)]
fn main() {
use authz_details_rs::prelude::*;
let rule = |name: &str, cap: u32| {
AuthzDetailsRule::builder(name)
.type_id("payment_initiation")
.field("$.instructedAmount.amount").le(cap)
.build()
.unwrap()
};
let mut policy = AuthzDetailsPolicy::with_max_rules(2);
policy.add_rule(rule("small", 100)).unwrap();
assert!(matches!(
policy.add_rule(rule("small", 200)),
Err(PolicyError::DuplicateRule { .. })
));
policy.add_rule(rule("large", 10_000)).unwrap();
assert!(matches!(
policy.add_rule(rule("huge", 1_000_000)),
Err(PolicyError::TooManyRules { .. })
));
assert!(policy.disable_rule("large"));
assert!(policy.enable_rule("large"));
assert!(policy.remove_rule("large"));
assert_eq!(policy.len(), 1);
}
add_rule rejects malformed rules (PolicyError::InvalidRule), duplicate
names, and additions beyond with_max_rules (a DoS guard shared with the
other BAC policy cores). load_rules replaces the permanent rules
atomically.
Error handling
Policy outcomes are values; errors signal malformed input:
| Error | Raised by | Meaning |
|---|---|---|
RuleError::MissingTypeId | build() | The rule does not name a type |
RuleError::InvalidPath | build() | A JSONPath is malformed or deeper than 10 segments |
RuleError::InvalidTimeOfDay | build() | A time is not HH:MM or HH:MM:SS |
PolicyError::InvalidRule / DuplicateRule / TooManyRules | add_rule | See above |
ConstraintViolation::TypeMismatch | rule.evaluate | A comparison met an unexpected value type; the policy records it as a warning and the rule fails |
Best practices
- Name rules after their intent (
eur-payments-up-to-10000), somatched_rulesreads as an audit trail. - Keep independent concerns in separate rules so they can be disabled and revoked independently.
- Evaluate the same policy at the authorization server (before granting) and at the resource server (before acting); rules serialize cleanly between services.
Next steps
Pin rules to validity windows and business hours with Temporal Rules, compose them with RBAC in the acls-rs Bridge and with host access in HBAC Integration.