Introduction
BAC Rules is a set of Rust libraries for evaluating access control policies, with a focus on FreeIPA and LDAP directory deployments. The libraries cover several access control models — HBAC, ABAC, RBAC, LDAP ACIs, POSIX ACLs, and Windows Security Descriptors — and can be used from Rust, Python, or the browser via WebAssembly.
Crates
Core libraries
| Crate | Purpose |
|---|---|
| acls-rs | Permission primitives, RBAC with role inheritance, temporal permissions |
| hbac-rs | FreeIPA Host-Based Access Control — three-dimensional matching (user × host × service) |
| abac-rs | Attribute-Based Access Control with arbitrary typed dimensions |
| ldap-acis | LDAP Access Control Instructions — parse, evaluate, generate, and transform ACIs for 389 DS and OpenLDAP |
| posix-acls | POSIX.1e ACL modeling: typed permission model, ACL builder, access check algorithm, user/group model, acls-rs bridge |
| win-sd | Windows Security Descriptor (MS-DTYP): SIDs, ACCESS_MASKs, 21 ACE types, SDDL parsing/generation, access check algorithm (integrity, privileges, restricted tokens), conditional ACEs, OBJECT_TYPE_LIST, claims, configurable PermissionMapping bridge |
Bindings
| Crate | Purpose |
|---|---|
| acls-python, hbac-python, abac-python, posix-acls-python, win-sd-python | Python bindings via PyO3 with cross-module type sharing |
| abac-wasm, hbac-wasm | WebAssembly packages for browsers and Node.js |
Tools
| Crate | Purpose |
|---|---|
| perf-testing | Benchmark CLI with synthetic rule generation, fixture management, and scaling analysis |
| ldif-parser | LDIF (RFC 2849) parser producing LdapEntry structures for ldap-acis |
Access control models
- RBAC (Role-Based Access Control) — permissions granted through roles with inheritance hierarchies
- HBAC (Host-Based Access Control) — FreeIPA’s model for deciding whether a user may access a service on a host
- ABAC (Attribute-Based Access Control) — policy decisions based on arbitrary attributes of the requester, resource, and environment
- LDAP ACIs — fine-grained access control over directory entries and attributes, as used by 389 Directory Server and OpenLDAP
- POSIX ACLs — POSIX.1e access control lists for file system permissions
- Windows Security Descriptors — MS-DTYP security descriptors with SDDL, DACLs/SACLs, and mandatory integrity
Project goals
- Correctness — permission composition follows well-defined algebraic rules so that grant/deny interactions behave predictably
- Performance — evaluation stays fast at scale through caching, indexing, and optional JIT compilation; see Performance Results for benchmark data
- Composability — combine HBAC, RBAC, and ABAC policies in a single evaluation
- Production use — designed for SSSD and enterprise identity deployments
Use cases
- FreeIPA deployments — HBAC rule evaluation for identity management
- SSSD integration — local caching and evaluation of access policies
- Directory servers — ACI analysis, migration, and evaluation for 389 DS and OpenLDAP
- Python applications — high-performance access control via Python bindings
- Web applications — browser and Node.js support via WebAssembly
- Samba/NFS interoperability — NT to POSIX ACL translation via the PermissionSet bridge
- Active Directory — AD schema access control analysis with SDDL and LDAP ACI cross-model comparison
Getting started
See the Installation guide to begin using BAC Rules.
Installation
Prerequisites
- Rust 1.70 or later
- Cargo (included with Rust)
Installing Rust
If you don’t have Rust installed:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Adding as a Dependency
acls-rs
For the algebraic access control foundation:
[dependencies]
acls-rs = "0.1"
# With serde support for JSON serialization
acls-rs = { version = "0.1", features = ["serde"] }
hbac-rs
For HBAC rule evaluation:
[dependencies]
hbac-rs = "0.1"
acls-rs = "0.1" # Required dependency
# With serde and caching support
hbac-rs = { version = "0.1", features = ["serde"] }
abac-rs
For ABAC rule evaluation:
[dependencies]
abac-rs = "0.1"
acls-rs = "0.1" # Required dependency
# With serde support
abac-rs = { version = "0.1", features = ["serde"] }
perf-testing
For performance testing and benchmarking:
[dependencies]
perf-testing = "0.1"
Building from Source
Clone the repository:
git clone https://codeberg.org/abbra/aci-validator.git bac-rules
cd bac-rules
Build all crates:
cargo build --release
Build specific crate:
cargo build --package hbac-rs --release
cargo build --package abac-rs --release
cargo build --package acls-rs --release
cargo build --package perf-testing --release
Running Tests
# Test all crates
cargo test --workspace
# Test specific crate
cargo test --package hbac-rs
cargo test --package abac-rs
cargo test --package acls-rs
cargo test --package perf-testing
Performance Testing CLI
Build the bac-perf CLI tool:
cargo build --release --package perf-testing
The binary will be at target/release/bac-perf.
Verification
Verify installation by running examples:
# acls-rs example
cargo run --package acls-rs --example basic_usage
# hbac-rs example
cargo run --package hbac-rs --example basic_usage
# abac-rs example
cargo run --package abac-rs --example basic_usage
# Performance test
./target/release/bac-perf --help
Python Bindings
All three crates (acls-rs, hbac-rs, abac-rs) provide Python bindings via PyO3.
Prerequisites
- Python 3.8 or higher
- Rust 1.70 or higher (for building from source)
- maturin - Python/Rust build tool
Quick Setup
We recommend using a virtual environment:
# Create and activate virtual environment
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install maturin
pip install maturin
Installing Python Bindings
Development mode (recommended for local development):
# Install all three packages
cd crates/acls-python && maturin develop && cd ../..
cd crates/hbac-python && maturin develop && cd ../..
cd crates/abac-python && maturin develop && cd ../..
Production wheels:
# Build wheels
cd crates/acls-python
maturin build --release --out ../../dist/
cd ../hbac-python
maturin build --release --out ../../dist/
cd ../abac-python
maturin build --release --out ../../dist/
cd ../..
# Install from wheels
pip install dist/acls_rs-*.whl
pip install dist/hbac_rs-*.whl
pip install dist/abac_rs-*.whl
Verifying Installation
# Test acls-rs bindings
python3 crates/acls-python/examples/basic_usage.py
# Test hbac-rs bindings
python3 crates/hbac-python/examples/basic_usage.py
# Test abac-rs bindings
python3 crates/abac-python/examples/basic_usage.py
Quick Test
# Test in Python REPL
python3
>>> from hbac_rs import HbacRuleBuilder, HbacPolicy, HbacRequest, Subject
>>> rule = HbacRuleBuilder("test").user("alice").host_category_all().service_category_all().enabled(True).build()
>>> policy = HbacPolicy()
>>> policy.add_rule(rule)
<hbac_rs.HbacPolicy object at 0x...>
>>> user = Subject("alice")
>>> request = HbacRequest(user, "server", "ssh")
>>> policy.check_access(request)
True
See the Python bindings documentation for detailed API reference:
Platform Support
Tested on:
- Linux (x86_64, aarch64)
- macOS (x86_64, Apple Silicon)
- Windows (x86_64)
The crates are platform-independent pure Rust with no OS-specific dependencies.
Quick Start
This guide will get you up and running with BAC Rules in 5 minutes.
acls-rs Quick Start
Basic Permission Usage
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
// Create a subject (user)
let mut alice = Subject::new("alice");
// Grant permissions
let read_file = AtomicPermission::new("file", "read");
let write_file = AtomicPermission::new("file", "write");
alice.grant(read_file);
alice.grant(write_file);
// Check permissions
assert!(alice.has_permission(&AtomicPermission::new("file", "read")));
assert!(alice.has_permission(&AtomicPermission::new("file", "write")));
}
RBAC Example
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
// Create roles
let mut developer_role = Role::new("developer", GrantDenialPair::empty());
developer_role.grant(AtomicPermission::new("code", "read"));
developer_role.grant(AtomicPermission::new("code", "write"));
let mut admin_role = Role::new("admin", GrantDenialPair::empty());
admin_role.grant(AtomicPermission::new("system", "admin"));
// Create RBAC policy
let mut policy = RbacPolicy::new();
policy.add_role(developer_role);
policy.add_role(admin_role);
// Assign roles to user
let mut user = Subject::new("bob");
user.roles.push("developer".to_string());
// Apply policy
policy.apply_to(&mut user);
// Check permissions
assert!(user.has_permission(&AtomicPermission::new("code", "read")));
}
hbac-rs Quick Start
Simple HBAC Rule
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
use acls_rs::Subject;
// Create an HBAC rule
let ssh_rule = HbacRule::builder("allow_ssh")
.user_category_all() // All users
.host_category_all() // All hosts
.service("sshd") // Only SSH service
.enabled(true)
.build()
.unwrap();
// Create a policy and add rule
let mut policy = HbacPolicy::new();
policy.add_rule(ssh_rule);
// Create a request
let user = Subject::new("alice");
let request = HbacRequest::new(user, "server1.example.com", "sshd");
// Evaluate
let result = policy.evaluate(&request);
assert!(result.is_allowed());
}
Group-Based Rule
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
use acls_rs::Subject;
// Create rule for admin group
let admin_rule = HbacRule::builder("admins_all_access")
.user_group("admins")
.host_category_all()
.service_category_all()
.enabled(true)
.build()
.unwrap();
let mut policy = HbacPolicy::new();
policy.add_rule(admin_rule);
// Create user with group membership
let mut alice = Subject::new("alice");
alice.roles.push("admins".to_string());
let request = HbacRequest::new(alice, "server1.example.com", "httpd");
let result = policy.evaluate(&request);
assert!(result.is_allowed());
}
abac-rs Quick Start
Simple ABAC Rule
#![allow(unused)]
fn main() {
use abac_rs::prelude::*;
use acls_rs::Subject;
// Create an ABAC rule
let rule = AbacRule::builder("allow_admin_access")
.allow()
.add_condition("role", "admin")
.add_condition("department", "engineering")
.enabled(true)
.build()
.unwrap();
// Create a policy and add rule
let mut policy = AbacPolicy::new();
policy.add_rule(rule);
// Create a request with attributes
let mut request = AbacRequest::new();
request.set_attribute("role", AttributeValue::String("admin".to_string()));
request.set_attribute("department", AttributeValue::String("engineering".to_string()));
// Evaluate
let result = policy.evaluate(&request);
assert!(result.is_allowed());
}
Multi-Type Attributes
#![allow(unused)]
fn main() {
use abac_rs::prelude::*;
use std::net::IpAddr;
// Create rule with IP address matching
let rule = AbacRule::builder("allow_from_trusted_network")
.allow()
.add_condition("source_ip", "10.0.0.0/8")
.add_condition("port", 443)
.enabled(true)
.build()
.unwrap();
let mut policy = AbacPolicy::new();
policy.add_rule(rule);
// Create request with typed attributes
let mut request = AbacRequest::new();
request.set_attribute("source_ip", AttributeValue::IpAddr("10.1.2.3".parse().unwrap()));
request.set_attribute("port", AttributeValue::Integer(443));
let result = policy.evaluate(&request);
assert!(result.is_allowed());
}
Performance Testing Quick Start
Generate Test Fixtures
# Generate 1000 HBAC rules with SSSD production distribution
bac-perf generate --count 1000 --output hbac_1k_prod --distribution sssd-prod
# Generate 100 rules for development
bac-perf generate --count 100 --output hbac_100_dev --distribution dev
Run Benchmarks
# Run all scenarios with caching
bac-perf bench --fixture hbac_1k_prod --scenario all --cache
# Run single latency test
bac-perf bench --fixture hbac_100_dev --scenario single-latency
# Save results as JSON
bac-perf bench --fixture hbac_1k_prod --scenario throughput \\
--cache --format json --output results/
List Fixtures
# List all fixtures
bac-perf list
# Show detailed information
bac-perf list --verbose
Next Steps
- Examples - More detailed examples
- acls-rs User Guide - Full acls-rs documentation
- hbac-rs User Guide - Full hbac-rs documentation
- abac-rs User Guide - Full abac-rs documentation
- Performance Testing Guide - Complete perf-testing documentation
Examples
This page provides complete runnable examples for common use cases.
Running Examples
All examples can be run from the workspace root:
# acls-rs examples
cargo run --package acls-rs --example basic_usage
cargo run --package acls-rs --example rbac_abac_demo
cargo run --package acls-rs --example predictive_calculation
# hbac-rs examples
cargo run --package bac-examples --example hbac_basic_usage
cargo run --package bac-examples --example hbac_advanced_features
cargo run --package bac-examples --example hbac_sssd_deployment
# abac-rs examples
cargo run --package abac-rs --example basic_usage
cargo run --package abac-rs --example multi_type_attributes
cargo run --package abac-rs --example custom_matchers
Example: Temporal Permissions
For temporal ABAC policies, see the ABAC examples.
// Conceptual example - see abac-rs for working temporal rule implementation
use acls_rs::prelude::*;
fn main() {
// Temporal permissions are implemented via ABAC temporal rules
// See crates/abac-rs/examples/temporal_rules.rs for working code
}
Example: HBAC with Deny Rules
use hbac_rs::prelude::*;
use acls_rs::Subject;
fn main() {
let mut policy = HbacPolicy::new();
// Allow all users to SSH
let allow_ssh = HbacRule::builder("allow_ssh")
.user_category_all()
.host_category_all()
.service("sshd")
.enabled(true)
.build()
.unwrap();
policy.add_rule(allow_ssh);
// Deny contractors from production servers
let deny_contractors = HbacRule::builder("deny_contractors_prod")
.deny()
.user_group("contractors")
.host_group("production")
.service_category_all()
.enabled(true)
.build()
.unwrap();
policy.add_rule(deny_contractors);
// Contractor trying to access production
let mut contractor = Subject::new("bob");
contractor.roles.push("contractors".to_string());
let request = HbacRequest::builder()
.user(contractor)
.targethost("prod-web1.example.com")
.targethost_group("production")
.service("sshd")
.build()
.unwrap();
let result = policy.evaluate(&request);
assert!(!result.is_allowed()); // Denied!
}
Example: HBAC+RBAC Composition
use hbac_rs::prelude::*;
use acls_rs::prelude::*;
fn main() {
// Setup HBAC policy
let mut hbac = HbacPolicy::new();
let rule = HbacRule::builder("web_servers")
.user_category_all()
.host_group("webservers")
.service("httpd")
.enabled(true)
.build()
.unwrap();
hbac.add_rule(rule);
// Setup RBAC policy
let mut rbac = RbacPolicy::new();
let grants = [AtomicPermission::new("web01.example.com", "httpd")].into();
let admin_role = Role::new("webadmin", GrantDenialPair::new(grants, PermissionSet::new()));
rbac.add_role(admin_role);
// Compose with AND mode (both must allow)
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::And);
// User with webadmin role
let mut user = Subject::new("alice");
user.roles.push("webadmin".to_string());
user.grant(AtomicPermission::new("web01.example.com", "httpd"));
let request = HbacRequest::builder()
.user(user)
.targethost("web01.example.com")
.targethost_group("webservers")
.service("httpd")
.build()
.unwrap();
let result = policy.evaluate(&request);
assert!(result.is_allowed()); // Both HBAC and RBAC allow
}
Example: SSSD Caching Deployment
use hbac_rs::cache::*;
use hbac_rs::prelude::*;
fn main() {
// Setup for local host
let hostname = "web01.prod.example.com";
let host_groups = vec!["webservers".to_string(), "production".to_string()];
// Create pipeline with host-specific filtering
let mut pipeline = RulePipeline::builder()
.with_filter(
HostFilter::for_host(hostname)
.with_groups(host_groups.clone())
)
.with_cache(IndexedCache::new())
.build();
// Load rules (from LDAP in production)
let rules = create_sample_rules();
pipeline.load(rules);
println!("Cached rules: {}", pipeline.rule_count());
println!("Memory usage: {} bytes", pipeline.stats().memory_bytes);
// Fast indexed evaluation
let user = Subject::new("alice");
let request = HbacRequest::new(user, hostname, "sshd");
let result = pipeline.evaluate(&request);
println!("Access: {}", if result.is_allowed() { "ALLOW" } else { "DENY" });
}
fn create_sample_rules() -> Vec<HbacRule> {
// See examples/sssd_deployment.rs for full implementation
vec![]
}
Example: Performance Testing
See the benchmarks in crates/*/benches/ for performance testing examples.
// Conceptual example - see crates/hbac-rs/benches for actual benchmarks
use perf_testing::*;
use perf_testing::synthesis::*;
use perf_testing::runner::*;
fn main() -> anyhow::Result<()> {
// Generate 1000 rules
let synthesizer = HbacSynthesizer::new(42);
let config = SynthesisConfig {
rule_count: 1000,
distributions: Distributions::sssd_production(),
seed: 42,
};
let rules = synthesizer.synthesize(&config);
// Save as fixture
let fixture_mgr = FixtureManager::new("fixtures")?;
let metadata = FixtureMetadata::new(
rules.len(),
config,
synthesizer.entity_pools(),
);
fixture_mgr.save("bench_1k", "hbac", &rules, metadata)?;
// Run benchmark
let mut runner = BenchmarkRunner::new("fixtures".into())?;
let scenario = Scenario::single_request_latency(
"bench_1k".to_string(),
"hbac".to_string(),
true, // with cache
);
let metrics = runner.run_scenario(&scenario)?;
println!("{}", ReportFormatter::format_terminal(&metrics));
Ok(())
}
More Examples
See the examples/ directory in each crate for more:
- acls-rs/examples/: RBAC, ABAC, predictive calculations
- examples/: Cross-crate examples (HBAC, NT↔POSIX translation, AD schema access)
- hbac-rs/: JIT compilation, temporal rules
- abac-rs/examples/: Multi-type attributes, custom matchers, deny rules
- abac-wasm/examples/: Node.js usage, browser demo, TypeScript sensor monitoring
- hbac-wasm/examples/: Node.js usage, browser demo
- perf-testing: Fixture generation, benchmarking workflows
All examples are fully documented and can be used as templates for your own code.
acls-rs: Algebraic Access Control
Every application that serves more than one user eventually needs access control.
You could scatter if user == "admin" checks throughout your code, but those
checks multiply, contradict each other, and resist refactoring. acls-rs provides
a foundation of composable building blocks that make permission decisions
predictable by construction.
This chapter uses a running example: you are building an internal developer platform where employees deploy services, access servers, and manage infrastructure. The platform starts small and grows, and the access control requirements grow with it.
What acls-rs gives you
Four layers of access control, each building on the previous:
-
Permissions and subjects – define what actions exist (
file:read,database:write) and which users hold them. Permissions form algebraic sets with well-defined union, intersection, and difference operations. Denials explicitly override grants. -
RBAC policies – group permissions into named roles (
developer,admin,ops) with inheritance hierarchies. Assign roles to users instead of managing permissions individually. -
ABAC policies – make access decisions based on runtime attributes (department, network location, clearance level). Useful when static roles cannot capture the policy you need.
-
Temporal permissions – grant access that activates at a future time or expires automatically. Contractor access, maintenance windows, and emergency overrides all fit this model.
Quick start
Add acls-rs to your Cargo.toml:
[dependencies]
acls-rs = "0.1"
With serde support:
[dependencies]
acls-rs = { version = "0.1", features = ["serde"] }
A minimal example that creates a user, grants permissions, and checks access against a protected resource:
use acls_rs::prelude::*;
fn main() {
// Define two permissions for a file-sharing service
let read = AtomicPermission::new("file", "read");
let write = AtomicPermission::new("file", "write");
// Create a subject (user) with those permissions
let alice = Subject::builder()
.id("alice")
.grant(read.clone())
.grant(write.clone())
.build()
.unwrap();
// Define a resource that requires read access
let document = Resource::new("quarterly-report.pdf")
.requires(read.clone())
.resource_type("file");
// Check whether Alice can access the document
assert!(document.can_access(&alice));
}
Algebraic guarantees
All permission operations satisfy formal mathematical properties:
- Associativity: combining three permission sets produces the same result regardless of grouping.
- Commutativity: the order of combination does not matter.
- Idempotence: combining a set with itself is a no-op.
- Identity: combining with the empty set returns the original.
These properties mean you can compose permission sets freely – merge
permissions from multiple roles, intersect them with context-based
restrictions, subtract revocations – and the result is always well-defined.
The library encodes these as Rust traits (Semigroup, Monoid,
MeetSemilattice, JoinSemilattice) with comprehensive property-based tests.
What to read next
- Permissions and Subjects – the fundamental building blocks: atomic permissions, permission sets, grant/denial pairs, subjects, and resources.
- RBAC Policies – role-based access control with inheritance and cycle detection.
- ABAC Policies – attribute-based access control for context-sensitive decisions.
- Temporal Permissions – time-limited access with automatic expiration.
Permissions and Subjects
Your platform’s first task is straightforward: an internal file-sharing service where some people can read documents and others can also write them. This chapter introduces the types that model that requirement.
Atomic permissions
An AtomicPermission is the smallest unit of access control – a single
action on a single resource namespace.
#![allow(unused)]
fn main() {
use acls_rs::permission::AtomicPermission;
let read_file = AtomicPermission::new("file", "read");
let write_db = AtomicPermission::new("database", "write");
// String representation is "namespace:action"
assert_eq!(read_file.to_string(), "file:read");
}
You can also parse permissions from strings:
#![allow(unused)]
fn main() {
use std::str::FromStr;
use acls_rs::permission::AtomicPermission;
let perm = AtomicPermission::from_str("server:admin").unwrap();
assert_eq!(perm.namespace(), "server");
assert_eq!(perm.action(), "admin");
}
Choose namespaces that reflect your domain (file, database, deploy,
api) and actions that are clear verbs (read, write, delete, admin).
Permission sets
A PermissionSet collects atomic permissions into a set with algebraic
operations. Two teams on your platform – frontend and backend developers –
have overlapping permission needs. Sets let you compute what they share and
where they differ.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let frontend = PermissionSet::from([
AtomicPermission::new("ui", "deploy"),
AtomicPermission::new("api", "read"),
]);
let backend = PermissionSet::from([
AtomicPermission::new("api", "read"),
AtomicPermission::new("api", "write"),
AtomicPermission::new("database", "query"),
]);
// Union: everything either team can do
let combined = frontend.clone().combine(backend.clone());
assert!(combined.contains(&AtomicPermission::new("ui", "deploy")));
assert!(combined.contains(&AtomicPermission::new("database", "query")));
// Intersection: what both teams share
let shared = frontend.clone().meet(backend.clone());
assert!(shared.contains(&AtomicPermission::new("api", "read")));
assert!(!shared.contains(&AtomicPermission::new("ui", "deploy")));
// Difference: what frontend has that backend does not
let frontend_only = frontend.difference(&backend);
assert!(frontend_only.contains(&AtomicPermission::new("ui", "deploy")));
}
The same operations are available as operators: | for union, & for
intersection, - for difference.
Grant/denial pairs
Sometimes you need to explicitly block a permission even when other rules
would grant it. A GrantDenialPair separates granted and denied permissions,
with a simple precedence rule: denials always win.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
// Start with an empty pair
let mut pair = GrantDenialPair::empty();
// Grant read and delete
let read = AtomicPermission::new("file", "read");
let delete = AtomicPermission::new("file", "delete");
pair.grants.extend(PermissionSet::from([read.clone(), delete.clone()]));
// Deny delete -- overrides the grant
pair.denials.extend(PermissionSet::from([delete.clone()]));
// Effective permissions: only read survives
assert!(pair.has_permission(&read));
assert!(!pair.has_permission(&delete));
let effective = pair.effective_permissions();
assert!(effective.contains(&read));
assert!(!effective.contains(&delete));
}
You can also construct a pair directly:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let grants = PermissionSet::from([
AtomicPermission::new("file", "read"),
AtomicPermission::new("file", "write"),
]);
let denials = PermissionSet::from([
AtomicPermission::new("file", "delete"),
]);
let pair = GrantDenialPair::new(grants, denials);
}
Subjects
A Subject represents a user (or service account) with permissions and role
memberships. The builder pattern is the idiomatic way to create subjects:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let alice = Subject::builder()
.id("alice")
.grant(AtomicPermission::new("file", "read"))
.grant(AtomicPermission::new("file", "write"))
.role("developers")
.role("team-leads")
.build()
.unwrap();
assert!(alice.has_permission(&AtomicPermission::new("file", "read")));
assert!(alice.has_permission(&AtomicPermission::new("file", "write")));
}
You can also create a subject and modify it incrementally:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let mut bob = Subject::new("bob");
bob.grant(AtomicPermission::new("file", "read"));
bob.deny(AtomicPermission::new("file", "delete"));
bob.roles.push("interns".to_string());
// Bob can read but not delete
assert!(bob.has_permission(&AtomicPermission::new("file", "read")));
assert!(!bob.has_permission(&AtomicPermission::new("file", "delete")));
}
Resources
A Resource models a protected object with required permissions. It answers
the question “can this subject access this resource?” by checking whether the
subject holds every required permission.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let alice = Subject::builder()
.id("alice")
.grant(AtomicPermission::new("file", "read"))
.build()
.unwrap();
let report = Resource::new("quarterly-report.pdf")
.requires(AtomicPermission::new("file", "read"))
.resource_type("file");
assert!(report.can_access(&alice));
// If a resource requires more than the subject has, access is denied
let admin_panel = Resource::new("admin-panel")
.requires(AtomicPermission::new("system", "admin"));
assert!(!admin_panel.can_access(&alice));
// missing_permissions tells you exactly what is lacking
let missing = admin_panel.missing_permissions(&alice);
assert!(missing.contains(&AtomicPermission::new("system", "admin")));
}
Permission deltas
When you need to describe a batch change – “add write access, remove read
access” – PermissionDelta captures the transformation:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let delta = PermissionDelta::builder()
.grant_str("file", "write")
.remove_str("file", "read")
.build();
let before = PermissionSet::from([
AtomicPermission::new("file", "read"),
]);
let after = delta.apply_to(before);
assert!(after.contains(&AtomicPermission::new("file", "write")));
assert!(!after.contains(&AtomicPermission::new("file", "read")));
}
Next steps
Individual permissions work when you have a handful of users. As your platform grows to dozens of people across engineering, ops, and product teams, managing permissions per person becomes unwieldy. The next chapter introduces roles – named bundles of permissions that you assign to users instead.
See RBAC Policies.
RBAC Policies
Your platform now has 50 employees across engineering, ops, and product teams.
Granting file:read and api:write to each person individually does not
scale. Role-Based Access Control (RBAC) solves this: define roles that carry
permissions, then assign roles to users.
Creating roles
A Role bundles a GrantDenialPair (the permissions the role carries) with a
name. Construct the permissions first, then wrap them in a role:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let viewer_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("file", "read"),
AtomicPermission::new("api", "read"),
]),
PermissionSet::new(), // no denials
);
let viewer = Role::new("viewer", viewer_perms);
let editor_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("file", "read"),
AtomicPermission::new("file", "write"),
AtomicPermission::new("api", "read"),
AtomicPermission::new("api", "write"),
]),
PermissionSet::new(),
);
let editor = Role::new("editor", editor_perms);
}
Building a policy
An RbacPolicy holds a set of roles and resolves the effective permissions
for any combination of role names.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
// (viewer and editor defined as above)
let mut policy = RbacPolicy::new();
policy.add_role(viewer);
policy.add_role(editor);
// Resolve permissions for a user with the "editor" role
let alice_roles = vec!["editor".to_string()];
let alice_perms = policy.resolve_permissions(&alice_roles).unwrap();
assert!(alice_perms.has_permission(&AtomicPermission::new("file", "write")));
}
For a quick check of whether a set of roles grants a specific permission, use
effective_permissions:
#![allow(unused)]
fn main() {
let effective = policy.effective_permissions(&alice_roles).unwrap();
assert!(effective.contains(&AtomicPermission::new("api", "write")));
}
Role hierarchy
Roles can inherit from parent roles. This avoids duplicating permissions across related roles. On your platform, a senior developer has everything a developer has, plus the ability to deploy to production:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let developer_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("code", "read"),
AtomicPermission::new("code", "write"),
AtomicPermission::new("ci", "trigger"),
]),
PermissionSet::new(),
);
let developer = Role::new("developer", developer_perms);
let senior_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("deploy", "production"),
]),
PermissionSet::new(),
);
let senior = Role::new("senior-developer", senior_perms)
.with_parent("developer");
let mut policy = RbacPolicy::new();
policy.add_role(developer);
policy.add_role(senior);
// A user with "senior-developer" gets both sets of permissions
let roles = vec!["senior-developer".to_string()];
let perms = policy.effective_permissions(&roles).unwrap();
assert!(perms.contains(&AtomicPermission::new("code", "read")));
assert!(perms.contains(&AtomicPermission::new("deploy", "production")));
}
Multiple role assignment
Users can hold more than one role. The effective permissions are the union of all assigned roles:
#![allow(unused)]
fn main() {
let roles = vec!["developer".to_string(), "reviewer".to_string()];
let perms = policy.resolve_permissions(&roles).unwrap();
}
Cycle detection
RBAC prevents circular inheritance. If role A inherits from B and B inherits from A, resolving permissions returns an error:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let role_a = Role::new("a", GrantDenialPair::empty())
.with_parent("b");
let role_b = Role::new("b", GrantDenialPair::empty())
.with_parent("a");
let mut policy = RbacPolicy::new();
policy.add_role(role_a);
policy.add_role(role_b);
let roles = vec!["a".to_string()];
match policy.resolve_permissions(&roles) {
Err(RbacError::CyclicDependency) => {
// Cycle detected -- the policy is misconfigured
}
_ => panic!("expected cycle detection"),
}
}
Conflict resolution
When a user holds multiple roles and one role grants a permission while
another denies it, denials take precedence. This is the same rule as
GrantDenialPair: denials always win.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let ops = Role::new("ops", GrantDenialPair::new(
PermissionSet::from([AtomicPermission::new("data", "delete")]),
PermissionSet::new(),
));
let safety = Role::new("safety-lock", GrantDenialPair::new(
PermissionSet::new(),
PermissionSet::from([AtomicPermission::new("data", "delete")]),
));
let mut policy = RbacPolicy::new();
policy.add_role(ops);
policy.add_role(safety);
let roles = vec!["ops".to_string(), "safety-lock".to_string()];
let perms = policy.resolve_permissions(&roles).unwrap();
// Denial from safety-lock overrides the grant from ops
assert!(!perms.has_permission(&AtomicPermission::new("data", "delete")));
}
Next steps
Roles handle static permission assignments well. But your platform now supports remote work, and the security team requires that certain operations (database writes, production deployments) are only allowed from the corporate network during business hours. Static roles cannot express this constraint. The next chapter introduces attribute-based access control for context-aware decisions.
See ABAC Policies.
ABAC Policies
Your platform’s security team mandates a new policy: database write access is only allowed from the corporate network during business hours. Roles cannot express this – a “database-writer” role either grants the permission or it does not, regardless of where or when the request originates.
Attribute-Based Access Control (ABAC) solves this by making access decisions based on runtime attributes: who the user is, what they are trying to access, and the environment in which the request occurs.
Attribute context
An AttributeContext is a HashMap<String, String> that carries the runtime
attributes available at decision time. You populate it from whatever sources
your application has – request headers, environment variables, identity
provider claims:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::policy::AttributeContext;
let mut context = AttributeContext::new();
context.insert("department".into(), "engineering".into());
context.insert("network".into(), "corporate".into());
context.insert("time_of_day".into(), "business-hours".into());
context.insert("clearance".into(), "standard".into());
}
Attribute rules
An AttributeRule maps a set of required attributes to permissions that are
granted (or denied) when those attributes are present and match. The rule
checks whether every key-value pair in the rule’s attribute map appears in the
context.
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::prelude::*;
// Grant database:write when network=corporate AND time_of_day=business-hours
let rule = AttributeRule::new()
.grant(
AtomicPermission::new("database", "write"),
HashMap::from([
("network".into(), "corporate".into()),
("time_of_day".into(), "business-hours".into()),
]),
);
}
Rules can also carry denials. A denial matches when its attribute requirements
are met and overrides any corresponding grant, just like GrantDenialPair:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::prelude::*;
// Deny production:deploy when environment=staging
let rule = AttributeRule::new()
.deny(
AtomicPermission::new("production", "deploy"),
HashMap::from([
("environment".into(), "staging".into()),
]),
);
}
You can chain multiple grants and denials on a single rule:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::prelude::*;
let rule = AttributeRule::new()
.grant(
AtomicPermission::new("api", "read"),
HashMap::from([("authenticated".into(), "true".into())]),
)
.grant(
AtomicPermission::new("api", "write"),
HashMap::from([
("authenticated".into(), "true".into()),
("role".into(), "editor".into()),
]),
)
.deny(
AtomicPermission::new("api", "admin"),
HashMap::from([("role".into(), "guest".into())]),
);
}
Building a policy
An AbacPolicy collects rules and evaluates them against a context. By
default, when multiple rules produce conflicting results, the policy uses
MeetResolver (intersection – the most restrictive outcome wins):
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::prelude::*;
let corp_db_rule = AttributeRule::new()
.grant(
AtomicPermission::new("database", "write"),
HashMap::from([
("network".into(), "corporate".into()),
("time_of_day".into(), "business-hours".into()),
]),
);
let read_anywhere = AttributeRule::new()
.grant(
AtomicPermission::new("database", "read"),
HashMap::from([("authenticated".into(), "true".into())]),
);
let mut policy = AbacPolicy::new();
policy.add_rule(corp_db_rule);
policy.add_rule(read_anywhere);
// Context: corporate network, business hours, authenticated
let mut context: AttributeContext = HashMap::new();
context.insert("network".into(), "corporate".into());
context.insert("time_of_day".into(), "business-hours".into());
context.insert("authenticated".into(), "true".into());
let result = policy.evaluate(&context);
assert!(result.has_permission(&AtomicPermission::new("database", "write")));
assert!(result.has_permission(&AtomicPermission::new("database", "read")));
// Context: home network -- database:write is not granted
let mut home_context: AttributeContext = HashMap::new();
home_context.insert("network".into(), "home".into());
home_context.insert("authenticated".into(), "true".into());
let result = policy.evaluate(&home_context);
assert!(!result.has_permission(&AtomicPermission::new("database", "write")));
assert!(result.has_permission(&AtomicPermission::new("database", "read")));
}
Conflict resolution
When multiple rules grant and deny the same permission, the conflict resolver determines the outcome. Two resolvers are available:
MeetResolver(default): intersection of all rule results. The most restrictive interpretation wins. Use this for high-security environments.JoinResolver: union of all rule results. The most permissive interpretation wins. Use this for convenience-first environments.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
// Use JoinResolver for a more permissive policy
let policy = AbacPolicy::with_resolver(JoinResolver);
}
Combining ABAC with RBAC
ABAC and RBAC address different concerns. A practical pattern is to use RBAC for static role assignments and ABAC for dynamic refinement:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use acls_rs::prelude::*;
// RBAC: resolve base permissions from roles
let mut rbac = RbacPolicy::new();
// ... add roles ...
let roles = vec!["developer".to_string()];
let role_perms = rbac.effective_permissions(&roles).unwrap();
// ABAC: evaluate context-dependent permissions
let mut abac = AbacPolicy::new();
// ... add rules ...
let context: AttributeContext = HashMap::new();
// ... populate context ...
let context_perms = abac.evaluate(&context).effective_permissions();
// Intersect: user gets only what both RBAC and ABAC agree on
let effective = role_perms.meet(context_perms);
}
Next steps
ABAC handles context at decision time, but it does not handle time itself. Your platform hires contractors who need access for exactly three months, and on-call engineers who need emergency admin access for 24 hours. The next chapter introduces temporal permissions – access that activates and expires automatically.
See Temporal Permissions.
Temporal Permissions
Your platform hires a contractor for a three-month engagement. They need read access to the codebase, but that access must disappear automatically when the contract ends. An on-call engineer needs emergency admin access for 24 hours after an incident, not forever. A database migration requires production write access during a four-hour maintenance window.
Temporal permissions model all of these: access that activates at a specific time and expires at another.
Creating temporal permissions
A TemporalPermission wraps an AtomicPermission with optional start and end
timestamps. Timestamps are milliseconds since the Unix epoch.
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::current_timestamp_millis;
let now = current_timestamp_millis();
let three_months = 90 * 24 * 60 * 60 * 1000; // in milliseconds
let contractor_access = TemporalPermission::new(
AtomicPermission::new("code", "read"),
Some(now), // active immediately
Some(now + three_months), // expires in 3 months
);
// Valid right now
assert!(contractor_access.is_currently_valid());
// Valid at a specific time
assert!(contractor_access.is_valid_at(now + 1000));
// Expired after the window
assert!(!contractor_access.is_valid_at(now + three_months + 1));
}
Validity windows
The valid_from and valid_until fields are both optional, giving you four
patterns:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::current_timestamp_millis;
let perm = AtomicPermission::new("system", "access");
let now = current_timestamp_millis();
// Active now, expires later
let expiring = TemporalPermission::new(perm.clone(), None, Some(now + 3600_000));
// Activates in the future, never expires
let deferred = TemporalPermission::new(perm.clone(), Some(now + 86400_000), None);
// Active during a specific window
let window = TemporalPermission::new(
perm.clone(),
Some(now + 3600_000),
Some(now + 7200_000),
);
// Always valid (equivalent to a regular permission)
let permanent = TemporalPermission::new(perm.clone(), None, None);
}
Attaching temporal permissions to subjects
Subjects carry a TemporalPermissionSet alongside their regular permissions.
Add temporal permissions with .add() and query what is effective at a given
time with .effective_at():
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::current_timestamp_millis;
let now = current_timestamp_millis();
let one_day = 24 * 60 * 60 * 1000;
let mut engineer = Subject::new("alice");
// Regular permission: always has code:read
engineer.grant(AtomicPermission::new("code", "read"));
// Temporal permission: admin access for 24 hours
engineer.temporal_permissions.add(TemporalPermission::new(
AtomicPermission::new("system", "admin"),
Some(now),
Some(now + one_day),
));
// Right now: both permissions are effective
let effective_now = engineer.effective_permissions_at(now + 1000);
assert!(effective_now.contains(&AtomicPermission::new("code", "read")));
assert!(effective_now.contains(&AtomicPermission::new("system", "admin")));
// After 24 hours: only the regular permission remains
let effective_later = engineer.effective_permissions_at(now + one_day + 1000);
assert!(effective_later.contains(&AtomicPermission::new("code", "read")));
assert!(!effective_later.contains(&AtomicPermission::new("system", "admin")));
}
Temporal permission sets
When you need to manage multiple temporal permissions as a collection,
TemporalPermissionSet provides batch operations:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::{TemporalPermissionSet, current_timestamp_millis};
let now = current_timestamp_millis();
let mut temp_perms = TemporalPermissionSet::new();
temp_perms.add(TemporalPermission::new(
AtomicPermission::new("database", "write"),
Some(now),
Some(now + 3600_000),
));
temp_perms.add(TemporalPermission::new(
AtomicPermission::new("deploy", "staging"),
Some(now),
Some(now + 86400_000),
));
// What permissions are effective right now?
let active = temp_perms.currently_effective();
// What permissions will be effective at a specific time?
let at_time = temp_perms.effective_at(now + 7200_000);
}
Use cases
Contractor access
A contractor needs three months of read-only access:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::current_timestamp_millis;
let now = current_timestamp_millis();
let three_months = 90 * 24 * 60 * 60 * 1000;
let mut contractor = Subject::new("contractor-jane");
contractor.temporal_permissions.add(TemporalPermission::new(
AtomicPermission::new("project", "read"),
Some(now),
Some(now + three_months),
));
}
Emergency access
An on-call engineer gets 24-hour admin access after an incident:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
use acls_rs::permission::current_timestamp_millis;
let now = current_timestamp_millis();
let one_day = 24 * 60 * 60 * 1000;
let mut oncall = Subject::new("alice");
oncall.temporal_permissions.add(TemporalPermission::new(
AtomicPermission::new("system", "admin"),
Some(now),
Some(now + one_day),
));
}
Maintenance window
A DBA gets production write access during a scheduled window:
#![allow(unused)]
fn main() {
use acls_rs::prelude::*;
let window_start = 1737338400_000; // 2025-01-20 02:00 UTC
let window_end = 1737352800_000; // 2025-01-20 06:00 UTC
let mut dba = Subject::new("dba-bob");
dba.temporal_permissions.add(TemporalPermission::new(
AtomicPermission::new("production", "write"),
Some(window_start),
Some(window_end),
));
}
Best practices
- Always set an expiration. Open-ended temporary access defeats the purpose.
- Use UTC timestamps internally. Convert to local time only for display.
- Build in a small grace period to account for clock skew between systems.
- Log temporal grants and expirations for audit purposes.
- Periodically check for expired permissions and clean up stale entries.
Next steps
So far, acls-rs has been controlling what actions users can perform within your application. But as your organization adopts FreeIPA for centralized identity management, a different question arises: which users can access which services on which hosts? This is host-based access control, and it is the domain of hbac-rs.
hbac-rs: HBAC Evaluation
Your organization has adopted FreeIPA for centralized identity management. Users, hosts, and services are all registered in the directory. Now you need to answer a new kind of question: can user Alice access the sshd service on host web01.example.com?
This is Host-Based Access Control (HBAC) – a three-dimensional access model where every decision depends on who is requesting access, which host they are targeting, and which service they want to use. All three dimensions must match for a rule to grant access.
hbac-rs provides idiomatic Rust types for HBAC rules, a policy engine with an integrated optimization pipeline, and deep integration with acls-rs for permissions, temporal rules, and RBAC composition.
Adding hbac-rs to your project
[dependencies]
hbac-rs = "0.1"
acls-rs = "0.1"
With serde support:
[dependencies]
hbac-rs = { version = "0.1", features = ["serde"] }
Basic usage
Create a rule that allows all users to SSH into a specific server, then evaluate a request:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
use acls_rs::Subject;
// Create an HBAC rule
let ssh_rule = HbacRule::builder("allow_ssh")
.user_category_all() // any user
.host("server.example.com") // this specific host
.service("sshd") // SSH service only
.enabled(true)
.build()
.unwrap();
// Build a policy and load the rule
let mut policy = HbacPolicy::new();
policy.add_rule(ssh_rule);
// Evaluate an access request
let alice = Subject::new("alice");
let request = HbacRequest::new(alice, "server.example.com", "sshd");
let result = policy.evaluate(&request);
assert!(result.is_allowed());
}
The three dimensions
Each HBAC rule specifies matching criteria on three dimensions:
| Dimension | Description | Examples |
|---|---|---|
| User | Who is requesting access | A named user, a user group, or all users |
| Host | Which host is being accessed | A named host, a host group, or all hosts |
| Service | Which service is requested | sshd, httpd, postgresql, or all services |
For each dimension, matching is either category=all (matches everything) or
specific names and groups. A rule matches a request only when all three
dimensions match.
HbacPolicy vs HbacPolicyLocal
hbac-rs provides two policy types:
HbacPolicy– thread-safe (Send + Sync), backed by aMutex. Use this when the policy is shared across threads (e.g., behind anArcin a web server).HbacPolicyLocal– single-threaded, backed by aRefCell. Use this when the policy lives on one thread (e.g., in a CLI tool or benchmark harness). Avoids mutex overhead.
Both types share the same API. Choose based on your threading model.
Integration with acls-rs
hbac-rs builds on acls-rs types:
| acls-rs type | HBAC usage |
|---|---|
Subject | Represents the requesting user (ID + role memberships as groups) |
AtomicPermission | Maps to (host, service) access grants |
GrantDenialPair | Handles allow/deny rule precedence |
TemporalPermission | Supports time-limited HBAC rules |
RbacPolicy | Composes with HBAC via ComposedPolicy |
What to read next
- HBAC Rules – constructing rules for different access patterns: category=all, specific entities, groups, and deny rules.
- Policy Evaluation – how the engine decides: matching logic, deny precedence, evaluation methods, and debugging.
- Caching System – scaling to thousands of rules with sub-microsecond evaluation.
- SSSD Integration – deploying on FreeIPA-enrolled hosts with LDAP synchronization and disk persistence.
- Temporal Rules – maintenance windows, contractor access, and auto-expiring rules.
- Policy Composition – combining HBAC host access with RBAC application permissions.
HBAC Rules
An HBAC rule answers one question: for a given combination of user, host, and service, should access be allowed or denied? This page covers how to construct rules for the access patterns your organization needs.
Creating a rule
Every rule starts with a name and is disabled by default. You populate the three dimensions – users, hosts, and services – and then enable the rule:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let rule = HbacRule::builder("allow_ssh_devs")
.user_group("developers")
.host_group("dev-servers")
.service("sshd")
.enabled(true)
.build()
.unwrap();
}
The add_* methods return Result<(), CategoryError>. They fail only if you
try to add a specific entity to a dimension that is already set to
category=all – the two modes are mutually exclusive.
Category=all vs. specific entities
Each dimension is either “all” (matches everything) or a list of specific names and groups. Use category=all when the rule should not restrict that dimension:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
// Allow any user to SSH into the bastion host
let bastion_rule = HbacRule::builder("bastion_ssh")
.user_category_all() // any user
.host("bastion.example.com") // this host only
.service("sshd") // SSH only
.enabled(true)
.build()
.unwrap();
}
Setting category=all and then calling add_user() on the same dimension
returns Err(CategoryError::CannotModifyAll).
Groups
Users, hosts, and services can be added individually or by group. Groups
are resolved via the Subject’s roles (for users) and the request’s group
fields (for hosts and services):
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let rule = HbacRule::builder("dba_postgres")
.user_group("dba-team")
.host_group("database-servers")
.service("postgresql")
.service("pgbouncer")
.enabled(true)
.build()
.unwrap();
// A user matches if their ID or any role matches the rule's user set
let mut dba = Subject::new("bob");
dba.roles.push("dba-team".to_string());
let request = HbacRequest::builder()
.user(dba)
.targethost("db01.example.com")
.targethost_group("database-servers")
.service("postgresql")
.build()
.unwrap();
}
The HbacRequest::builder() lets you attach host groups and service groups
that the matching engine checks alongside direct names.
Deny rules
By default, rules allow access. A deny rule blocks access even when other rules would allow it – denials take precedence:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut policy = HbacPolicy::new();
// Allow all developers to access dev servers
let allow = HbacRule::builder("allow_devs")
.user_group("developers")
.host_group("dev-servers")
.service_category_all()
.enabled(true)
.build()
.unwrap();
policy.add_rule(allow);
// Deny everyone from production hosts
let deny = HbacRule::builder("deny_production")
.deny()
.user_category_all()
.host_group("production")
.service_category_all()
.enabled(true)
.build()
.unwrap();
policy.add_rule(deny);
// A developer requesting access to a production host is denied,
// even though the allow rule matches their group
let dev = Subject::new("alice");
let request = HbacRequest::new(dev, "prod-web-01.example.com", "sshd");
let result = policy.evaluate(&request);
assert!(result.is_denied());
}
Use HbacRule::builder(name).deny() to create deny rules. You can check a rule’s type
with rule.is_allow() and rule.is_deny().
Enable and disable
Rules participate in evaluation only when enabled. This lets you prepare rules in advance and activate them on schedule:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut rule = HbacRule::builder("weekend_maintenance")
// ... configure dimensions ...
.build()
.unwrap();
// Disabled by default -- does not affect evaluation
assert!(!rule.enabled);
rule.enable();
// Now participates in evaluation
rule.disable();
// Temporarily removed from evaluation without deleting the rule
}
You can also enable or disable rules through the policy:
#![allow(unused)]
fn main() {
policy.enable_rule("weekend_maintenance");
policy.disable_rule("weekend_maintenance");
}
HbacResource: permission-based modeling
HbacResource models a (host, service) pair as an acls-rs Resource with
permissions. This is useful when you want to check access via the permission
system rather than rule evaluation:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let resource = HbacResource::new("database.example.com", "postgresql");
let mut alice = Subject::new("alice");
// Alice has no permissions yet
assert!(!resource.can_access(&alice));
// Grant access (adds the corresponding AtomicPermission to Alice)
resource.grant_access(&mut alice);
assert!(resource.can_access(&alice));
// Check what permission is required
let required = resource.required_permission();
assert!(alice.has_permission(&required));
// Revoke access
resource.deny_access(&mut alice);
assert!(!resource.can_access(&alice));
}
The builder provides more control over groups:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let resource = HbacResource::builder()
.host("db01.example.com")
.service("postgresql")
.host_group("database-servers")
.service_group("databases")
.build()
.unwrap();
}
Evaluate and apply
When a policy grants access, you often want to record that decision as a
permission on the subject. evaluate_and_apply does both in one call:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut policy = HbacPolicy::new();
// ... add rules ...
let alice = Subject::new("alice");
let mut request = HbacRequest::new(alice, "server.example.com", "sshd");
let (result, permissions) = policy.evaluate_and_apply(&mut request);
if result.is_allowed() {
// request.user now carries the granted AtomicPermission
assert!(!permissions.is_empty());
}
}
Putting it together
A typical server setup with layered rules:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut policy = HbacPolicy::new();
// Baseline: SSH access for all employees to all hosts
let ssh_all = HbacRule::builder("employee_ssh")
.user_category_all()
.host_category_all()
.service("sshd")
.enabled(true)
.build()
.unwrap();
policy.add_rule(ssh_all);
// Specific: DBAs can access PostgreSQL on database servers
let dba_pg = HbacRule::builder("dba_postgres")
.user_group("dba-team")
.host_group("database-servers")
.service("postgresql")
.enabled(true)
.build()
.unwrap();
policy.add_rule(dba_pg);
// Lockdown: deny all access to production hosts
let deny_prod = HbacRule::builder("deny_production")
.deny()
.user_category_all()
.host_group("production")
.service_category_all()
.enabled(true)
.build()
.unwrap();
policy.add_rule(deny_prod);
// Test: DBA can access dev database
let mut dba = Subject::new("bob");
dba.roles.push("dba-team".to_string());
let dev_request = HbacRequest::builder()
.user(dba.clone())
.targethost("db-dev-01.example.com")
.targethost_group("database-servers")
.service("postgresql")
.build()
.unwrap();
assert!(policy.evaluate(&dev_request).is_allowed());
// Test: DBA cannot access production database (deny rule wins)
let prod_request = HbacRequest::builder()
.user(dba)
.targethost("db-prod-01.example.com")
.targethost_group("production")
.service("postgresql")
.build()
.unwrap();
assert!(policy.evaluate(&prod_request).is_denied());
}
Next steps
You know how to build rules. The next page explains how the evaluation engine processes them – the matching logic, deny precedence, and the different evaluation methods available.
See Policy Evaluation.
Policy Evaluation
When a user attempts to log in to a host, the policy engine needs to decide: allow or deny? This page explains how that decision is made and which evaluation methods to use.
How evaluation works
The engine processes rules in four steps:
- Filter: Only enabled rules participate. Disabled rules are skipped entirely.
- Match: Each remaining rule is checked against all three dimensions of the request (user, host, service). A rule matches only if all three dimensions match.
- Deny precedence: If any matching rule is a deny rule, the request is denied regardless of how many allow rules also match.
- Default deny: If no allow rule matches (and no deny rule matched), the request is denied. HBAC is deny-by-default.
This means that allow rules grant access and deny rules revoke it, with denials always taking priority.
Dimension matching
For each dimension, a rule matches if any of these conditions holds:
- The dimension is set to category=all.
- The request’s entity name appears in the rule’s names set.
- Any of the request’s groups overlap with the rule’s groups set.
For the user dimension, the entity name is Subject.id and the groups are
Subject.roles. For host and service dimensions, the names and groups come
from HbacRequest.
Evaluation result
Every evaluation method returns an HbacEvaluationResult:
#![allow(unused)]
fn main() {
pub struct HbacEvaluationResult {
pub allowed: bool,
pub matched_rules: Vec<String>,
pub not_matched_rules: Vec<String>,
}
}
allowed– the final decision.matched_rules– names of rules that matched all three dimensions.not_matched_rules– names of enabled rules that did not match.
The is_allowed() and is_denied() methods provide convenient boolean checks.
Evaluation methods
check_access: fast boolean
When you only need a yes/no answer and do not care which rules matched:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let allowed: bool = policy.check_access(&request);
}
This is the fastest path – it short-circuits as soon as a decision can be made.
evaluate: standard evaluation
Returns the full result with the list of matched and unmatched rules:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let result = policy.evaluate(&request);
if result.is_allowed() {
println!("Access granted by: {:?}", result.matched_rules);
} else {
println!("Access denied. Rules checked: {:?}", result.not_matched_rules);
}
}
evaluate_detailed: debugging
Identical to evaluate but populates not_matched_rules with the names of
rules that were checked but did not match. Use this when debugging unexpected
denials:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let result = policy.evaluate_detailed(&request);
if result.is_denied() {
println!("Denied. These rules did not match:");
for name in &result.not_matched_rules {
println!(" - {}", name);
}
}
}
evaluate_at: temporal evaluation
Evaluates the request at a specific timestamp. Temporal rules whose validity window does not include the timestamp are excluded:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let now = current_timestamp_millis();
let result = policy.evaluate_at(&request, now);
}
There is also evaluate_detailed_at for the detailed variant.
evaluate_and_apply: grant permissions
Evaluates the request and, if access is allowed, applies the corresponding permissions to the request’s subject. This integrates HBAC decisions with acls-rs permission tracking:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let alice = Subject::new("alice");
let mut request = HbacRequest::new(alice, "server.example.com", "sshd");
let (result, permissions) = policy.evaluate_and_apply(&mut request);
if result.is_allowed() {
// request.user now carries the granted AtomicPermission
for perm in &permissions {
println!("Granted: {}", perm);
}
}
}
A temporal variant, evaluate_and_apply_at, accepts a timestamp.
Debugging unexpected denials
When a user reports “I can’t log in”, work through this checklist:
- Is the rule enabled? Check
rule.enabled. - Does the user dimension match? Is the user’s ID or any of their roles in the rule’s user set? Or is the user dimension set to category=all?
- Does the host dimension match? Is the request’s target host or any of its groups in the rule’s host set?
- Does the service dimension match? Same check for services.
- Is a deny rule matching? A matching deny rule overrides all allow rules.
- Is the rule temporal? Check whether the current time falls within the rule’s validity window.
Use evaluate_detailed to see which rules were checked and did not match.
Performance considerations
Without caching, evaluation is O(n) where n is the number of enabled rules. The optimization stack (described in Caching System) reduces this to sub-microsecond cached lookups for large rule sets.
See Performance Results for benchmark data at different rule counts.
Next steps
As your rule count grows, linear evaluation becomes a bottleneck. The next page introduces the caching pipeline that brings evaluation time down to sub-microsecond latency.
See Caching System.
Caching System
Your FreeIPA deployment has 5,000 HBAC rules across 200 hosts. Evaluating every rule on every login attempt is too slow. The caching pipeline filters, indexes, and stores only the rules that matter for the local host, bringing evaluation time down to sub-microsecond latency.
Architecture
The pipeline has four stages:
graph TD
A[RuleSource] -->|fetch rules| B[RuleFilter]
B -->|keep applicable rules| C[IndexedCache]
C -->|fast indexed lookup| D[Evaluator]
- RuleSource fetches rules from an external backend (LDAP, file, API).
- RuleFilter keeps only rules applicable to this host.
- IndexedCache stores rules with multi-dimensional indexes for fast lookup.
- Evaluator uses the indexed cache for sub-microsecond decisions.
Building a pipeline
RulePipelineBuilder assembles the stages:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let pipeline = RulePipeline::builder()
.with_filter(
HostFilter::for_host("web01.example.com")
.with_group("web-servers")
)
.with_cache(IndexedCache::new())
.build();
}
You can also attach a source for automatic fetching:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let source = MemorySource::new(rules);
let pipeline = RulePipeline::builder()
.with_source(Box::new(source))
.with_filter(HostFilter::for_host("web01.example.com"))
.with_cache(IndexedCache::new())
.build();
}
Sources
The RuleSource trait abstracts where rules come from:
#![allow(unused)]
fn main() {
pub trait RuleSource {
fn fetch_all(&mut self) -> Result<Vec<HbacRule>, RuleSourceError>;
fn fetch_updated_since(&mut self, timestamp: u64) -> Result<Vec<HbacRule>, RuleSourceError>;
fn is_available(&self) -> bool;
}
}
Two implementations are provided:
MemorySource– holds rules in memory. Useful for testing and bootstrapping.CompositeSource– tries multiple sources in order. If the primary source is unavailable, falls back to secondary sources.
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let mut composite = CompositeSource::new();
composite.add_source(Box::new(primary_ldap_source));
composite.add_source(Box::new(fallback_file_source));
}
For production deployments, implement RuleSource for your LDAP library.
See SSSD Integration for an example.
Filters
The RuleFilter trait determines which rules enter the cache:
#![allow(unused)]
fn main() {
pub trait RuleFilter {
fn should_cache(&self, rule: &HbacRule) -> bool;
}
}
Available filters:
| Filter | Purpose |
|---|---|
HostFilter | Keep rules whose host dimension matches this host or its groups |
ApplicabilityFilter | Keep only global rules (global_only) or all enabled rules (all_enabled) |
AcceptAllFilter | Keep everything (no filtering) |
AndFilter | Require all child filters to accept |
OrFilter | Accept if any child filter accepts |
HostFilter is the most impactful: on a host that only matches 100 out of
5,000 rules, it reduces memory usage by 98%.
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let filter = HostFilter::for_host("web01.example.com")
.with_group("web-servers")
.with_group("dmz-hosts");
}
Compose filters for more precise control:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let filter = AndFilter::new()
.with(Box::new(
HostFilter::for_host("web01.example.com")
.with_group("web-servers")
))
.with(Box::new(ApplicabilityFilter::all_enabled()));
}
Cache and indexes
IndexedCache stores rules in memory with multi-dimensional indexes for
fast lookup:
#![allow(unused)]
fn main() {
use hbac_rs::cache::IndexedCache;
let mut cache = IndexedCache::new();
// Find rules matching a specific user
let user_rules = cache.find_by_user("alice", &["developers".into()]);
// Find rules matching a specific host
let host_rules = cache.find_by_host("web01", &["web-servers".into()]);
// Combined: narrow candidates by both dimensions
let candidates = cache.find_by_user_and_host(
"alice", &["developers".into()],
"web01", &["web-servers".into()],
);
}
For disk persistence (requires the serde feature):
#![allow(unused)]
fn main() {
use hbac_rs::cache::DiskCache;
let mut cache = DiskCache::new("/var/cache/hbac/rules.json")?;
// ... use as any other RuleCache ...
cache.flush()?; // persist to disk
}
Loading and refreshing
Load rules manually or let the pipeline fetch from its source:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let mut pipeline = RulePipeline::builder()
.with_source(Box::new(source))
.with_filter(HostFilter::for_host("web01"))
.with_cache(IndexedCache::new())
.build();
// Full refresh from source
let count = pipeline.refresh()?;
// Incremental update (only fetch rules changed since last refresh)
let updated = pipeline.incremental_update()?;
// Or load directly without a source
pipeline.load(rules);
}
Monitoring
CacheStats tracks cache health:
#![allow(unused)]
fn main() {
let stats = pipeline.stats();
println!(
"Rules: {}, Memory: {} bytes, Hit rate: {:.1}%",
stats.rule_count,
stats.memory_bytes,
stats.hit_rate() * 100.0,
);
}
Fields:
| Field | Description |
|---|---|
rule_count | Number of rules in the cache |
memory_bytes | Estimated memory consumption |
hit_count | Number of cache hits |
miss_count | Number of cache misses |
The hit_rate() method returns a value between 0.0 and 1.0.
Memory optimization
Host-specific filtering is the single most effective optimization. On a host where only 100 out of 5,000 rules apply, filtering reduces memory from megabytes to tens of kilobytes. This makes hbac-rs practical for resource-constrained environments like embedded Linux systems and containers.
See Performance Results for measured memory usage at different rule counts.
Next steps
The pipeline needs rules from somewhere. In a FreeIPA environment, that somewhere is LDAP, synchronized by SSSD. The next page shows how to integrate with SSSD deployments.
See SSSD Integration.
SSSD Integration
SSSD (System Security Services Daemon) runs on every FreeIPA-enrolled host. It periodically fetches HBAC rules from the FreeIPA LDAP directory and caches them locally for fast evaluation at login time. hbac-rs fits into this architecture as the evaluation engine.
Architecture
graph LR
A[FreeIPA LDAP] -->|periodic sync| B[SSSD<br/>hbac-rs cache]
B -->|fast lookup| C[PAM/NSS]
C -->|auth decision| D[User Login]
The flow:
- SSSD connects to FreeIPA LDAP and fetches HBAC rules.
- Rules are filtered to those applicable to the local host and stored in
an
IndexedCache. - When a user attempts to log in, PAM calls into the evaluation engine.
- The engine returns allow or deny in sub-microsecond time.
Setting up the pipeline
The local host’s identity determines which rules to cache:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let hostname = std::fs::read_to_string("/etc/hostname")
.unwrap()
.trim()
.to_string();
let host_groups = vec!["web-servers".to_string()]; // from SSSD/FreeIPA
let mut pipeline = RulePipeline::builder()
.with_filter(
HostFilter::for_host(&hostname)
.with_groups(host_groups)
)
.with_cache(IndexedCache::new())
.build();
}
Implementing a rule source
hbac-rs does not include an LDAP client. You implement the RuleSource trait
for your preferred LDAP library. Here is an illustrative example:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
use hbac_rs::cache::{RuleSource, RuleSourceError};
struct LdapRuleSource {
connection: LdapConn,
base_dn: String,
}
impl RuleSource for LdapRuleSource {
fn fetch_all(&mut self) -> Result<Vec<HbacRule>, RuleSourceError> {
let filter = "(objectClass=ipahbacrule)";
let results = self.connection
.search(&self.base_dn, Scope::Subtree, filter, vec!["*"])
.map_err(|e| RuleSourceError::ConnectionFailed(e.to_string()))?;
let rules = results
.into_iter()
.filter_map(|entry| parse_ldap_entry_to_hbac_rule(entry))
.collect();
Ok(rules)
}
fn fetch_updated_since(
&mut self,
timestamp: u64,
) -> Result<Vec<HbacRule>, RuleSourceError> {
let since = format_ldap_generalized_time(timestamp);
let filter = format!(
"(&(objectClass=ipahbacrule)(modifyTimestamp>={}))",
since
);
let results = self.connection
.search(&self.base_dn, Scope::Subtree, &filter, vec!["*"])
.map_err(|e| RuleSourceError::ConnectionFailed(e.to_string()))?;
let rules = results
.into_iter()
.filter_map(|entry| parse_ldap_entry_to_hbac_rule(entry))
.collect();
Ok(rules)
}
}
}
The parse_ldap_entry_to_hbac_rule function would map LDAP attributes
(ipaUniqueID, memberUser, memberHost, memberService,
ipaEnabledFlag, accessRuleType) to HbacRule fields.
Periodic refresh
Run incremental updates in a background thread to pick up rule changes without a full reload:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
use std::thread;
use std::time::Duration;
// Initial full load
pipeline.refresh().expect("initial HBAC sync failed");
// Background updates every 5 minutes
thread::spawn(move || {
loop {
thread::sleep(Duration::from_secs(300));
match pipeline.incremental_update() {
Ok(count) => {
log::info!("Updated {} HBAC rules", count);
}
Err(e) => {
log::warn!("HBAC incremental update failed: {}", e);
}
}
}
});
}
incremental_update calls source.fetch_updated_since() with the timestamp
of the last successful refresh, so only changed rules are fetched.
Disk persistence
With the serde feature enabled, DiskCache persists rules to disk so the
host can evaluate access immediately after a restart, even before LDAP is
reachable:
#![allow(unused)]
fn main() {
use hbac_rs::cache::DiskCache;
// On startup: try loading from disk first
let mut disk_cache = DiskCache::new("/var/cache/hbac/rules.json")?;
let cached_rules: Vec<HbacRule> = disk_cache
.get_all()
.into_iter()
.cloned()
.collect();
if !cached_rules.is_empty() {
pipeline.load(cached_rules);
log::info!("Loaded HBAC rules from disk cache");
} else {
pipeline.refresh()?;
log::info!("No disk cache, performed full LDAP sync");
}
// After each refresh: persist to disk
disk_cache.store(pipeline.rules().iter().cloned().collect());
disk_cache.flush()?;
}
Evaluating access
When PAM or your application needs to check access:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
fn check_user_access(
policy: &HbacPolicy,
username: &str,
groups: &[String],
hostname: &str,
host_groups: &[String],
service: &str,
) -> bool {
let mut user = Subject::new(username);
user.roles.extend(groups.iter().cloned());
let request = HbacRequest::builder()
.user(user)
.targethost(hostname)
.targethost_group(host_groups[0].clone())
.service(service)
.build()
.unwrap();
policy.check_access(&request)
}
}
Use check_access for the fastest path when you only need a boolean result.
Monitoring
Track cache health to detect synchronization problems early:
#![allow(unused)]
fn main() {
let stats = pipeline.stats();
log::info!(
"HBAC cache: {} rules, {} bytes, {:.1}% hit rate",
stats.rule_count,
stats.memory_bytes,
stats.hit_rate() * 100.0,
);
}
A sustained 0% hit rate or a drop in rule count may indicate a broken LDAP connection or a misconfigured host filter.
Composite sources
For resilience, use CompositeSource to fall back from LDAP to a local file:
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let mut source = CompositeSource::new();
source.add_source(Box::new(ldap_source));
source.add_source(Box::new(file_source));
let pipeline = RulePipeline::builder()
.with_source(Box::new(source))
.with_filter(HostFilter::for_host(&hostname))
.with_cache(IndexedCache::new())
.build();
}
If the LDAP source returns Err or is_available() returns false, the
pipeline tries the next source.
Next steps
Some SSSD deployments need maintenance windows – time-limited rules that grant access during scheduled operations and auto-expire afterward. The next page covers temporal HBAC rules.
See Temporal Rules.
Temporal HBAC Rules
A contractor needs SSH access to development servers for three months. The ops team needs access to all hosts during a four-hour maintenance window on Saturday night. An on-call engineer needs emergency access for 24 hours after an incident.
TemporalHbacRule wraps a standard HBAC rule with a validity window. The
rule participates in evaluation only when the current time falls within that
window.
Creating a temporal rule
A TemporalHbacRule takes an HbacRule and optional start/end timestamps
(milliseconds since the Unix epoch):
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let now = current_timestamp_millis();
let three_months = 90 * 24 * 60 * 60 * 1000_u64;
let rule = HbacRule::builder("contractor_access")
.user("contractor-jane")
.host_group("dev-servers")
.service_category_all()
.enabled(true)
.build()
.unwrap();
let temporal = TemporalHbacRule::new(
rule,
Some(now), // active immediately
Some(now + three_months), // expires in 3 months
);
assert!(temporal.is_currently_valid());
}
Convenience constructors
For common patterns, named constructors save boilerplate:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let rule = HbacRule::builder("emergency_admin")
.user("alice")
.host_category_all()
.service_category_all()
.enabled(true)
.build()
.unwrap();
// Valid for the next 24 hours from now
let emergency = TemporalHbacRule::valid_for_duration(
rule,
24 * 60 * 60 * 1000, // 24 hours in milliseconds
);
}
All constructors:
| Constructor | Description |
|---|---|
new(rule, from, until) | Full control over start and end |
valid_for_duration(rule, ms) | Starts now, expires after the given duration |
valid_from(rule, timestamp) | Starts at the given time, never expires |
valid_until(rule, timestamp) | Starts immediately, expires at the given time |
Adding to a policy
Use add_temporal_rule on the policy:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut policy = HbacPolicy::new();
// ... create temporal rule as above ...
policy.add_temporal_rule(temporal);
}
Temporal rules coexist with permanent rules in the same policy.
Evaluating at a specific time
Use evaluate_at to evaluate against a specific timestamp instead of “now”:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let now = current_timestamp_millis();
let request = HbacRequest::new(
Subject::new("contractor-jane"),
"dev-01.example.com",
"sshd",
);
// Evaluate at the current time
let result = policy.evaluate_at(&request, now);
assert!(result.is_allowed());
// Evaluate after the rule has expired
let four_months = 120 * 24 * 60 * 60 * 1000_u64;
let result = policy.evaluate_at(&request, now + four_months);
assert!(result.is_denied());
}
There is also evaluate_detailed_at for debugging.
Checking validity
Query whether a temporal rule is active at a given time:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let now = current_timestamp_millis();
// Is the rule active right now?
assert!(temporal.is_currently_valid());
// Is it active at a specific time?
assert!(temporal.is_valid_at(now + 1000));
// Get the underlying HbacRule only if currently valid
if let Some(rule) = temporal.current_rule() {
println!("Active rule: {}", rule.name);
}
// Get the rule at a specific time
if let Some(rule) = temporal.rule_at(now + 1000) {
println!("Rule active at time: {}", rule.name);
}
}
Maintenance window example
Grant the ops team access to all hosts for a four-hour maintenance window:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let window_start = 1737338400_000_u64; // 2025-01-20 02:00 UTC
let window_end = 1737352800_000_u64; // 2025-01-20 06:00 UTC
let rule = HbacRule::builder("maintenance_window")
.user_group("ops-team")
.host_category_all()
.service_category_all()
.enabled(true)
.build()
.unwrap();
let temporal = TemporalHbacRule::new(rule, Some(window_start), Some(window_end));
let mut policy = HbacPolicy::new();
policy.add_temporal_rule(temporal);
let ops_user = Subject::new("ops-alice");
let request = HbacRequest::new(ops_user, "prod-db-01.example.com", "sshd");
// During the window: allowed
let mid_window = window_start + 2 * 60 * 60 * 1000;
assert!(policy.evaluate_at(&request, mid_window).is_allowed());
// After the window: denied
assert!(policy.evaluate_at(&request, window_end + 1).is_denied());
}
Emergency access example
Grant immediate access that auto-expires after 24 hours:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let rule = HbacRule::builder("incident_response")
.user("oncall-bob")
.host_category_all()
.service_category_all()
.enabled(true)
.build()
.unwrap();
let temporal = TemporalHbacRule::valid_for_duration(
rule,
24 * 60 * 60 * 1000, // 24 hours
);
policy.add_temporal_rule(temporal);
}
Recurring schedules
TemporalHbacRule models a single time window. Recurring schedules (e.g.,
“business hours only, Monday through Friday”) are not built in. To implement
recurring access, manage the temporal rules yourself:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
fn create_business_hours_rule(date_start_ms: u64, date_end_ms: u64) -> TemporalHbacRule {
let rule = HbacRule::builder("business_hours")
.user_category_all()
.host_category_all()
.service("internal-app")
.enabled(true)
.build()
.unwrap();
TemporalHbacRule::new(rule, Some(date_start_ms), Some(date_end_ms))
}
// Generate rules for each business day and add them to the policy
}
Best practices
- Always set an expiration. Open-ended temporary access defeats the purpose.
- Use UTC timestamps internally. Convert to local time only for display.
- Build in a small grace period to account for clock skew between hosts.
- Log temporal grants and expirations for your audit trail.
- Periodically remove expired temporal rules to keep the policy lean.
Next steps
Your organization now has HBAC for host access and RBAC for application permissions. The final HBAC chapter shows how to combine them into a unified policy where both must agree before granting access.
See Policy Composition.
Policy Composition
Your platform uses HBAC to control which hosts users can access and RBAC to
control what actions they can perform within applications. These are separate
concerns, but they need to work together: a developer should only deploy to
production if HBAC allows them on the host and their RBAC role grants the
deploy:production permission.
ComposedPolicy unifies HBAC and RBAC evaluation into a single decision.
Creating a composed policy
ComposedPolicy::new takes an HbacPolicy, an RbacPolicy, and a
CompositionMode that determines how the two results are combined:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
use acls_rs::prelude::*;
// HBAC: who can access which hosts
let mut hbac = HbacPolicy::new();
let ssh_rule = HbacRule::builder("dev_ssh")
.user_group("developers")
.host_group("dev-servers")
.service("sshd")
.enabled(true)
.build()
.unwrap();
hbac.add_rule(ssh_rule);
// RBAC: what actions users can perform
let mut rbac = RbacPolicy::new();
let dev_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("code", "read"),
AtomicPermission::new("code", "write"),
]),
PermissionSet::new(),
);
rbac.add_role(Role::new("developer", dev_perms));
// Compose: both must agree
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::And);
}
Composition modes
| Mode | Behavior |
|---|---|
And | Both HBAC and RBAC must allow. Use for defense in depth. |
Or | Either policy allowing is sufficient. Use for convenience-first environments. |
HbacFirst | HBAC is evaluated first. If HBAC denies, RBAC is not checked. |
RbacFirst | RBAC is evaluated first. If RBAC denies, HBAC is not checked. |
And mode
The strictest mode. The user must be allowed by HBAC (correct host + service) and their RBAC roles must grant the required permissions:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::And);
let mut dev = Subject::new("alice");
dev.roles.push("developers".to_string());
let request = HbacRequest::new(dev, "dev-01.example.com", "sshd");
// Allowed only if both HBAC and RBAC agree
let result = policy.evaluate(&request);
}
Or mode
The most permissive mode. Access is granted if either policy allows:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::Or);
}
HbacFirst / RbacFirst
Fallback modes that prefer one policy over the other:
HbacFirst: Use HBAC’s decision if any rules matched; otherwise fall back to RBAC. This is useful when host-based access control is the primary gatekeeper:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
// Prefer HBAC rules when they apply, fall back to RBAC permissions
// if no HBAC rules matched
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::HbacFirst);
}
RbacFirst: Use RBAC’s decision if the user has any permissions; otherwise fall back to HBAC. This is useful when role-based permissions are the primary access control:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
// Prefer RBAC permissions when user has any, fall back to HBAC rules
// if user has no effective permissions
let policy = ComposedPolicy::new(hbac, rbac, CompositionMode::RbacFirst);
}
Evaluating
ComposedPolicy provides the same evaluation methods as HbacPolicy:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut dev = Subject::new("alice");
dev.roles.push("developers".to_string());
let request = HbacRequest::new(dev, "dev-01.example.com", "sshd");
// Full result
let result = policy.evaluate(&request);
if result.is_allowed() {
println!("Access granted by: {:?}", result.matched_rules);
}
// Check for degraded decisions (e.g. RBAC role resolution failure)
if result.has_warnings() {
eprintln!("Warning: decision may be incomplete: {:?}", result.warnings);
}
// Fast boolean check
let allowed: bool = policy.check_access(&request);
}
RBAC role resolution failures
If RBAC role resolution fails (circular role dependency, missing role definition, depth limit exceeded), evaluate() returns deny and records the reason in result.warnings. This is a fail-safe: silently falling back to direct-permission evaluation could allow privilege escalation via a crafted role graph.
Always check result.has_warnings() if you need to distinguish a clean deny from one caused by a configuration error:
#![allow(unused)]
fn main() {
let result = policy.evaluate(&request);
if result.is_denied() && result.has_warnings() {
// Denied because RBAC is misconfigured — worth alerting
for w in &result.warnings {
eprintln!("Policy warning: {w}");
}
}
}
Accessing inner policies
You can modify the HBAC or RBAC policy after composition:
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut policy = ComposedPolicy::new(hbac, rbac, CompositionMode::And);
// Add a new HBAC rule
let new_rule = HbacRule::builder("ops_all")
.user_group("ops")
.host_category_all()
.service_category_all()
.enabled(true)
.build()
.unwrap();
policy.hbac_policy_mut().add_rule(new_rule);
// Add a new RBAC role
let ops_perms = GrantDenialPair::new(
PermissionSet::from([AtomicPermission::new("system", "admin")]),
PermissionSet::new(),
);
policy.rbac_policy_mut().add_role(Role::new("ops", ops_perms));
}
Layering HBAC with ABAC manually
ComposedPolicy combines HBAC and RBAC. If you also need ABAC
(context-aware refinement), layer it manually:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use hbac_rs::prelude::*;
use acls_rs::prelude::*;
// Step 1: Check HBAC host access
let request = HbacRequest::new(
Subject::new("alice"),
"prod-01.example.com",
"deploy-service",
);
let hbac_result = hbac_policy.evaluate(&request);
if !hbac_result.is_allowed() {
println!("Denied: no host access");
return;
}
// Step 2: Resolve RBAC permissions
let roles = vec!["developer".to_string()];
let role_perms = rbac_policy.effective_permissions(&roles).unwrap();
// Step 3: Evaluate ABAC context
let mut context: HashMap<String, String> = HashMap::new();
context.insert("network".into(), "corporate".into());
context.insert("time_of_day".into(), "business-hours".into());
let abac_result = abac_policy.evaluate(&context);
let context_perms = abac_result.effective_permissions();
// Step 4: Intersect RBAC and ABAC results
let effective = role_perms.meet(context_perms);
if effective.contains(&AtomicPermission::new("deploy", "production")) {
println!("Allowed: host access + role + context all agree");
}
}
This pattern evaluates coarse-to-fine: HBAC (host-level) first, then RBAC (role-level), then ABAC (context-level). Short-circuiting at any layer avoids unnecessary work.
Choosing a composition strategy
| Scenario | Recommended mode |
|---|---|
| High-security environment | And – defense in depth |
| User convenience is priority | Or – either policy suffices |
| Host access is the bottleneck | HbacFirst – skip RBAC when host is unreachable |
| Most denials are permission-based | RbacFirst – skip HBAC when role lacks permissions |
| Need ABAC too | Manual layering (HBAC + RBAC + ABAC) |
Next steps
Your access control system is complete: permissions, roles, attributes, time windows, host-based access, and policy composition. Before deploying to production, validate that performance meets your SLA under realistic load.
See Performance Testing Framework.
abac-rs: Attribute-Based Access Control
You’ve outgrown the three-dimensional user/host/service model. Your access control needs depend on four, five, or ten attributes – tenant isolation, data classification, time-of-day restrictions, IP CIDR ranges, and custom business logic that can’t be expressed in fixed dimensions.
This is Attribute-Based Access Control (ABAC) – a policy model where decisions are based on arbitrary attribute dimensions and pluggable matching strategies. Every dimension can use exact matching, CIDR matching, numeric comparisons, or custom predicates. Rules can require that any subset of dimensions match, and new dimensions can be added without changing code.
abac-rs provides an N-dimensional policy engine with a multi-layer optimization pipeline: constant-result fast path, bitmap deny index, LRU caching, AHash, Bloom filter pre-screening, compiled evaluation (for consistent dimensions), composite indexing, and deny-only indexing (when universal allow rules exist). It integrates with acls-rs for algebraic correctness and works seamlessly with perf-testing for benchmarking.
Adding abac-rs to your project
[dependencies]
abac-rs = "0.1"
acls-rs = "0.1"
With serde support:
[dependencies]
abac-rs = { version = "0.1", features = ["serde"] }
Basic usage
Create a rule that allows engineers to read production databases, then evaluate a request:
#![allow(unused)]
fn main() {
use abac_rs::{AbacPolicy, AbacRule, AbacRequest, AttributeType};
// Create an ABAC rule
let rule = AbacRule::builder("allow_engineers_prod_read")
// User dimension: members of the engineers group
.dimension_values("user", vec![
AttributeType::String("group:engineers".into()),
])
// Resource dimension: production databases
.dimension_values("resource", vec![
AttributeType::String("prod:db-01".into()),
AttributeType::String("prod:db-02".into()),
])
// Action dimension: read-only access
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
// Build a policy and load the rule
let mut policy = AbacPolicy::new();
policy.add_rule(rule);
// Evaluate an access request
let mut request = AbacRequest::new();
request.add_attribute(
"user",
AttributeType::String("alice".into()),
vec![AttributeType::String("group:engineers".into())],
);
request.add_attribute(
"resource",
AttributeType::String("prod:db-01".into()),
vec![],
);
request.add_attribute(
"action",
AttributeType::String("read".into()),
vec![],
);
let decision = policy.evaluate(&request);
assert!(decision.is_allowed());
}
Unlimited dimensions
Unlike hbac-rs’s fixed user/host/service model, abac-rs supports arbitrary dimensions. Each rule specifies matching criteria on whichever dimensions are relevant:
#![allow(unused)]
fn main() {
// Cloud IAM: four dimensions
let rule = AbacRule::builder("cloud_iam_policy")
.dimension_values("principal", vec![/* ... */])
.dimension_values("resource", vec![/* ... */])
.dimension_values("action", vec![/* ... */])
.dimension_values("source_ip", vec![/* ... */])
.enabled(true)
.build();
// Healthcare: five dimensions
let rule = AbacRule::builder("healthcare_policy")
.dimension_values("role", vec![/* ... */])
.dimension_values("patient_age", vec![/* ... */])
.dimension_values("data_type", vec![/* ... */])
.dimension_values("purpose", vec![/* ... */])
.dimension_values("time_of_day", vec![/* ... */])
.enabled(true)
.build();
// Multi-tenancy: ten+ dimensions
let rule = AbacRule::builder("multitenant_policy")
.dimension_values("user", vec![/* ... */])
.dimension_values("tenant", vec![/* ... */])
.dimension_values("environment", vec![/* ... */])
// ... add as many as needed
.enabled(true)
.build();
}
A rule matches a request only when all of its dimensions match.
Multi-type attributes
Attributes aren’t limited to strings. abac-rs supports six built-in types plus custom extensions:
#![allow(unused)]
fn main() {
use abac_rs::AttributeType;
use std::net::IpAddr;
// String (most common)
let user = AttributeType::String("alice".into());
// Integer
let age = AttributeType::Integer(25);
// Float
let risk_score = AttributeType::Float(0.85);
// IP address
let ip = AttributeType::IpAddr("10.1.2.3".parse().unwrap());
// IP CIDR range
let network = AttributeType::IpCidr("10.0.0.0/8".parse().unwrap());
// Custom types via trait
let custom = AttributeType::Custom(Arc::new(MyCustomType { /* ... */ }));
}
Wildcard matching
Dimensions can use AttributeValue::All to match any value:
#![allow(unused)]
fn main() {
// Allow any user to read public data
let rule = AbacRule::builder("public_read")
.dimension_all("user")
.dimension_values("resource", vec![
AttributeType::String("public:data".into()),
])
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
}
Group membership
Requests can specify group memberships for each dimension. The policy checks both the primary value and all groups:
#![allow(unused)]
fn main() {
request.add_attribute(
"user",
AttributeType::String("alice".into()), // primary value
vec![
AttributeType::String("group:admins".into()), // groups
AttributeType::String("group:developers".into()),
],
);
}
A rule that requires group:admins will match this request even though the
primary user is alice.
Deny-override semantics
Deny rules take precedence over allow rules. The policy evaluates in this order:
- Check all deny rules – if any match, return
Decision::Deny. - Check all allow rules – if any match, return
Decision::Allow. - Default to
Decision::Denyif no rules match.
#![allow(unused)]
fn main() {
let allow_rule = AbacRule::builder("allow_read")
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
let deny_rule = AbacRule::builder("deny_guests")
.deny()
.dimension_values("user", vec![
AttributeType::String("group:guests".into()),
])
.enabled(true)
.build();
// If a request matches both rules, deny wins
}
Optimization pipeline
abac-rs uses a multi-layer optimization pipeline:
graph TD
A[Request] --> B{Layer 0<br/>Constant result?}
B -->|Yes| C[Return ~15 ns]
B -->|No| D{Layer 1<br/>Bitmap deny index?}
D -->|Yes| D2[u64 AND intersection]
D -->|No| E{Layer 2<br/>LRU cache hit?}
D2 --> RET[Return result]
E -->|Yes| F[Return sub-μs]
E -->|No| G{Layer 3<br/>Bloom filter reject?}
G -->|Yes| H[Return Deny ~50 ns]
G -->|No| I[Layer 4<br/>Compiled evaluator]
I --> I2[Pre-extracted attrs]
I2 --> J{Layer 5<br/>Composite index}
J --> K[Match O candidates]
Through systematic optimization, abac-rs achieves high throughput across all scales (x86_64, cached, single-threaded):
| Rules | Throughput | Mean Latency | Memory |
|---|---|---|---|
| 100 | 1.50M r/s | 668 ns | 7.8 KB |
| 1,000 | 4.76M r/s | 210 ns | 78 KB |
| 10,000 | 2.92M r/s | 343 ns | 781 KB |
| 100,000 | 1.19M r/s | 843 ns | 7.6 MB |
| 1,000,000 | 156K r/s | 6.43 us | 76 MB |
HBAC is faster or equal to ABAC at all scales on x86_64 due to bitmap deny index optimization and memory-optimized rule storage in HBAC. ABAC’s advantage is N-dimensional flexibility and 3.2x less memory (e.g., 76 MB vs 241 MB at 1M rules), not throughput. Choose abac-rs when you need more than three dimensions, custom attribute types, or lower memory usage.
See Performance Results for detailed benchmarks and Cross-BAC Benchmarking for direct comparison.
Pluggable matchers
For dimensions that need custom matching logic beyond exact equality, implement
the Matcher trait:
#![allow(unused)]
fn main() {
use abac_rs::Matcher;
pub struct IpCidrMatcher;
impl Matcher for IpCidrMatcher {
fn matches(
&self,
rule_value: &AttributeValue,
request_value: &AttributeType,
request_groups: &[AttributeType],
) -> bool {
match (rule_value, request_value) {
(AttributeValue::Specific(cidrs), AttributeType::IpAddr(ip)) => {
// Check if IP is in any allowed CIDR
cidrs.iter().any(|cidr| {
if let AttributeType::IpCidr(network) = cidr {
network.contains(*ip)
} else {
false
}
})
}
(AttributeValue::All, _) => true,
_ => false,
}
}
}
}
See Custom Matchers for complete examples.
What to read next
- ABAC Rules – creating rules with multiple dimensions.
- Policy Evaluation – understanding the optimization pipeline.
- Multi-type Attributes – using Integer, IpAddr, and custom types.
- Custom Matchers – implementing CIDR matching, numeric ranges, and predicates.
- Performance Results – benchmarks and scaling characteristics.
ABAC Rules
ABAC rules define access policies across arbitrary attribute dimensions. Each rule specifies matching criteria on one or more dimensions, a rule type (Allow/Deny), and an enabled status.
Creating rules
#![allow(unused)]
fn main() {
use abac_rs::{AbacRule, AttributeType};
let rule = AbacRule::builder("allow_engineers_prod_read")
// User must be in engineers group
.dimension_values("user", vec![
AttributeType::String("group:engineers".into()),
])
// Resource must be a production database
.dimension_values("resource", vec![
AttributeType::String("prod:db-01".into()),
AttributeType::String("prod:db-02".into()),
])
// Action must be read
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
}
Rule types
- Allow (default) – grants access if all dimensions match
- Deny – blocks access if all dimensions match (takes precedence)
#![allow(unused)]
fn main() {
// Allow rule (default)
let rule = AbacRule::builder("allow_rule")
// ... dimensions ...
.enabled(true)
.build();
// Deny rule
let rule = AbacRule::builder("deny_rule")
.deny()
// ... dimensions ...
.enabled(true)
.build();
}
Wildcard matching
Use AttributeValue::All to match any value for a dimension:
#![allow(unused)]
fn main() {
// Allow any user to read public data
let rule = AbacRule::builder("public_read")
.dimension_all("user")
.dimension_values("resource", vec![
AttributeType::String("public:data".into()),
])
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
}
#![allow(unused)]
fn main() {
// Any user can read public data
let rule = AbacRule::builder("public_read")
.dimension_all("user")
.dimension_values("resource", vec![
AttributeType::String("public:data".into()),
])
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
}
Multi-dimensional matching
A rule matches a request only when all dimensions match. This is an AND relationship – every dimension requirement must be satisfied.
See also
Policy Evaluation
The ABAC policy engine evaluates access requests through a multi-layer optimization pipeline. Each layer short-circuits on success, so most requests never reach the sequential evaluation fallback.
Basic evaluation
#![allow(unused)]
fn main() {
use abac_rs::{AbacPolicy, AbacRequest, AttributeType};
let mut policy = AbacPolicy::new();
// ... add rules ...
let mut request = AbacRequest::new();
request.add_attribute("user", AttributeType::String("alice".into()), vec![]);
request.add_attribute("resource", AttributeType::String("db-01".into()), vec![]);
request.add_attribute("action", AttributeType::String("read".into()), vec![]);
let decision = policy.evaluate(&request);
if decision.is_allowed() {
// Grant access
}
}
Optimization pipeline
- Layer 0: Constant result (~15 ns) – universal allow/deny detection
- Layer 1: Bitmap deny index – u64 bitmask intersection when universal allow exists
- Layer 2: LRU cache (sub-μs) – memoization of recent evaluations
- Layer 3: AHash – 2-3x faster than SipHash for non-cryptographic use
- Layer 4: Compiled evaluator – pre-extracted attributes + array indexing
- Layer 5: Composite index (O(log n)) – candidate selection fallback
- Layer 6: Deny-only indexing – skip allow rules when universal allow exists
The compiled evaluator (Layer 4) is automatically detected and applied when all rules use consistent dimensions. It pre-extracts request attributes once (3 HashMap lookups) instead of per-candidate (600+ lookups), providing a 62x speedup at large scales.
Deny-only indexing: When a universal allow rule exists, only deny rules are indexed. This is valid because:
- Deny rules are checked first (deny-override semantics)
- If no deny matches, universal allow guarantees Allow decision
- Individual allow rules become irrelevant
This optimization reduces index size and evaluation time dramatically in typical deployments where most rules are allow and only a few are deny exceptions.
See ABAC Performance for detailed layer breakdown.
Lazy index building
Indexes are built on first evaluation after rule changes, not during
add_rule(). This eliminates O(n²) overhead during batch rule loading.
Deny-override semantics
- Check all deny rules – if any match, return
Deny - Check all allow rules – if any match, return
Allow - Default to
Denyif no rules match
Denials always take precedence over allows.
See also
Multi-type Attributes
ABAC attributes support six built-in types plus custom extensions. This enables type-safe matching beyond string equality – IP CIDR ranges, numeric comparisons, and domain-specific predicates.
Built-in types
#![allow(unused)]
fn main() {
use abac_rs::AttributeType;
use std::net::IpAddr;
use ipnetwork::IpNetwork;
// String (most common)
let user = AttributeType::String("alice".into());
// Integer
let age = AttributeType::Integer(25);
// Float
let risk_score = AttributeType::Float(0.85);
// IP address
let ip = AttributeType::IpAddr("10.1.2.3".parse().unwrap());
// IP CIDR range
let network = AttributeType::IpCidr("10.0.0.0/8".parse().unwrap());
}
Custom types
Implement AttributeTypeTrait for domain-specific types:
#![allow(unused)]
fn main() {
use abac_rs::AttributeTypeTrait;
use std::any::Any;
use std::sync::Arc;
struct TimeOfDay {
hour: u8,
minute: u8,
}
impl AttributeTypeTrait for TimeOfDay {
fn as_any(&self) -> &dyn Any { self }
fn eq(&self, other: &dyn AttributeTypeTrait) -> bool {
other.as_any().downcast_ref::<TimeOfDay>()
.map_or(false, |t| self.hour == t.hour && self.minute == t.minute)
}
fn hash(&self) -> u64 {
(self.hour as u64) << 8 | (self.minute as u64)
}
fn clone_trait(&self) -> Arc<dyn AttributeTypeTrait> {
Arc::new(TimeOfDay { hour: self.hour, minute: self.minute })
}
}
// Use in rules
let time = AttributeType::Custom(Arc::new(TimeOfDay { hour: 14, minute: 30 }));
}
Type matching
Attributes match only when types are compatible:
StringmatchesStringIntegermatchesIntegerIpAddrmatchesIpAddr(orIpCidrwith custom matcher)- Custom types match via
AttributeTypeTrait::eq()
Cross-type matching requires custom matchers.
See also
Custom Matchers
The default ExactMatcher uses HashSet membership for O(1) exact equality
checks. For dimensions that need custom logic – CIDR matching, numeric ranges,
time-of-day restrictions – implement the Matcher trait.
The Matcher trait
#![allow(unused)]
fn main() {
use abac_rs::{Matcher, AttributeValue, AttributeType};
pub trait Matcher: Send + Sync {
fn matches(
&self,
rule_value: &AttributeValue,
request_value: &AttributeType,
request_groups: &[AttributeType],
) -> bool;
fn supports_bloom_filter(&self) -> bool { true }
}
}
IP CIDR matching
Match IP addresses against CIDR ranges:
#![allow(unused)]
fn main() {
use abac_rs::{Matcher, AttributeValue, AttributeType};
pub struct IpCidrMatcher;
impl Matcher for IpCidrMatcher {
fn matches(
&self,
rule_value: &AttributeValue,
request_value: &AttributeType,
_groups: &[AttributeType],
) -> bool {
match (rule_value, request_value) {
(AttributeValue::All, _) => true,
(AttributeValue::Specific(cidrs), AttributeType::IpAddr(ip)) => {
cidrs.iter().any(|cidr| {
if let AttributeType::IpCidr(network) = cidr {
network.contains(*ip)
} else {
false
}
})
}
_ => false,
}
}
}
}
Usage:
#![allow(unused)]
fn main() {
use ipnetwork::IpNetwork;
// Rule: allow from corporate network
let rule = AbacRule::builder("allow_corp_network")
.dimension_values("source_ip", vec![
AttributeType::IpCidr("10.0.0.0/8".parse::<IpNetwork>().unwrap()),
])
.enabled(true)
.build();
// Request with IP address
let mut request = AbacRequest::new();
request.add_attribute(
"source_ip",
AttributeType::IpAddr("10.1.2.3".parse().unwrap()),
vec![]
);
}
Numeric range matching
Match integers within a range:
#![allow(unused)]
fn main() {
pub struct NumericRangeMatcher;
impl Matcher for NumericRangeMatcher {
fn matches(
&self,
rule_value: &AttributeValue,
request_value: &AttributeType,
_groups: &[AttributeType],
) -> bool {
match (rule_value, request_value) {
(AttributeValue::All, _) => true,
(AttributeValue::Specific(ranges), AttributeType::Integer(value)) => {
ranges.iter().any(|range| {
if let AttributeType::Integer(min) = range {
*value >= *min // Simplified: actual range needs min+max
} else {
false
}
})
}
_ => false,
}
}
fn supports_bloom_filter(&self) -> bool { false }
}
}
Performance considerations
Custom matchers:
- Bypass Bloom filter optimization (return
falsefromsupports_bloom_filter) - Still benefit from LRU cache and composite index
- Evaluated sequentially for candidate rules
For best performance, use ExactMatcher when possible.
See also
Policy Composition
Your platform uses ABAC to control access based on multiple attributes (user groups, resource types, actions, time windows, IP ranges) and RBAC to manage role-based permissions. These complement each other: ABAC provides fine-grained, multi-dimensional control while RBAC provides role hierarchy and simple permission management.
ComposedPolicy unifies ABAC and RBAC evaluation into a single decision with configurable composition modes.
Creating a composed policy
ComposedPolicy::new takes an AbacPolicy, an RbacPolicy, and a CompositionMode:
#![allow(unused)]
fn main() {
use abac_rs::{AbacPolicy, AbacRule, AbacRequest, AttributeType};
use abac_rs::{ComposedPolicy, CompositionMode};
use acls_rs::policy::RbacPolicy;
use acls_rs::permission::{AtomicPermission, PermissionSet, GrantDenialPair};
use acls_rs::policy::Role;
use acls_rs::Subject;
// ABAC: attribute-based rules
let mut abac = AbacPolicy::new();
let rule = AbacRule::builder("allow_engineers")
.dimension_values("user", vec![
AttributeType::String("group:engineers".into()),
])
.dimension_values("resource", vec![
AttributeType::String("prod:database".into()),
])
.dimension_values("action", vec![
AttributeType::String("read".into()),
])
.enabled(true)
.build();
abac.add_rule(rule).unwrap();
// RBAC: role-based permissions
let mut rbac = RbacPolicy::new();
let engineer_perms = GrantDenialPair::new(
PermissionSet::from([
AtomicPermission::new("database", "read"),
AtomicPermission::new("database", "write"),
]),
PermissionSet::new(),
);
rbac.add_role(Role::new("engineer", engineer_perms));
// Compose: both must agree
let mut policy = ComposedPolicy::new(abac, rbac, CompositionMode::And);
}
Composition modes
| Mode | Behavior |
|---|---|
And | Both ABAC and RBAC must allow. Use for defense in depth. |
Or | Either policy allowing is sufficient. Use for convenience-first environments. |
AbacFirst | ABAC is evaluated first. If ABAC has matching rules, use that decision. Otherwise fall back to RBAC. |
RbacFirst | RBAC is evaluated first. If RBAC has applicable permissions, use that decision. Otherwise fall back to ABAC. |
And mode
The strictest mode. The user must satisfy ABAC attribute requirements and their RBAC roles must grant the required permission:
#![allow(unused)]
fn main() {
use abac_rs::{ComposedPolicy, CompositionMode};
let mut policy = ComposedPolicy::new(abac, rbac, CompositionMode::And);
let mut user = Subject::new("alice");
user.roles.push("engineer".to_string());
let mut request = AbacRequest::new();
request.add_attribute("user", AttributeType::String("alice".into()),
vec![AttributeType::String("group:engineers".into())]);
request.add_attribute("resource", AttributeType::String("prod:database".into()), vec![]);
request.add_attribute("action", AttributeType::String("read".into()), vec![]);
// Allowed only if both ABAC and RBAC agree
let result = policy.evaluate(&mut request, &user);
}
Or mode
The most permissive mode. Access is granted if either policy allows:
#![allow(unused)]
fn main() {
let mut policy = ComposedPolicy::new(abac, rbac, CompositionMode::Or);
// Access granted if ABAC OR RBAC allows
let result = policy.evaluate(&mut request, &user);
}
This is useful when you want to grant access through either fine-grained ABAC rules or broad RBAC roles.
AbacFirst mode
ABAC takes precedence. If the ABAC policy has enabled rules for the request dimensions, use that decision. Otherwise fall back to RBAC:
#![allow(unused)]
fn main() {
let mut policy = ComposedPolicy::new(abac, rbac, CompositionMode::AbacFirst);
// If ABAC has matching rules, use ABAC decision
// If ABAC has no matching rules, fall back to RBAC
let result = policy.evaluate(&mut request, &user);
}
This is useful when you want fine-grained control via ABAC where rules exist, but default RBAC permissions elsewhere.
RbacFirst mode
RBAC takes precedence. If the user has roles with relevant permissions, use that decision. Otherwise fall back to ABAC:
#![allow(unused)]
fn main() {
let mut policy = ComposedPolicy::new(abac, rbac, CompositionMode::RbacFirst);
// If user has roles, use RBAC decision
// Otherwise fall back to ABAC rules
let result = policy.evaluate(&mut request, &user);
}
Thread safety
ComposedPolicy comes in two variants:
ComposedPolicy- Thread-safe (usesMutexfor caches), can be shared across threadsComposedPolicyLocal- Single-threaded (usesRefCellfor caches), faster but notSend + Sync
Both use the generic ComposedPolicyCore<A, P> type where:
A: CacheLock- Lock type for ABAC evaluation cacheP: PermissionCacheLock- Lock type for RBAC permission cache
#![allow(unused)]
fn main() {
use abac_rs::{ComposedPolicy, ComposedPolicyLocal, CompositionMode};
// Thread-safe variant (Mutex-based)
let policy = ComposedPolicy::new(abac, rbac, CompositionMode::And);
// Single-threaded variant (RefCell-based)
let policy_local = ComposedPolicyLocal::new(abac_local, rbac, CompositionMode::And);
}
RBAC permission mapping
For RBAC evaluation, ComposedPolicy extracts resource and action attributes from the ABAC request:
resourceattribute → permission namespaceactionattribute → permission action
For example, a request with resource="database" and action="read" maps to checking the database:read permission in RBAC.
If these attributes are missing from the request, RBAC evaluation is skipped and only the ABAC result is used.
Performance
ComposedPolicy maintains two caches:
- ABAC evaluation cache - LRU cache of request → decision mappings
- RBAC permission cache - LRU cache of role sets → resolved permissions
You can configure the permission cache size:
#![allow(unused)]
fn main() {
let policy = ComposedPolicy::with_cache_size(
abac,
rbac,
CompositionMode::And,
200, // cache up to 200 unique role combinations
);
}
Call invalidate_permission_cache() when the RBAC policy or role hierarchy changes:
#![allow(unused)]
fn main() {
// Modify RBAC policy
policy.rbac_policy_mut().add_role(new_role);
// Cache was automatically invalidated by rbac_policy_mut()
}
See also
- RBAC Policies - Role-based access control
- ABAC Rules - Attribute-based rules
- Policy Evaluation - ABAC evaluation pipeline
- Python Bindings - Using ComposedPolicy from Python
posix-acls: POSIX ACL Modeling
Your team shares a Linux server. Source code, build artifacts, and
documentation all live on the filesystem, and different people need different
levels of access. Traditional Unix permissions (chmod 750) give you three
slots – owner, group, everyone else – but you need to give one contractor
read access and one DBA read-write access without touching the group
membership of every file. That is the problem POSIX ACLs (POSIX.1e) solve.
posix-acls provides Rust types that model the full POSIX.1e ACL system – from
individual rwx permission triples up through complete ACLs with named user
and group entries, default ACLs for directories, mask computation, and the
access-check algorithm. On top of that core model, a higher-level policy
layer lets you declare permissions against a user/group directory and derive
concrete ACLs automatically.
Two layers
The crate is organised into two layers, each building on the previous:
-
Typed POSIX ACL model – the
rwxpermission triple (PosixPerm) with lattice algebra, the six ACL tag types (AclTag), ACL entries (AclEntry), and the complete ACL type (PosixAcl) with fluent construction (AclBuilder),getfacl(1)parsing and generation, Unix mode word conversion, and the POSIX.1e access-check algorithm. -
User/group model and policy – an abstract directory of users and groups (
UserGroupModel) with a policy system (AclPolicy) that assigns permissions to principals and derives validatedPosixAclvalues. The model ensures that every named grant references a real user or group.
An acls-rs bridge connects both layers to the algebraic permission system in acls-rs, so you can mix POSIX file permissions with application-level access control.
Quick start
Add posix-acls to your Cargo.toml:
[dependencies]
posix-acls = "0.1"
With serde support:
[dependencies]
posix-acls = { version = "0.1", features = ["serde"] }
A minimal example that builds an ACL, serialises it to getfacl text, and
checks access:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
// Build an ACL for a shared project directory
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.build();
// Serialise to the getfacl(1) text format
let text = generate::<GetfaclGenerator>(&acl).unwrap();
assert!(text.contains("user:bob:rw-"));
// Parse it back
let recovered = parse::<GetfaclParser>(&text).unwrap();
assert_eq!(acl.owner(), recovered.owner());
// Check access -- bob can read, but not execute
let r = acl.check_access("bob", &["staff"] as &[&str], PosixPerm::R);
assert!(r.granted);
let r = acl.check_access("bob", &["staff"] as &[&str], PosixPerm::X);
assert!(!r.granted);
}
Policy-driven ACLs
When your organisation has dozens of users across multiple teams, building ACLs by hand is error-prone. The policy layer lets you declare intent and validate it against a user/group directory:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
// Define the organisation's users and groups
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_user(UserEntry::new("bob").with_groups(["devs"]));
model.add_user(UserEntry::new("carol").with_groups(["ops"]));
model.add_group(GroupEntry::new("devs"));
model.add_group(GroupEntry::new("ops"));
// Declare a policy for the project directory
let policy = AclPolicy::builder()
.owner("alice")
.owning_group("devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.add_group("ops", PosixPerm::R)
.build()
.unwrap();
// Generate a concrete ACL -- the model validates that "bob" and "ops" exist
let acl = policy.generate(&model).unwrap();
assert!(acl.is_valid().is_ok());
assert!(acl.is_extended());
}
Mode word interoperability
POSIX ACLs and traditional Unix mode words (chmod 755) are two views of the
same permission model. posix-acls converts between them losslessly for
minimal (non-extended) ACLs:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
// Build from a mode word
let acl = PosixAcl::from_mode("alice", "devs", 0o755);
assert_eq!(acl.to_mode(), 0o755);
// Extended ACLs reflect the mask in stat()'s group bits (POSIX.1e section 23.1.6)
let extended = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::R)
.add_user("bob", PosixPerm::RW)
.build();
assert_eq!((extended.to_mode() >> 3) & 0o7, 0o7); // mask = rwx
}
Integration with acls-rs
The bridge module converts between POSIX permission triples and acls-rs
PermissionSet values, so you can combine file-level access control with
application-level policies:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
use acls_rs::permission::AtomicPermission;
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.build();
// Convert Alice's effective permissions to an acls-rs PermissionSet
let set = acl.to_permission_set("alice", &[] as &[&str]);
assert!(set.contains(&AtomicPermission::new("posix", "read")));
assert!(set.contains(&AtomicPermission::new("posix", "write")));
assert!(set.contains(&AtomicPermission::new("posix", "execute")));
// Or as a GrantDenialPair (POSIX ACLs have no explicit denials)
let gdp = acl.to_grant_denial_pair("alice", &[] as &[&str]);
assert!(gdp.denials.is_empty());
}
What to read next
- Permissions – the
rwxpermission triple, special bits, and the lattice algebra that makes composition well-defined. - Building ACLs – tags, entries, the
PosixAcltype, fluent construction withAclBuilder, and structural validation. - Parsing and Generation – reading and writing the
getfacl(1)text format, round-trip guarantees, and extending with custom formats. - Access Checks – the full POSIX.1e access-check algorithm, the role of the mask, and mode word conversion.
- User/Group Model and Policy – declaring users and groups, building policies, deriving validated ACLs, and handling errors.
- acls-rs Bridge – converting between POSIX permissions and acls-rs types for cross-system access control.
- Temporal Access – end-to-end example: time-bounded contractor team grants → group-based POSIX ACLs → per-user effective access → setfacl commands.
Permissions
Your shared project directory needs three kinds of access: reading source files, writing build artifacts, and executing scripts. Every permission decision in POSIX ACLs ultimately reduces to a combination of these three bits. This page covers the types that model them and the algebraic structure that makes combining permissions predictable.
PosixPerm
PosixPerm represents the classic rwx permission triple as a 3-bit
bitmask. Eight named constants cover every combination:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
assert_eq!(PosixPerm::NONE.to_string(), "---");
assert_eq!(PosixPerm::R.to_string(), "r--");
assert_eq!(PosixPerm::RW.to_string(), "rw-");
assert_eq!(PosixPerm::RWX.to_string(), "rwx");
// Individual bit checks
assert!(PosixPerm::RW.read());
assert!(PosixPerm::RW.write());
assert!(!PosixPerm::RW.execute());
}
All eight constants: NONE, X, W, WX, R, RX, RW, RWX.
You can also construct from raw bits – only the low 3 bits are kept:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
let p = PosixPerm::new(0b101);
assert_eq!(p, PosixPerm::RX);
assert_eq!(p.bits(), 5);
}
Containment
contains checks whether one permission set is a superset of another – the
fundamental operation behind access checks:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
assert!(PosixPerm::RWX.contains(PosixPerm::R));
assert!(PosixPerm::RW.contains(PosixPerm::RW));
assert!(!PosixPerm::R.contains(PosixPerm::W));
}
Masking
POSIX.1e defines an effective-rights mask that limits the permissions of
named user and group entries. The mask_with method applies it:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
// Bob has rwx, but the mask is r-- -- effective permissions are r--
assert_eq!(PosixPerm::RWX.mask_with(PosixPerm::R), PosixPerm::R);
// The mask does not restrict the owner or other entries --
// those are applied directly in the access-check algorithm
assert_eq!(PosixPerm::RW.mask_with(PosixPerm::RX), PosixPerm::R);
}
Lattice algebra
PosixPerm implements the algebraic traits from acls_rs::algebra. The
partial order is the subset relation on bits: R and W are incomparable,
but both are less than RW.
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
use acls_rs::algebra::{JoinSemilattice, MeetSemilattice, Monoid};
// Join (OR) is the least upper bound -- the least restrictive merge
assert_eq!(PosixPerm::R.join(PosixPerm::W), PosixPerm::RW);
// Meet (AND) is the greatest lower bound -- the most restrictive merge
assert_eq!(PosixPerm::RW.meet(PosixPerm::RX), PosixPerm::R);
// Identity element is NONE
assert_eq!(PosixPerm::RWX.join(PosixPerm::identity()), PosixPerm::RWX);
}
The partial order:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
assert!(PosixPerm::R < PosixPerm::RW);
assert!(PosixPerm::RWX > PosixPerm::R);
// R and W are incomparable -- neither is a subset of the other
assert_eq!(PosixPerm::R.partial_cmp(&PosixPerm::W), None);
}
These properties – commutativity, idempotence, associativity, and the
absorption law – are verified by property-based tests using proptest.
They guarantee that merging permissions from multiple sources (roles, group
memberships, policy rules) always produces a well-defined result regardless
of evaluation order.
| Trait | Operation | Meaning |
|---|---|---|
Semigroup | combine (OR) | Merge two permission sets |
Monoid | identity (NONE) | Empty permission set |
JoinSemilattice | join (OR) | Least upper bound |
MeetSemilattice | meet (AND) | Greatest lower bound |
BoundedJoinSemilattice | bottom (NONE) | Minimal element |
BoundedMeetSemilattice | top (RWX) | Maximal element |
Parsing and display
PosixPerm implements Display and FromStr for the standard rwx string
format:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
let p: PosixPerm = "rw-".parse().unwrap();
assert_eq!(p, PosixPerm::RW);
// Display produces the same format
assert_eq!(format!("{p}"), "rw-");
}
The parser also handles s, t, S, and T in the execute position (as
produced by some getfacl implementations when SUID/SGID/sticky bits
interact with the execute bit):
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
// Lowercase 's'/'t' means execute IS set (plus the special bit)
let p: PosixPerm = "rws".parse().unwrap();
assert_eq!(p, PosixPerm::RWX);
// Uppercase 'S'/'T' means execute is NOT set (only the special bit)
let p: PosixPerm = "rwS".parse().unwrap();
assert_eq!(p, PosixPerm::RW);
}
SpecialBits
SpecialBits represents the three special Unix permission bits – SUID,
SGID, and sticky – which occupy bits 11-9 of the 12-bit mode word. These
are not part of the ACL entry system but are carried alongside the ACL for
mode word conversion.
#![allow(unused)]
fn main() {
use posix_acls::perm::SpecialBits;
let bits = SpecialBits::SGID;
assert!(bits.sgid());
assert!(!bits.suid());
assert!(!bits.sticky());
// Display uses the getfacl # flags: format
assert_eq!(bits.to_string(), "-s-");
}
SGID on a directory is particularly relevant: it causes new files to inherit the directory’s owning group, which is often combined with default ACLs to enforce consistent group ownership in shared project directories.
Constants: NONE, STICKY, SGID, SUID. Construct from raw bits with
SpecialBits::new(bits).
Next steps
You now know how individual permissions work. The next page introduces ACL
tags and entries – the structures that attach permissions to specific users
and groups – and the AclBuilder for constructing complete ACLs.
See Building ACLs.
Building ACLs
Permissions alone are not useful until they are attached to principals. A
POSIX ACL is a list of entries, each pairing a tag (which principal?) with a
permission triple (what can they do?). This page covers the tag types, entry
structure, the central PosixAcl type, and the fluent AclBuilder.
ACL tags
AclTag identifies which principal an ACL entry applies to. POSIX.1e
defines six tag types:
#![allow(unused)]
fn main() {
use posix_acls::AclTag;
// Three mandatory tags -- every valid ACL has exactly one of each
let owner = AclTag::UserObj; // user::
let group = AclTag::GroupObj; // group::
let other = AclTag::Other; // other::
// Two named tags -- these make the ACL "extended"
let alice = AclTag::User("alice".to_string()); // user:alice:
let devs = AclTag::Group("devs".to_string()); // group:devs:
// The mask tag -- required when named entries are present
let mask = AclTag::Mask; // mask::
assert!(alice.is_named());
assert!(!owner.is_named());
assert!(owner.is_mandatory());
assert!(!mask.is_mandatory());
}
| Tag | getfacl syntax | Required? |
|---|---|---|
UserObj | user::perms | Always |
User(name) | user:name:perms | Makes ACL extended |
GroupObj | group::perms | Always |
Group(name) | group:name:perms | Makes ACL extended |
Mask | mask::perms | Required when extended |
Other | other::perms | Always |
ACL entries
An AclEntry pairs a tag with a PosixPerm:
#![allow(unused)]
fn main() {
use posix_acls::{AclEntry, AclTag, PosixPerm};
let entry = AclEntry::new(
AclTag::User("bob".to_string()),
PosixPerm::RW,
);
assert_eq!(entry.tag, AclTag::User("bob".to_string()));
assert_eq!(entry.perms, PosixPerm::RW);
assert_eq!(entry.to_string(), "user:bob:rw-");
}
Both tag and perms are public fields – you can inspect and modify them
directly.
PosixAcl
PosixAcl is the central type: a complete ACL for a file or directory.
It carries an owner name, an owning group name, optional special bits,
access entries, and optional default entries (for directories).
The constructor creates a minimal ACL with UserObj, GroupObj, and Other
all set to NONE:
#![allow(unused)]
fn main() {
use posix_acls::{PosixAcl, PosixPerm};
let mut acl = PosixAcl::new("alice", "devs");
acl.set_owner_perms(PosixPerm::RWX);
acl.set_group_perms(PosixPerm::RX);
acl.set_other_perms(PosixPerm::R);
assert_eq!(acl.owner(), "alice");
assert_eq!(acl.owning_group(), "devs");
assert!(!acl.is_extended());
assert!(acl.is_valid().is_ok());
}
Adding named entries makes the ACL extended and requires a mask:
#![allow(unused)]
fn main() {
use posix_acls::{PosixAcl, PosixPerm};
let mut acl = PosixAcl::new("alice", "devs");
acl.set_owner_perms(PosixPerm::RWX);
acl.set_group_perms(PosixPerm::RX);
acl.set_other_perms(PosixPerm::NONE);
acl.add_user("bob", PosixPerm::RW);
// The mask must be set explicitly when using PosixAcl directly
let mask = acl.compute_mask();
acl.set_mask(mask);
assert!(acl.is_extended());
assert!(acl.is_valid().is_ok());
}
AclBuilder
For anything beyond a minimal ACL, AclBuilder is the idiomatic way to
construct PosixAcl values. It auto-computes the mask when named entries
are present:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.add_group("ops", PosixPerm::R)
.build();
assert!(acl.is_valid().is_ok());
assert!(acl.is_extended());
// mask = union of named + group entries = RW | R | RX = RWX
assert_eq!(acl.mask(), PosixPerm::RWX);
}
All builder methods consume and return self, so you chain them fluently.
Named-entry methods use upsert semantics – if the same name appears twice,
the last call wins:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.add_user("bob", PosixPerm::R) // overwrites the previous grant
.build();
let bob = acl.entries().iter()
.find(|e| matches!(&e.tag, posix_acls::AclTag::User(n) if n == "bob"))
.unwrap();
assert_eq!(bob.perms, PosixPerm::R);
}
Default ACLs
Directories can carry a default ACL that is inherited by files and subdirectories created within them. The builder supports default entries through a parallel set of methods:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
// Access ACL (applies to the directory itself)
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
// Default ACL (inherited by new files in this directory)
.default_owner_perms(PosixPerm::RWX)
.default_owning_group_perms(PosixPerm::RX)
.default_other_perms(PosixPerm::NONE)
.default_user("bob", PosixPerm::RW)
.default_group("ops", PosixPerm::R)
.build();
assert!(!acl.default_entries().is_empty());
assert!(acl.is_valid().is_ok());
}
Default ACLs follow the same rules as access ACLs – they need their own
mask entry when named entries are present, and AclBuilder auto-computes
it.
Special bits
SUID, SGID, and sticky bits are not ACL entries, but AclBuilder carries
them so that a PosixAcl can represent the full 12-bit mode word:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use posix_acls::perm::SpecialBits;
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::RX)
.special_bits(SpecialBits::SGID)
.build();
assert!(acl.special_bits().sgid());
assert_eq!(acl.to_mode(), 0o2755);
}
Validation
PosixAcl::is_valid() checks structural correctness:
#![allow(unused)]
fn main() {
use posix_acls::{PosixAcl, PosixPerm};
// Missing mask on extended ACL
let mut acl = PosixAcl::new("alice", "devs");
acl.set_owner_perms(PosixPerm::RWX);
acl.set_group_perms(PosixPerm::RX);
acl.set_other_perms(PosixPerm::NONE);
acl.add_user("bob", PosixPerm::RW);
// Forgot to set mask!
let err = acl.is_valid().unwrap_err();
assert!(err.to_string().contains("mask"));
}
Validation errors (PosixAclValidationError):
| Variant | Meaning |
|---|---|
MissingEntry | A required tag (user::, group::, other::) is absent |
DuplicateEntry | The same named entry appears more than once |
MissingMask | Named entries exist but no mask:: entry is present |
SpuriousMask | A mask:: entry exists but there are no named entries |
AclBuilder::build() always produces a valid ACL because it auto-computes
the mask and emits entries in the correct order. You only need is_valid()
when constructing ACLs manually or after parsing untrusted input.
Next steps
You can now build ACLs programmatically. The next page shows how to
serialise them to the getfacl(1) text format and parse them back, with
round-trip guarantees.
Parsing and Generation
Your ACLs need to move between Rust code and the outside world. The most
common external format is the text output of getfacl(1), the standard
POSIX tool for displaying ACLs. posix-acls can parse this format into
PosixAcl values and generate it back, with round-trip fidelity.
The getfacl format
A getfacl text block looks like this:
# owner: alice
# group: devs
# flags: -s-
user::rwx
user:bob:rw-
group::r-x
group:ops:r--
mask::rwx
other::r--
default:user::rwx
default:group::r-x
default:other::r--
Comment lines (#) carry metadata: owner name, owning group, and optional
flags (SUID/SGID/sticky). Entry lines use the tag:qualifier:perms syntax.
Default entries are prefixed with default:.
Parsing
parse::<GetfaclParser>(text) converts a getfacl text block into a
PosixAcl:
#![allow(unused)]
fn main() {
use posix_acls::parser::{parse, GetfaclParser};
let text = "\
owner: alice
group: devs
user::rwx
user:bob:rw-
group::r-x
mask::rwx
other::r--
";
let acl = parse::<GetfaclParser>(text).unwrap();
assert_eq!(acl.owner(), "alice");
assert_eq!(acl.owning_group(), "devs");
assert!(acl.is_extended());
}
The parser validates the ACL structurally after parsing – it delegates to
PosixAcl::is_valid() so there is a single authoritative validation path.
If the text is syntactically valid but structurally invalid (e.g., named
entries without a mask), you get a ParseError rather than a silently
broken PosixAcl.
Parse errors
ParseError covers seven failure modes:
| Variant | Cause |
|---|---|
EmptyInput | The input string is empty or all whitespace |
InvalidTagSyntax | A tag name is not user, group, mask, or other |
InvalidPermString | A permission field is not a valid rwx string |
MissingRequiredEntry | A mandatory tag (user::, group::, other::) is absent |
DuplicateEntry | The same named entry appears more than once |
MissingMask | Named entries present but no mask:: |
SpuriousMask | A mask:: entry with no named entries |
#![allow(unused)]
fn main() {
use posix_acls::parser::{parse, GetfaclParser, ParseError};
// Named entry without a mask
let text = "user::rwx\nuser:bob:rw-\ngroup::r-x\nother::r--\n";
assert_eq!(parse::<GetfaclParser>(text), Err(ParseError::MissingMask));
// Empty input
assert_eq!(parse::<GetfaclParser>(" \n "), Err(ParseError::EmptyInput));
}
Generation
generate::<GetfaclGenerator>(acl) produces the getfacl text format from
a PosixAcl. Entries are emitted in canonical POSIX order: UserObj,
named users (sorted alphabetically), GroupObj, named groups (sorted),
Mask, Other:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use posix_acls::generator::{generate, GetfaclGenerator};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("zoe", PosixPerm::R)
.add_user("bob", PosixPerm::RW)
.build();
let text = generate::<GetfaclGenerator>(&acl).unwrap();
// Named users are sorted alphabetically
let bob_pos = text.find("user:bob:").unwrap();
let zoe_pos = text.find("user:zoe:").unwrap();
assert!(bob_pos < zoe_pos);
}
The generator validates the ACL before producing output. If is_valid()
fails, you get a GenerateError::InvalidAcl.
Default entries are emitted after access entries with the default: prefix:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use posix_acls::generator::{generate, GetfaclGenerator};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.default_owner_perms(PosixPerm::RWX)
.default_owning_group_perms(PosixPerm::RX)
.default_other_perms(PosixPerm::NONE)
.build();
let text = generate::<GetfaclGenerator>(&acl).unwrap();
assert!(text.contains("default:user::rwx"));
assert!(text.contains("default:group::r-x"));
}
Special bits are emitted as a # flags: comment line when any are set:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use posix_acls::perm::SpecialBits;
use posix_acls::generator::{generate, GetfaclGenerator};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::R)
.special_bits(SpecialBits::SGID)
.build();
let text = generate::<GetfaclGenerator>(&acl).unwrap();
assert!(text.contains("# flags: -s-"));
}
Round-trip fidelity
Parse-generate-parse round-trips preserve all structural information:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use posix_acls::perm::SpecialBits;
use posix_acls::parser::{parse, GetfaclParser};
use posix_acls::generator::{generate, GetfaclGenerator};
let original = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::R)
.add_user("bob", PosixPerm::RW)
.add_group("ops", PosixPerm::R)
.special_bits(SpecialBits::SGID)
.default_owner_perms(PosixPerm::RWX)
.default_owning_group_perms(PosixPerm::RX)
.default_other_perms(PosixPerm::NONE)
.build();
let text = generate::<GetfaclGenerator>(&original).unwrap();
let recovered = parse::<GetfaclParser>(&text).unwrap();
assert_eq!(original.owner(), recovered.owner());
assert_eq!(original.owning_group(), recovered.owning_group());
assert_eq!(original.special_bits(), recovered.special_bits());
assert_eq!(original.entries(), recovered.entries());
assert_eq!(original.default_entries(), recovered.default_entries());
}
Custom formats
Both the parser and generator are trait-based, so you can add new text
formats without modifying the crate. Implement PosixAclFormat for parsing
or PosixAclGenerator for generation:
use posix_acls::parser::PosixAclFormat;
use posix_acls::{PosixAcl, ParseError};
struct MyFormat;
impl PosixAclFormat for MyFormat {
fn parse(input: &str) -> Result<PosixAcl, ParseError> {
// Your parsing logic here
todo!()
}
fn name() -> &'static str {
"my-format"
}
}
The generic functions parse::<F>(text) and generate::<F>(acl) dispatch
to whichever format you provide.
Next steps
You can now serialise and deserialise ACLs. The next page explains the POSIX.1e access-check algorithm – how the kernel decides whether a user can read, write, or execute a file based on its ACL.
See Access Checks.
Access Checks
You have built an ACL and serialised it. Now the question that matters: can
user X do operation Y on this file? This page covers the POSIX.1e
access-check algorithm as implemented by PosixAcl::check_access, the role
of the mask entry, and conversion between ACLs and Unix mode words.
The access-check algorithm
check_access implements POSIX.1e section 17.4. It walks the ACL entries
in a fixed order and returns on the first match:
- Owner – if the user is the file owner, use the
user::permissions. No masking is applied. - Named user – if a
user:name:entry matches, return its permissions masked with the mask entry. - Group – collect all matching
group:name:entries (by checking the user’s group list) plus thegroup::entry if the owning group is in the user’s groups. If any match, return the join (bitwise OR) of all matched entries’ masked permissions. - Other – if nothing else matched, use
other::permissions. No masking is applied.
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::R)
.add_user("bob", PosixPerm::RW)
.add_group("ops", PosixPerm::R)
.build();
// 1. Owner match -- uses user:: directly, no masking
let r = acl.check_access("alice", &[] as &[&str], PosixPerm::RWX);
assert!(r.granted);
// 2. Named user match -- masked with mask entry
let r = acl.check_access("bob", &["staff"] as &[&str], PosixPerm::R);
assert!(r.granted);
// 3. Group match -- owning group via group list
let r = acl.check_access("charlie", &["devs"] as &[&str], PosixPerm::R);
assert!(r.granted);
// 3b. Named group match
let r = acl.check_access("dave", &["ops"] as &[&str], PosixPerm::R);
assert!(r.granted);
// 4. Other fallback
let r = acl.check_access("eve", &["guests"] as &[&str], PosixPerm::R);
assert!(r.granted);
}
AccessResult
check_access returns an AccessResult with three fields:
| Field | Type | Meaning |
|---|---|---|
granted | bool | Whether the requested permissions are fully held |
matched_tag | AclTag | Which entry determined the result |
effective_perms | PosixPerm | The actual permissions after masking |
The matched_tag field tells you not just yes/no, but why – useful for
auditing and debugging:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, AclTag, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.build();
let r = acl.check_access("alice", &[] as &[&str], PosixPerm::RWX);
assert_eq!(r.matched_tag, AclTag::UserObj);
assert_eq!(r.effective_perms, PosixPerm::RWX);
let r = acl.check_access("bob", &[] as &[&str], PosixPerm::R);
assert!(matches!(r.matched_tag, AclTag::User(ref n) if n == "bob"));
let r = acl.check_access("eve", &[] as &[&str], PosixPerm::R);
assert_eq!(r.matched_tag, AclTag::Other);
assert!(!r.granted);
}
The mask’s role
The mask entry limits effective permissions for named users and named groups.
It does not affect the owner (user::) or other (other::) entries.
This design lets the file owner set an upper bound on everyone else’s
access:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let mut acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::NONE)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RWX)
.build();
// Auto-computed mask is RWX -- bob gets full access
let r = acl.check_access("bob", &[] as &[&str], PosixPerm::W);
assert!(r.granted);
// Override mask to R only
acl.set_mask(PosixPerm::R);
// Now bob's effective permissions are RWX & R = R
let r = acl.check_access("bob", &[] as &[&str], PosixPerm::W);
assert!(!r.granted);
assert_eq!(r.effective_perms, PosixPerm::R);
}
The mask is auto-computed by AclBuilder as the bitwise OR of all named
user, named group, and owning group entries. You can also compute it
manually with acl.compute_mask() and set it with acl.set_mask(mask).
Group accumulation
When a user is a member of multiple groups that have ACL entries, the algorithm joins (ORs) all matching group permissions. This is a least-restrictive merge:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::R)
.other_perms(PosixPerm::NONE)
.add_group("ops", PosixPerm::W)
.add_group("deploy", PosixPerm::X)
.build();
// Charlie is in both ops and deploy -- effective = W | X = WX (after masking)
let r = acl.check_access(
"charlie",
&["ops", "deploy"] as &[&str],
PosixPerm::WX,
);
assert!(r.granted);
}
Mode word conversion
POSIX ACLs and traditional Unix mode words are two views of the same
permission model. PosixAcl::from_mode builds a minimal (non-extended)
ACL from a 12-bit mode word, and PosixAcl::to_mode converts back:
#![allow(unused)]
fn main() {
use posix_acls::{PosixAcl, PosixPerm};
let acl = PosixAcl::from_mode("alice", "devs", 0o755);
assert_eq!(acl.entries()[0].perms, PosixPerm::RWX); // user::rwx
assert_eq!(acl.entries()[1].perms, PosixPerm::RX); // group::r-x
assert_eq!(acl.entries()[2].perms, PosixPerm::RX); // other::r-x
assert_eq!(acl.to_mode(), 0o755);
}
Mode words include the special bits in the high 3 bits:
#![allow(unused)]
fn main() {
use posix_acls::PosixAcl;
// 0o2755 = SGID + rwxr-xr-x
let acl = PosixAcl::from_mode("alice", "devs", 0o2755);
assert!(acl.special_bits().sgid());
assert_eq!(acl.to_mode(), 0o2755);
}
For extended ACLs, to_mode follows POSIX.1e section 23.1.6: the group
bits in the mode word reflect the mask entry, not the group::
permissions. This is what stat(2) and ls -l show:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::R)
.add_user("bob", PosixPerm::RW)
.build();
// mask = RW | RX = RWX → stat() shows group bits as rwx
let mode = acl.to_mode();
assert_eq!((mode >> 3) & 0o7, 0o7);
}
Minimal ACL mode-word round-trips are exhaustively tested for all 4096 values (9 permission bits plus 3 special bits).
Next steps
You know how permissions are checked and how ACLs map to mode words. When your organisation has many users and groups, managing ACLs manually becomes error-prone. The next page introduces the user/group model and policy system for declaring and validating ACLs at a higher level.
See User/Group Model and Policy.
User/Group Model and Policy
Your project has grown from a few developers to a full team with contractors, ops staff, and interns. Each shared directory needs an ACL that references specific users and groups – and if you grant permissions to a user who does not exist, nothing will work as expected. The model and policy layer (Layer 2) catches these problems at build time by validating every grant against a known directory of users and groups.
UserEntry and GroupEntry
A UserEntry represents a user with their group memberships:
#![allow(unused)]
fn main() {
use posix_acls::model::UserEntry;
let alice = UserEntry::new("alice").with_groups(["devs", "ops"]);
assert_eq!(alice.name, "alice");
assert_eq!(alice.groups, vec!["devs", "ops"]);
}
A GroupEntry is just a name:
#![allow(unused)]
fn main() {
use posix_acls::model::GroupEntry;
let devs = GroupEntry::new("devs");
assert_eq!(devs.name, "devs");
}
Both have public fields, so you can inspect and modify them directly.
UserGroupModel
UserGroupModel is a HashMap-backed directory of users and groups. It
provides O(1) lookups by name:
#![allow(unused)]
fn main() {
use posix_acls::model::{GroupEntry, UserEntry, UserGroupModel};
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs", "ops"]));
model.add_user(UserEntry::new("bob").with_groups(["devs"]));
model.add_user(UserEntry::new("carol").with_groups(["ops"]));
model.add_group(GroupEntry::new("devs"));
model.add_group(GroupEntry::new("ops"));
assert!(model.get_user("alice").is_some());
assert!(model.get_group("devs").is_some());
assert_eq!(model.groups_of("alice"), &["devs", "ops"]);
// Unknown users return None / empty slice
assert!(model.get_user("unknown").is_none());
assert_eq!(model.groups_of("unknown"), &[] as &[String]);
}
Adding a user or group with a name that already exists replaces the previous entry (upsert semantics):
#![allow(unused)]
fn main() {
use posix_acls::model::{UserEntry, UserGroupModel};
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_user(UserEntry::new("alice").with_groups(["ops"]));
assert_eq!(model.get_user("alice").unwrap().groups, &["ops"]);
}
You can iterate over all users and groups:
#![allow(unused)]
fn main() {
use posix_acls::model::{GroupEntry, UserEntry, UserGroupModel};
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice"));
model.add_user(UserEntry::new("bob"));
model.add_group(GroupEntry::new("devs"));
assert_eq!(model.users().count(), 2);
assert_eq!(model.groups().count(), 1);
assert!(!model.is_empty());
}
AclPolicy
AclPolicy declares what permissions users and groups should have. It
looks similar to AclBuilder, but it does not produce a PosixAcl directly
– instead, you call generate(model) to validate the grants against a
UserGroupModel and then derive the concrete ACL:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_user(UserEntry::new("bob").with_groups(["devs"]));
model.add_group(GroupEntry::new("devs"));
let policy = AclPolicy::builder()
.owner("alice")
.owning_group("devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.add_user("bob", PosixPerm::RW)
.build()
.unwrap();
let acl = policy.generate(&model).unwrap();
assert!(acl.is_valid().is_ok());
assert_eq!(acl.owner(), "alice");
assert!(acl.is_extended());
}
The builder requires owner and owning_group to be set. All other
fields default to PosixPerm::NONE.
Policy validation
generate checks that every named user and group in the grant list exists
in the model. If it finds an unknown principal, it returns a PolicyError:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
use posix_acls::policy::PolicyError;
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_group(GroupEntry::new("devs"));
// Grant permissions to a user not in the model
let policy = AclPolicy::builder()
.owner("alice")
.owning_group("devs")
.add_user("unknown", PosixPerm::R)
.build()
.unwrap();
let err = policy.generate(&model).unwrap_err();
assert_eq!(err, PolicyError::UnknownUser("unknown".into()));
}
The same validation applies to default ACL grants:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
use posix_acls::policy::PolicyError;
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_group(GroupEntry::new("devs"));
let policy = AclPolicy::builder()
.owner("alice")
.owning_group("devs")
.default_group("nobody", PosixPerm::R)
.build()
.unwrap();
let err = policy.generate(&model).unwrap_err();
assert_eq!(err, PolicyError::UnknownGroup("nobody".into()));
}
The owner and owning group are not validated against the model – they
may be system principals (like root) that are not tracked in your
application’s directory:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
let mut model = UserGroupModel::new();
model.add_group(GroupEntry::new("devs"));
let policy = AclPolicy::builder()
.owner("root")
.owning_group("devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.build()
.unwrap();
let acl = policy.generate(&model).unwrap();
assert_eq!(acl.owner(), "root");
}
Policy errors
PolicyError covers four cases:
| Variant | Cause |
|---|---|
MissingOwner | builder().build() called without .owner() |
MissingOwningGroup | builder().build() called without .owning_group() |
UnknownUser(name) | A named-user grant references a user absent from the model |
UnknownGroup(name) | A named-group grant references a group absent from the model |
#![allow(unused)]
fn main() {
use posix_acls::policy::{AclPolicy, PolicyError};
// Missing owner
let err = AclPolicy::builder()
.owning_group("devs")
.build()
.unwrap_err();
assert_eq!(err, PolicyError::MissingOwner);
// Missing owning group
let err = AclPolicy::builder()
.owner("alice")
.build()
.unwrap_err();
assert_eq!(err, PolicyError::MissingOwningGroup);
}
Default ACLs in policies
Policies support the same default ACL methods as AclBuilder. Default
grants are validated against the model just like access grants:
#![allow(unused)]
fn main() {
use posix_acls::prelude::*;
let mut model = UserGroupModel::new();
model.add_user(UserEntry::new("alice").with_groups(["devs"]));
model.add_user(UserEntry::new("bob").with_groups(["devs"]));
model.add_group(GroupEntry::new("devs"));
let policy = AclPolicy::builder()
.owner("alice")
.owning_group("devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.default_owner_perms(PosixPerm::RWX)
.default_owning_group_perms(PosixPerm::RX)
.default_other_perms(PosixPerm::NONE)
.default_user("bob", PosixPerm::RW)
.build()
.unwrap();
let acl = policy.generate(&model).unwrap();
assert!(!acl.default_entries().is_empty());
}
Serde support
With the serde feature enabled, UserGroupModel, UserEntry, and
GroupEntry all derive Serialize and Deserialize. This lets you load
your organisation’s user/group directory from a JSON or TOML configuration
file and use it to validate policies at build time or in CI.
Next steps
You can now declare policies and validate them against a user directory.
- acls-rs Bridge – connect POSIX permissions to the algebraic access control system in acls-rs for cross-system policies.
- Temporal Access – a full end-to-end walkthrough:
time-bounded contractor team grants, group-based POSIX ACLs on shared
mounts, per-user effective access derived via
check_access, and thesetfaclcommands to apply them.
acls-rs Bridge
Your platform manages both POSIX file permissions and application-level
access control. A deployment script checks whether a user can write to a
directory (POSIX ACL) and also whether they hold the deploy:execute
permission in your RBAC system. The bridge module connects these two worlds
by converting between PosixPerm and acls-rs PermissionSet types.
Namespace convention
The bridge uses a PermissionMapping with namespace "posix":
PosixPerm bit | AtomicPermission |
|---|---|
Read (r, bit 4) | posix:read |
Write (w, bit 2) | posix:write |
Execute (x, bit 1) | posix:execute |
The mapping is also available as posix_acls::bridge::posix_mapping()
for use with the PermissionMapping API (e.g. for building custom
cross-model bridges).
PosixPerm to PermissionSet
PosixPerm::to_permission_set() converts a permission triple into an
acls-rs PermissionSet:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
use acls_rs::permission::AtomicPermission;
let set = PosixPerm::RW.to_permission_set();
assert_eq!(set.len(), 2);
assert!(set.contains(&AtomicPermission::new("posix", "read")));
assert!(set.contains(&AtomicPermission::new("posix", "write")));
assert!(!set.contains(&AtomicPermission::new("posix", "execute")));
}
PermissionSet to PosixPerm
PosixPerm::from_permission_set(set) converts back. It recognises only
AtomicPermission values with namespace "posix" and actions "read",
"write", or "execute". All other permissions in the set are ignored:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
use acls_rs::permission::{AtomicPermission, PermissionSet};
let set = PermissionSet::from([
AtomicPermission::new("posix", "read"),
AtomicPermission::new("posix", "write"),
AtomicPermission::new("app", "deploy"), // ignored
]);
assert_eq!(PosixPerm::from_permission_set(&set), PosixPerm::RW);
}
The conversion round-trips cleanly for all eight permission values:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
for bits in 0u8..8 {
let p = PosixPerm::new(bits);
let set = p.to_permission_set();
let p2 = PosixPerm::from_permission_set(&set);
assert_eq!(p, p2);
}
}
ACL to PermissionSet
PosixAcl::to_permission_set(user, groups) runs the POSIX.1e access-check
algorithm and converts the effective permissions into a PermissionSet:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use acls_rs::permission::AtomicPermission;
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.build();
// Alice is the owner -- gets rwx
let set = acl.to_permission_set("alice", &[] as &[&str]);
assert_eq!(set.len(), 3);
assert!(set.contains(&AtomicPermission::new("posix", "read")));
assert!(set.contains(&AtomicPermission::new("posix", "write")));
assert!(set.contains(&AtomicPermission::new("posix", "execute")));
// Eve matches "other" -- gets nothing
let set = acl.to_permission_set("eve", &[] as &[&str]);
assert_eq!(set.len(), 0);
}
ACL to GrantDenialPair
PosixAcl::to_grant_denial_pair(user, groups) returns the effective
permissions as a GrantDenialPair. POSIX ACLs have no explicit denial
mechanism – absence of a permission is implicit denial, so the denials
field is always empty:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.build();
let gdp = acl.to_grant_denial_pair("alice", &[] as &[&str]);
assert!(gdp.denials.is_empty());
assert_eq!(gdp.grants.len(), 3);
let effective = gdp.effective_permissions();
assert_eq!(effective.len(), 3);
}
This is useful when you need to compose POSIX file permissions with
application-level GrantDenialPair values from other access control
systems, since GrantDenialPair supports algebraic combination.
Combining with application-level permissions
The bridge enables cross-system access checks. You can merge POSIX file
permissions with application-level grants into a single PermissionSet:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
use acls_rs::prelude::*;
let acl = AclBuilder::new("alice", "devs")
.owner_perms(PosixPerm::RWX)
.owning_group_perms(PosixPerm::RX)
.other_perms(PosixPerm::NONE)
.build();
// Get POSIX file permissions for alice
let file_perms = acl.to_permission_set("alice", &[] as &[&str]);
// Application-level permissions
let app_perms = PermissionSet::from([
AtomicPermission::new("deploy", "execute"),
AtomicPermission::new("config", "read"),
]);
// Combine both into a single permission set
let combined = file_perms.combine(app_perms);
assert!(combined.contains(&AtomicPermission::new("posix", "write")));
assert!(combined.contains(&AtomicPermission::new("deploy", "execute")));
}
The "posix" namespace keeps file permissions distinct from application
permissions, so they compose without collision.
Working with temporal permissions
Through the posix:read/write/execute namespace convention, POSIX
permission bits integrate naturally with acls-rs temporal types. Each rwx
bit becomes an AtomicPermission that carries its own validity window:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
use acls_rs::permission::AtomicPermission;
use acls_rs::permission::temporal::{TemporalPermission, TemporalPermissionSet};
// Grant read+write for a 24-hour window (timestamps in milliseconds)
let start = 1_700_000_000_000u64;
let end = 1_700_086_400_000u64;
let mut temporal = TemporalPermissionSet::new();
temporal.add(TemporalPermission::new(
AtomicPermission::new("posix", "read"), Some(start), Some(end)));
temporal.add(TemporalPermission::new(
AtomicPermission::new("posix", "write"), Some(start), Some(end)));
// During the window: both bits are active
let mid = 1_700_040_000_000u64;
let active = temporal.effective_at(mid);
assert_eq!(PosixPerm::from_permission_set(&active), PosixPerm::RW);
// After the window: nothing is active
let after = 1_700_100_000_000u64;
let expired = temporal.effective_at(after);
assert_eq!(PosixPerm::from_permission_set(&expired), PosixPerm::NONE);
}
For a complete end-to-end walkthrough — contractor teams with overlapping
time windows, group-based ACLs on shared mounts, per-user effective access,
and generated setfacl commands — see
Temporal Access.
Temporal Access: From Organisational Model to File Permissions
Your organisation hires external contractor teams under Statements of Work (SOWs) that define both the access scope and the validity window. Alpha-team may access engineering files during Q1; gamma-team may read legal and HR directories only during a two-week compliance audit. When the window closes, their access disappears — no manual cleanup, no forgotten ACL entries.
This page walks through the full pipeline: model the organisational structure, attach time-bounded permission grants using acls-rs temporal types, derive group-based POSIX ACLs at any point in time, and verify per-user effective access using the POSIX.1e algorithm.
The scenario
Four shared NFS mounts serve different data domains:
| Mount | Owner | Group | Mode | Purpose |
|---|---|---|---|---|
/srv/data/engineering | sysadmin | eng-staff | 0750 | Source code, builds |
/srv/data/legal | legal-admin | legal-staff | 0750 | Contracts, IP filings |
/srv/data/hr | hr-admin | hr-staff | 0750 | Personnel files |
/srv/data/reports | sysadmin | staff | 0754 | Business reports |
Four contractor teams, each with a different access window:
| Team | POSIX Groups | Window | Mounts |
|---|---|---|---|
| Alpha | alpha-lead, alpha-dev | Q1 2026 (Jan–Mar) | engineering |
| Beta | beta-team | H1 2026 (Jan–Jun) | reports |
| Gamma | gamma-team | Audit (Feb 1–Mar 14) | legal, hr |
| Delta | delta-team, delta-lead, delta-eng | Migration (Mar 1–14) | engineering, reports |
Different permissions within a team are handled by separate POSIX groups.
Alice (alpha-team lead) is in alpha-lead and gets rwx; Bob (developer)
is in alpha-dev and gets r-x. The ACL itself only contains
group:name:perm entries — no per-user ACEs.
Step 1: Define the temporal grants
Each grant is a TemporalPermissionSet from acls-rs. The three rwx bits
are stored as AtomicPermission("posix", "read"), ("posix", "write"),
and ("posix", "execute"), each with a valid_from / valid_until
window:
#![allow(unused)]
fn main() {
use acls_rs::permission::temporal::{TemporalPermission, TemporalPermissionSet};
use acls_rs::permission::AtomicPermission;
fn tps(bits: u8, from: u64, until: u64) -> TemporalPermissionSet {
let mut s = TemporalPermissionSet::new();
if bits & 0b100 != 0 {
s.add(TemporalPermission::new(
AtomicPermission::new("posix", "read"), Some(from), Some(until)));
}
if bits & 0b010 != 0 {
s.add(TemporalPermission::new(
AtomicPermission::new("posix", "write"), Some(from), Some(until)));
}
if bits & 0b001 != 0 {
s.add(TemporalPermission::new(
AtomicPermission::new("posix", "execute"), Some(from), Some(until)));
}
s
}
// Alpha-team leads: rwx on engineering, Q1 2026
let alpha_lead_eng = tps(0b111, Q1_FROM, Q1_UNTIL);
// Gamma-team: read-only on legal, audit window
let gamma_legal = tps(0b100, AUDIT_FROM, AUDIT_UNTIL);
}
At any point in time, effective_at(t) returns only the bits whose windows
overlap t. Converting back to a PosixPerm tells you the team’s ceiling:
#![allow(unused)]
fn main() {
use posix_acls::PosixPerm;
let ceiling = PosixPerm::from_permission_set(&alpha_lead_eng.effective_at(time));
// Before Q1: PosixPerm::NONE
// During Q1: PosixPerm::RWX
// After Q1: PosixPerm::NONE
}
Step 2: Build group-based POSIX ACLs
For each mount at a given time, collect the active grants and produce named-group ACEs:
#![allow(unused)]
fn main() {
use posix_acls::{AclBuilder, PosixPerm};
fn acl_at(mount: &MountDef, grants: &[GroupGrant], time: u64)
-> posix_acls::PosixAcl
{
let mut builder = AclBuilder::new(mount.owner, mount.owning_group)
.owner_perms(mount.owner_perms)
.owning_group_perms(mount.group_perms)
.other_perms(mount.other_perms);
for grant in grants.iter().filter(|g| g.mount == mount.path) {
let ceiling = grant.ceiling_at(time);
if ceiling != PosixPerm::NONE {
builder = builder.add_group(grant.group, ceiling);
}
}
builder.build()
}
}
During the March 10 migration peak this produces, for /srv/data/engineering:
# owner: sysadmin
# group: eng-staff
user::rwx
group::r-x
group:alpha-dev:r-x
group:alpha-lead:rwx
group:delta-team:rwx
mask::rwx
other::---
After April 1, the same function returns a minimal ACL with no named groups:
# owner: sysadmin
# group: eng-staff
user::rwx
group::r-x
other::---
No groups to clean up — they simply produce no ACEs when their windows are closed.
Step 3: Derive per-user effective access
Per-user access is computed by PosixAcl::check_access, which runs the
full POSIX.1e algorithm: scan named-group entries, join all matching groups’
masked permissions. No per-user ACEs are needed:
#![allow(unused)]
fn main() {
let acl = acl_at(&engineering, &grants, march_10);
let user_groups = vec!["alpha-lead"];
let result = acl.check_access("alice", &user_groups, PosixPerm::NONE);
// result.effective_perms == PosixPerm::RWX
// result.matched_tag == AclTag::Group("alpha-lead")
let user_groups = vec!["alpha-dev"];
let result = acl.check_access("bob", &user_groups, PosixPerm::NONE);
// result.effective_perms == PosixPerm::RX
// result.matched_tag == AclTag::Group("alpha-dev")
}
Users with no matching contractor group fall through to other::, which
gives them no access (---).
Step 4: Generate setfacl commands
The POSIX ACL text output maps directly to setfacl invocations:
# Applied on each access-review cycle for /srv/data/engineering:
setfacl --remove-all --recursive /srv/data/engineering
setfacl -m u::rwx,g::r-x,o::--- /srv/data/engineering
setfacl -m g:alpha-lead:rwx -R /srv/data/engineering
setfacl -m g:alpha-dev:r-x -R /srv/data/engineering
setfacl -m g:delta-team:rwx -R /srv/data/engineering
setfacl -m m::rwx -R /srv/data/engineering
Group membership is established once at on-boarding:
groupadd alpha-lead
usermod -a -G alpha-lead alice
groupadd alpha-dev
usermod -a -G alpha-dev bob
The periodic ACL review cycle only runs setfacl — group membership is
stable.
Step 5: Track ACL evolution over time
By evaluating the same grants at five points in time, you get a complete audit trail of what changed and when:
| Date | engineering | legal | hr | reports |
|---|---|---|---|---|
| 2026-01-15 | alpha-lead:rwx, alpha-dev:r-x | — | — | beta-team:r– |
| 2026-02-10 | (same) | gamma-team:r– | gamma-team:r– | (same) |
| 2026-03-10 | +delta-team:rwx | (same) | (same) | +delta-lead:rwx, +delta-eng:r-x |
| 2026-04-01 | (all removed) | (removed) | (removed) | beta-team:r– |
| 2026-06-30 | — | — | — | beta-team:r– (expires tonight) |
Each transition is deterministic: the temporal grant’s window either overlaps the evaluation time or it doesn’t. There is no state to track, no cleanup script to forget to run.
Step 6: Point-in-time access queries
Can dave (gamma-team auditor) read /srv/data/hr?
| Date | Window | Verdict |
|---|---|---|
| 2026-01-15 | before audit | denied |
| 2026-02-10 | audit active | granted (r--) |
| 2026-03-10 | audit active | granted (r--) |
| 2026-03-15 | audit closed | denied |
The answer comes from evaluating the temporal grant at the query time,
building the ACL, and running check_access. No filesystem access
required — the model is self-contained.
Python equivalent
The same scenario works from Python using acls_rs for temporal types and
posix_acls for ACL construction:
from acls_rs import AtomicPermission, TemporalPermission, TemporalPermissionSet
from posix_acls import AclBuilder, PosixPerm
# Build a temporal permission set: rwx during Q1 2026
tps = TemporalPermissionSet()
tps.add(TemporalPermission(AtomicPermission("posix", "read"), Q1_FROM, Q1_UNTIL))
tps.add(TemporalPermission(AtomicPermission("posix", "write"), Q1_FROM, Q1_UNTIL))
tps.add(TemporalPermission(AtomicPermission("posix", "execute"), Q1_FROM, Q1_UNTIL))
# At a given time, extract the active bits
perm_set = tps.effective_at(time)
has_read = AtomicPermission("posix", "read") in perm_set
# Build a group-based ACL
acl = (AclBuilder("sysadmin", "eng-staff")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_group("alpha-lead", PosixPerm.RWX.bits)
.add_group("alpha-dev", PosixPerm.RX.bits)
.build())
# Check access for a user in the alpha-lead group
result = acl.check_access("alice", ["alpha-lead"], PosixPerm.NONE.bits)
print(result.effective_perms) # rwx
print(result.granted) # True
The complete Python example is in
crates/posix-acls-python/examples/contractor_access.py.
Key design points
-
Grants live at the group level. The ACL contains
group:name:permentries, neveruser:name:perm. Role differentiation (lead vs developer) is expressed as separate POSIX groups, each with its own grant. -
Temporal windows are declarative. Each grant carries a
TemporalPermissionSetwith explicitvalid_from/valid_until. No cron jobs, no cleanup scripts — the evaluation function queries the model at a given time and produces the correct ACL. -
Per-user access is derived, not stored.
PosixAcl::check_accessruns the POSIX.1e algorithm (join all matching groups’ masked permissions) to compute what each user can actually do. The report is a view, not a separate data structure. -
The same model drives
setfaclcommands. Thegetfacltext output maps 1:1 tosetfacl -m g:group:perminvocations. The periodic review cycle is: evaluate grants at current time → generate ACLs → apply withsetfacl. -
The audit trail is inherent. Evaluating at consecutive times produces a diff of group ACE additions and removals, exactly what a compliance audit needs.
See also
- acls-rs Bridge — the
posix:read/write/executenamespace convention that connects POSIX permissions to acls-rs temporal types. - Temporal Permissions — how
TemporalPermissionandTemporalPermissionSetwork in acls-rs. - The full Rust example:
crates/posix-acls/examples/contractor_access.rs - The full Python example:
crates/posix-acls-python/examples/contractor_access.py
win-sd: Windows Security Descriptor Modeling
Your organisation runs both Windows and Linux. Files live on Samba shares,
Active Directory defines who can access what, and the security descriptors
that Windows puts on every object carry fine-grained permissions — read
extended attributes, write the DACL, take ownership — that go far beyond the
three-bit rwx of POSIX. win-sd provides Rust types that model the full
MS-DTYP security descriptor system, from individual access mask bits up
through complete DACLs, SACLs, SDDL parsing, the access-check algorithm,
conditional ACE expressions, mandatory integrity labels, and ACL inheritance.
Architecture
The crate is organised into layers, each building on the previous:
-
Foundation types —
Sid(Security Identifier with string parsing and well-known constants),AccessMask(32-bit permission bitmask with lattice algebra),Guid(128-bit UUID for Object ACEs). -
ACE model —
AceType(21 MS-DTYP ACE type variants),AceFlags(inheritance and audit flags),Ace(entry with optional GUIDs for Object ACEs, optionalapplication_datafor Callback ACEs, integrity level accessors, and structural validation). -
Security descriptor —
WinAcl(ordered ACE collection with canonical ordering),ControlFlags(16-bit SD control field),SecurityDescriptor(top-level container with owner/group/DACL/SACL),SecurityDescriptorBuilder(fluent construction with auto-canonicalization). -
SDDL — full parser and generator for the Security Descriptor Definition Language text format, with SID alias tables (well-known and domain-relative), rights alias tables, and domain context support.
-
Access check — the Windows access-check algorithm per MS-DTYP §2.5.3.2, with mandatory integrity enforcement, privilege handling, MAXIMUM_ALLOWED mode, callback ACE conditional evaluation, Object ACE GUID filtering, and restricted token support.
-
Advanced features — conditional ACE expression engine (AST, binary format parser/writer, evaluator with depth limit), mandatory integrity labels, generic rights mapping, binary self-relative format serialization, ACL inheritance propagation, resource attribute claims, and an access token model.
An acls-rs bridge connects everything to the algebraic permission system
via configurable PermissionMapping tables. Pre-defined mappings cover
file ("win" namespace), Active Directory ("ad"), and registry ("reg")
semantics. Unlike POSIX ACLs (which have no explicit denials), the bridge
populates both the grants and denials fields of GrantDenialPair.
Quick start
Add win-sd to your Cargo.toml:
[dependencies]
win-sd = "0.1"
With serde support:
[dependencies]
win-sd = { version = "0.1", features = ["serde"] }
Build a security descriptor, generate SDDL, and check access:
#![allow(unused)]
fn main() {
use win_sd::prelude::*;
// Build a security descriptor with a DACL
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.group(Sid::administrators())
.deny(Sid::anonymous(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::administrators(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ)
.build();
// Generate SDDL
let sddl = generate::<SddlGenerator>(&sd).unwrap();
assert!(sddl.contains("(D;")); // deny ACE
assert!(sddl.contains("(A;")); // allow ACEs
// Parse it back
let recovered = parse::<SddlParser>(&sddl).unwrap();
assert_eq!(recovered.owner(), sd.owner());
// Check access — administrators can read
let result = sd.check_access(
&[Sid::administrators(), Sid::everyone()],
AccessMask::FILE_GENERIC_READ,
);
assert!(result.granted);
// Anonymous is denied
let result = sd.check_access(
&[Sid::anonymous(), Sid::everyone()],
AccessMask::FILE_GENERIC_READ,
);
assert!(!result.granted);
}
Full access check with token
The check_access_full method supports the complete Windows access-check
algorithm, including mandatory integrity, privileges, generic rights mapping,
conditional ACE evaluation, and restricted tokens:
#![allow(unused)]
fn main() {
use win_sd::prelude::*;
use win_sd::token::{AccessToken, Privilege};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.allow(Sid::administrators(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ)
.mandatory_label(IntegrityLevel::High, IntegrityPolicy::NO_WRITE_UP)
.build();
// A medium-integrity token trying to write to a high-integrity object
let token = AccessToken::new(Sid::administrators())
.with_integrity(IntegrityLevel::Medium);
let result = sd.check_access_full(
&token,
AccessMask::FILE_WRITE_DATA,
None, // no object type GUID
Some(&GenericMapping::file()),
None, // no attribute context
);
assert!(!result.granted); // denied by mandatory integrity check
}
NT ↔ POSIX translation
The nt_posix_translate example demonstrates bidirectional translation
between Windows Security Descriptors and POSIX ACLs, including two lossless
round-trip paths:
cargo run -p bac-examples --example nt_posix_translate
See NT-POSIX Translation for details.
AD schema access control
The ad_schema_access example parses the real Windows Server v1903 AD DS
schema (LDIF), evaluates SDDL security descriptors with win-sd,
translates them into LDAP ACIs with ldap-acis, and compares both models
through the acls-rs PermissionSet bridge:
cargo run -p bac-examples --example ad_schema_access
See AD Schema Access Control for details.
What to read next
- SIDs and Access Masks — Security Identifiers, the 32-bit permission bitmask, lattice algebra, and well-known constants.
- ACEs and Security Descriptors — ACE types, flags, entries, the WinAcl container, canonical ordering, and the SecurityDescriptor type with fluent construction.
- SDDL Parsing — the Security Descriptor Definition Language text format, alias tables, domain context, and round-trip guarantees.
- Access Checks — the Windows access-check algorithm, mandatory integrity, privileges, MAXIMUM_ALLOWED, callback ACE evaluation, Object ACE GUID filtering, and restricted tokens.
- Advanced Features — conditional expressions, binary serialization, ACL inheritance, generic rights mapping, resource attribute claims, and the access token model.
- acls-rs Bridge — configurable
PermissionMappingfor file/AD/registry semantics,_withvariants, GrantDenialPair with explicit denials, and cross-system permission comparison. - NT-POSIX Translation — bidirectional translation between Windows SDs and POSIX ACLs, lossy and lossless round-trip paths, and the fundamental impedance mismatch.
SIDs and Access Masks
Security Identifiers (SIDs)
A SID uniquely identifies a security principal — a user, group, computer, or service. It consists of a revision (always 1), a 6-byte identifier authority, and up to 15 sub-authority values.
#![allow(unused)]
fn main() {
use win_sd::Sid;
// Parse from standard string format
let sid: Sid = "S-1-5-32-544".parse().unwrap();
assert_eq!(sid, Sid::administrators());
assert_eq!(sid.to_string(), "S-1-5-32-544");
// Well-known SID constructors
let _ = Sid::everyone(); // S-1-1-0
let _ = Sid::local_system(); // S-1-5-18
let _ = Sid::authenticated_users();// S-1-5-11
let _ = Sid::anonymous(); // S-1-5-7
let _ = Sid::creator_owner(); // S-1-3-0
// Domain SIDs
let domain = Sid::from_authority(5, vec![21, 100, 200, 300]);
let alice = Sid::from_authority(5, vec![21, 100, 200, 300, 1001]);
assert_eq!(alice.rid(), Some(1001));
assert_eq!(alice.domain(), domain);
}
SIDs implement Hash, Eq, Ord, and optional Serialize/Deserialize,
making them suitable as HashMap keys.
ACCESS_MASK
The AccessMask is a 32-bit bitmask encoding the permissions requested or
granted. Bits are divided into regions:
| Bits | Category | Examples |
|---|---|---|
| 0–15 | Object-specific | FILE_READ_DATA, FILE_WRITE_DATA, KEY_QUERY_VALUE |
| 16–20 | Standard rights | DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE |
| 24–25 | Special | ACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED |
| 28–31 | Generic | GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE, GENERIC_ALL |
#![allow(unused)]
fn main() {
use win_sd::AccessMask;
use acls_rs::algebra::JoinSemilattice;
// Named constants
let read = AccessMask::FILE_GENERIC_READ;
let write = AccessMask::FILE_GENERIC_WRITE;
// Lattice algebra: join (OR) is the least upper bound
let combined = read.join(write);
assert!(combined.contains(read));
assert!(combined.contains(write));
// Subset partial order
assert!(read < AccessMask::FILE_ALL_ACCESS);
// Bitwise operators
let rw = AccessMask::FILE_READ_DATA | AccessMask::FILE_WRITE_DATA;
assert_eq!(rw & AccessMask::FILE_READ_DATA, AccessMask::FILE_READ_DATA);
}
File-specific composites
| Constant | Bits |
|---|---|
FILE_GENERIC_READ | FILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE |
FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE |
FILE_GENERIC_EXECUTE | FILE_EXECUTE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE |
FILE_ALL_ACCESS | All standard + file-specific rights (0x001F01FF) |
Registry key rights
The crate also defines registry key constants (KEY_QUERY_VALUE,
KEY_SET_VALUE, KEY_READ, KEY_WRITE, KEY_ALL_ACCESS) and Active
Directory rights (ADS_RIGHT_DS_READ_PROP, ADS_RIGHT_DS_WRITE_PROP, etc.)
for use with Object ACEs.
Generic rights mapping
Generic rights (bits 28–31) are abstract — they must be mapped to
object-specific bits before use in access checks. GenericMapping provides
this:
#![allow(unused)]
fn main() {
use win_sd::{AccessMask, GenericMapping};
let mapping = GenericMapping::file();
let expanded = mapping.map_mask(AccessMask::GENERIC_READ);
assert_eq!(expanded, AccessMask::FILE_GENERIC_READ);
}
Pre-defined mappings: file(), directory(), registry_key().
ACEs and Security Descriptors
ACE types
The AceType enum covers all 21 MS-DTYP ACE type variants:
| Category | Types | SDDL | Numeric |
|---|---|---|---|
| Basic | AccessAllowed, AccessDenied | A, D | 0x00, 0x01 |
| Audit | SystemAudit, SystemAlarm | AU, AL | 0x02, 0x03 |
| Object | AccessAllowedObject, AccessDeniedObject, SystemAuditObject, SystemAlarmObject | OA, OD, OU, OL | 0x05–0x08 |
| Callback | AccessAllowedCallback, AccessDeniedCallback, + Object variants | XA, XD, ZA, ZD, XU, XL, ZU, ZL | 0x09–0x10 |
| Special | SystemMandatoryLabel, SystemResourceAttribute, SystemScopedPolicyId | ML, RA, SP | 0x11–0x13 |
Classification methods: is_allow(), is_deny(), is_audit(),
is_object_type(), is_callback(), is_mandatory_label(),
is_dacl_type(), is_sacl_type().
ACE flags
Inheritance and audit flags on each ACE:
#![allow(unused)]
fn main() {
use win_sd::AceFlags;
let flags = AceFlags::CONTAINER_INHERIT | AceFlags::OBJECT_INHERIT;
assert!(flags.contains(AceFlags::CONTAINER_INHERIT));
assert!(!flags.is_inherited());
}
| Flag | Value | Meaning |
|---|---|---|
OBJECT_INHERIT | 0x01 | Non-container children inherit |
CONTAINER_INHERIT | 0x02 | Container children inherit |
NO_PROPAGATE_INHERIT | 0x04 | Stop propagation to grandchildren |
INHERIT_ONLY | 0x08 | Does not apply to this object |
INHERITED_ACE | 0x10 | Was inherited from parent |
SUCCESSFUL_ACCESS | 0x40 | Audit on success (SACL) |
FAILED_ACCESS | 0x80 | Audit on failure (SACL) |
ACE entries
An Ace carries type, flags, mask, SID, and optional GUID/application data:
#![allow(unused)]
fn main() {
use win_sd::{Ace, AccessMask, Sid};
// Convenience constructors
let allow = Ace::allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ);
let deny = Ace::deny(Sid::anonymous(), AccessMask::FILE_ALL_ACCESS);
let audit = Ace::audit(Sid::everyone(), AccessMask::FILE_WRITE_DATA);
// Object ACE with GUID
let guid = "bf967aba-0de6-11d0-a285-00aa003049e2".parse().unwrap();
let obj_ace = Ace::allow_object(
Sid::administrators(),
AccessMask::new(0x0010), // ADS_RIGHT_DS_READ_PROP
Some(guid),
None,
);
assert!(obj_ace.ace_type().is_object_type());
assert!(obj_ace.object_type().is_some());
}
WinAcl
An ordered collection of ACEs, used for both DACLs and SACLs:
#![allow(unused)]
fn main() {
use win_sd::{WinAcl, Ace, AccessMask, Sid};
let mut acl = WinAcl::new();
acl.add_ace(Ace::deny(Sid::anonymous(), AccessMask::FILE_ALL_ACCESS));
acl.add_ace(Ace::allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ));
assert!(acl.is_canonical()); // deny before allow
assert_eq!(acl.ace_count(), 2);
// Canonicalize sorts into MS-recommended order:
// explicit deny → explicit allow → inherited deny → inherited allow
acl.canonicalize();
}
SecurityDescriptor
The top-level container:
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptor, SecurityDescriptorBuilder, AccessMask, Sid};
use win_sd::{IntegrityLevel, IntegrityPolicy};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.group(Sid::administrators())
.deny(Sid::anonymous(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::administrators(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ)
.mandatory_label(IntegrityLevel::High, IntegrityPolicy::NO_WRITE_UP)
.protected_dacl(true)
.build();
assert!(sd.is_valid().is_ok());
assert!(sd.control().dacl_present());
assert!(sd.control().dacl_protected());
assert!(sd.dacl().unwrap().is_canonical());
assert!(sd.sacl().is_some()); // mandatory label is in the SACL
}
The builder automatically sets DACL_PRESENT/SACL_PRESENT flags and
canonicalizes ACE ordering.
SDDL Parsing and Generation
The Security Descriptor Definition Language (SDDL) is the standard text
format for Windows security descriptors: O:ownerG:groupD:dacl_flags(ACEs)S:sacl_flags(ACEs).
Parsing
#![allow(unused)]
fn main() {
use win_sd::{parse, SddlParser, Sid, AccessMask};
let sddl = "O:BAG:SYD:(D;;GA;;;AN)(A;;FA;;;BA)(A;;FR;;;WD)";
let sd = parse::<SddlParser>(sddl).unwrap();
assert_eq!(sd.owner(), Some(&Sid::administrators()));
assert_eq!(sd.group(), Some(&Sid::local_system()));
assert_eq!(sd.dacl().unwrap().ace_count(), 3);
}
Generating
#![allow(unused)]
fn main() {
use win_sd::{generate, SddlGenerator, SecurityDescriptorBuilder, AccessMask, Sid};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::administrators())
.group(Sid::local_system())
.allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ)
.build();
let sddl = generate::<SddlGenerator>(&sd).unwrap();
assert_eq!(sddl, "O:BAG:SYD:(A;;FR;;;WD)");
}
SID aliases
Well-known SIDs are represented by two-letter aliases:
| Alias | SID | Name |
|---|---|---|
| WD | S-1-1-0 | Everyone |
| BA | S-1-5-32-544 | BUILTIN\Administrators |
| BU | S-1-5-32-545 | BUILTIN\Users |
| SY | S-1-5-18 | LOCAL SYSTEM |
| AN | S-1-5-7 | ANONYMOUS LOGON |
| AU | S-1-5-11 | Authenticated Users |
| CO | S-1-3-0 | Creator Owner |
| LW | S-1-16-4096 | Low Integrity |
| ME | S-1-16-8192 | Medium Integrity |
| HI | S-1-16-12288 | High Integrity |
| SI | S-1-16-16384 | System Integrity |
BUILTIN and other well-known SIDs
| Alias | SID | Name |
|---|---|---|
| PS | S-1-5-10 | Principal Self |
| ED | S-1-5-9 | Enterprise DCs |
| OW | S-1-3-4 | Owner Rights |
| BO | S-1-5-32-551 | Backup Operators |
| SO | S-1-5-32-549 | Server Operators |
| PO | S-1-5-32-550 | Print Operators |
| AO | S-1-5-32-548 | Account Operators |
| PU | S-1-5-32-547 | Power Users |
| RE | S-1-5-32-552 | Replicator |
| RU | S-1-5-32-554 | Pre-W2K Compatible |
| IS | S-1-5-32-568 | IIS_IUSRS |
| CY | S-1-5-32-569 | Crypto Operators |
| ER | S-1-5-32-573 | Event Log Readers |
| HA | S-1-5-32-578 | Hyper-V Admins |
| AA | S-1-5-32-579 | Access Control Assistance |
| AC | S-1-15-2-1 | All Application Packages |
Domain-relative aliases
Domain-relative aliases (DA, DU, DD, etc.) require a DomainContext:
#![allow(unused)]
fn main() {
use win_sd::{parse_with_domain, generate_with_domain, DomainContext, Sid};
let domain = DomainContext::new(
Sid::from_authority(5, vec![21, 100, 200, 300]),
);
// DA resolves to S-1-5-21-100-200-300-512 (Domain Admins)
let sd = parse_with_domain("O:DAG:DUD:(A;;FA;;;DA)", &domain).unwrap();
let owner = sd.owner().unwrap();
assert_eq!(owner.rid(), Some(512));
// Generate uses aliases when a domain context is provided
let sddl = generate_with_domain(&sd, &domain).unwrap();
assert!(sddl.contains("DA"));
}
| Alias | RID | Name |
|---|---|---|
| DA | 512 | Domain Admins |
| DU | 513 | Domain Users |
| DG | 514 | Domain Guests |
| DC | 515 | Domain Computers |
| DD | 516 | Domain Controllers |
| SA | 518 | Schema Admins |
| EA | 519 | Enterprise Admins |
| PA | 520 | Policy Admins |
| RO | 521 | Read-Only DCs |
Rights aliases
Access mask values use two-letter codes:
| Code | Value | Meaning |
|---|---|---|
| GA | 0x10000000 | GENERIC_ALL |
| GR | 0x80000000 | GENERIC_READ |
| GW | 0x40000000 | GENERIC_WRITE |
| GX | 0x20000000 | GENERIC_EXECUTE |
| FA | 0x001F01FF | FILE_ALL_ACCESS |
| FR | 0x00120089 | FILE_GENERIC_READ |
| FW | 0x00120116 | FILE_GENERIC_WRITE |
| FX | 0x001200A0 | FILE_GENERIC_EXECUTE |
| KA | 0x000F003F | KEY_ALL_ACCESS |
| KR | 0x00020019 | KEY_READ |
| KW | 0x00020006 | KEY_WRITE |
| CC | 0x00000001 | CREATE_CHILD (AD) |
| DC | 0x00000002 | DELETE_CHILD (AD) |
| RP | 0x00000010 | READ_PROP (AD) |
| WP | 0x00000020 | WRITE_PROP (AD) |
| CR | 0x00000100 | CONTROL_ACCESS (AD) |
| RC | 0x00020000 | READ_CONTROL |
| SD | 0x00010000 | DELETE |
| WD | 0x00040000 | WRITE_DAC |
| WO | 0x00080000 | WRITE_OWNER |
| NW | 0x01 | NO_WRITE_UP (integrity) |
| NR | 0x02 | NO_READ_UP (integrity) |
| NX | 0x04 | NO_EXECUTE_UP (integrity) |
ACL flags: P (protected), AR (auto-inherit request), AI (auto-inherited).
Hex literals are also accepted: 0x001F01FF.
Access Checks
The win-sd crate provides three access-check methods, from simple to full:
Simple check
check_access takes a slice of token SIDs and a desired access mask.
Implements the basic MS-DTYP §2.5.3.2 algorithm:
- No DACL present → grant all
- Empty DACL → deny all (except owner implicit READ_CONTROL | WRITE_DAC)
- Walk ACEs in order, skip INHERIT_ONLY
- Deny ACEs block; allow ACEs accumulate
- All desired bits must be granted and none denied
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.deny(Sid::anonymous(), AccessMask::FILE_ALL_ACCESS)
.allow(Sid::administrators(), AccessMask::FILE_ALL_ACCESS)
.build();
let result = sd.check_access(
&[Sid::administrators(), Sid::everyone()],
AccessMask::FILE_READ_DATA,
);
assert!(result.granted);
}
Object ACE check
check_access_object adds GUID filtering for Object ACEs:
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid, Ace, Guid, WinAcl};
let guid: Guid = "bf967aba-0de6-11d0-a285-00aa003049e2".parse().unwrap();
let ace = Ace::allow_object(
Sid::administrators(),
AccessMask::new(0x0010),
Some(guid),
None,
);
let mut sd = win_sd::SecurityDescriptor::new();
let mut dacl = WinAcl::new();
dacl.add_ace(ace);
sd.set_dacl(dacl);
// Matches only when the requested object type GUID matches
let result = sd.check_access_object(
&[Sid::administrators()],
AccessMask::new(0x0010),
Some(&guid),
);
assert!(result.granted);
}
Full token-based check
check_access_full supports the complete Windows access-check algorithm:
- Mandatory integrity check: scans SACL for
SystemMandatoryLabelACE, denies write/read/execute when token integrity is below object integrity. - Privilege handling:
SeSecurityPrivilegegrants ACCESS_SYSTEM_SECURITY;SeTakeOwnershipPrivilegegrants WRITE_OWNER. - Generic rights expansion: expands GENERIC_READ/WRITE/EXECUTE/ALL via
GenericMapping. - MAXIMUM_ALLOWED mode: when desired mask contains MAXIMUM_ALLOWED, computes the full set of grantable rights.
- Callback ACE evaluation: evaluates conditional expressions via
AttributeContext. Fail-secure: allow ACEs without a parseable condition are skipped; deny ACEs without a condition still deny. - Restricted tokens: second DACL walk against restricted SIDs, intersected with the primary result.
#![allow(unused)]
fn main() {
use win_sd::prelude::*;
use win_sd::token::{AccessToken, Privilege};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.allow(Sid::everyone(), AccessMask::FILE_ALL_ACCESS)
.mandatory_label(IntegrityLevel::High, IntegrityPolicy::NO_WRITE_UP)
.build();
// High-integrity token can write
let high = AccessToken::new(Sid::everyone())
.with_integrity(IntegrityLevel::High);
let result = sd.check_access_full(&high, AccessMask::FILE_WRITE_DATA, None, None, None);
assert!(result.granted);
// Medium-integrity token cannot (NO_WRITE_UP)
let medium = AccessToken::new(Sid::everyone())
.with_integrity(IntegrityLevel::Medium);
let result = sd.check_access_full(&medium, AccessMask::FILE_WRITE_DATA, None, None, None);
assert!(!result.granted);
}
AccessCheckResult
All three methods return AccessCheckResult:
granted: bool— whether the full desired mask was satisfiedgranted_mask: AccessMask— accumulated granted bits (after denials)denied_mask: AccessMask— accumulated denied bits
Advanced Features
Mandatory integrity labels
Windows integrity levels control write-up/read-down/execute-up policies. Each level maps to a SID under authority 16:
#![allow(unused)]
fn main() {
use win_sd::{Ace, IntegrityLevel, IntegrityPolicy};
assert!(IntegrityLevel::Low < IntegrityLevel::Medium);
assert!(IntegrityLevel::Medium < IntegrityLevel::High);
let sid = IntegrityLevel::Medium.to_sid();
assert_eq!(sid.to_string(), "S-1-16-8192");
let ace = Ace::mandatory_label(
IntegrityLevel::High,
IntegrityPolicy::NO_WRITE_UP | IntegrityPolicy::NO_READ_UP,
);
assert_eq!(ace.integrity_level(), Some(IntegrityLevel::High));
}
Conditional expressions
Callback ACEs (types XA, XD, ZA, ZD, XU, ZU) carry
conditional expressions in their application_data field, encoded in the
MS-DTYP binary postfix (RPN) format per §2.4.4.17. The crate provides
two interfaces: a text-based SDDL syntax and the underlying binary format.
SDDL text syntax
The sddl_conditional module parses and generates the human-readable
SDDL conditional expression syntax:
#![allow(unused)]
fn main() {
use win_sd::sddl_conditional::{parse_sddl_condition, generate_sddl_condition};
// Parse from text
let expr = parse_sddl_condition(r#"@User.dept == "Sales""#).unwrap();
// Generate text (round-trips with correct precedence)
let text = generate_sddl_condition(&expr);
assert_eq!(text, r#"@User.dept == "Sales""#);
// Complex expressions with logical operators
let expr = parse_sddl_condition(
r#"@User.dept == "Sales" && @Resource.public == 1"#
).unwrap();
// Membership operators
let expr = parse_sddl_condition("Member_of{SID(BA)}").unwrap();
// Composite literals
let expr = parse_sddl_condition(
r#"@User.groups Contains {"admin", "devs"}"#
).unwrap();
// Existence checks
let expr = parse_sddl_condition("Exists @User.clearance").unwrap();
}
Supported SDDL syntax:
| Element | Examples |
|---|---|
| Attribute references | @User.name, @Resource.name, @Device.name, @Local.name |
| Comparison | ==, !=, <, <=, >, >= |
| Set operators | Contains, Any_of |
| Logical | &&, ||, !(...) |
| Existence | Exists @User.attr |
| Membership | Member_of{SID(BA)}, Not_Member_of{...}, Member_of_Any{...}, Device_Member_of{...}, plus Not_Device_Member_of, Device_Member_of_Any, Not_Member_of_Any, Not_Device_Member_of_Any |
| Literals | integers, "strings", SID(BA), SID(S-1-5-...), {"a", "b"} |
The parser enforces a depth limit of 128 to prevent stack overflow from deeply nested input. Trailing unparsed input is rejected.
AST and value types
The ConditionalExpr enum has 22 variants:
| Category | Variants |
|---|---|
| Leaf nodes | Literal(ClaimValue), UserAttr, ResourceAttr, DeviceAttr, LocalAttr |
| Comparison | Equals, NotEquals, LessThan, LessEqual, GreaterThan, GreaterEqual |
| Set | Contains, AnyOf |
| Logical | And, Or, Not |
| Existence | Exists |
| Membership | MemberOf, DeviceMemberOf, NotMemberOf, NotDeviceMemberOf, MemberOfAny, DeviceMemberOfAny, NotMemberOfAny, NotDeviceMemberOfAny |
The ClaimValue enum carries typed data:
| Variant | Rust type | Description |
|---|---|---|
Int64 | i64 | 64-bit signed integer |
Uint64 | u64 | 64-bit unsigned integer |
String | String | Unicode string |
Sid | Sid | Security identifier |
Boolean | bool | Boolean value |
OctetString | Vec<u8> | Raw byte string |
Composite | Vec<ClaimValue> | Multi-valued set |
Building expressions programmatically
#![allow(unused)]
fn main() {
use win_sd::conditional::*;
let expr = ConditionalExpr::Equals(
Box::new(ConditionalExpr::UserAttr("department".to_string())),
Box::new(ConditionalExpr::Literal(ClaimValue::String("Engineering".to_string()))),
);
}
Binary format
The binary format uses the artx signature prefix (0x61 0x72 0x74 0x78):
#![allow(unused)]
fn main() {
use win_sd::conditional::*;
let expr = ConditionalExpr::Equals(
Box::new(ConditionalExpr::UserAttr("department".to_string())),
Box::new(ConditionalExpr::Literal(ClaimValue::String("Engineering".to_string()))),
);
// Write to binary
let binary = write_conditional(&expr);
assert!(is_conditional_ace_data(&binary));
// Parse back
let parsed = parse_conditional(&binary).unwrap();
assert_eq!(expr, parsed);
}
Extracting conditions from callback ACEs
#![allow(unused)]
fn main() {
use win_sd::Ace;
fn inspect_ace(ace: &Ace) {
if ace.ace_type().is_callback() {
if let Some(result) = ace.condition() {
match result {
Ok(expr) => println!("condition: {:?}", expr),
Err(e) => println!("parse error: {e}"),
}
}
}
}
}
Evaluation
Evaluation uses three-valued logic via ConditionalResult:
True— the expression matched; the ACE applies.False— the expression did not match; the ACE is skipped.Unknown— an attribute was missing or a type mismatch occurred. For allow ACEs,Unknownmeans “not granted.” For deny ACEs,Unknownmeans “denied” (fail-secure).
#![allow(unused)]
fn main() {
use win_sd::conditional::*;
struct MyCtx;
impl AttributeContext for MyCtx {
fn get_user_attr(&self, name: &str) -> Option<ClaimValue> {
if name == "department" {
Some(ClaimValue::String("Engineering".to_string()))
} else { None }
}
fn get_resource_attr(&self, _: &str) -> Option<ClaimValue> { None }
fn get_device_attr(&self, _: &str) -> Option<ClaimValue> { None }
fn get_local_attr(&self, _: &str) -> Option<ClaimValue> { None }
fn is_member_of(&self, _: &win_sd::Sid) -> bool { false }
fn is_device_member_of(&self, _: &win_sd::Sid) -> bool { false }
}
let expr = ConditionalExpr::Equals(
Box::new(ConditionalExpr::UserAttr("department".to_string())),
Box::new(ConditionalExpr::Literal(ClaimValue::String("Engineering".to_string()))),
);
assert_eq!(expr.evaluate(&MyCtx), ConditionalResult::True);
// Missing attribute returns Unknown
let missing = ConditionalExpr::Exists(
Box::new(ConditionalExpr::UserAttr("clearance".to_string())),
);
assert_eq!(missing.evaluate(&MyCtx), ConditionalResult::False);
}
The AttributeContext trait has six methods:
| Method | Purpose |
|---|---|
get_user_attr(name) | Look up a user claim attribute |
get_resource_attr(name) | Look up a resource attribute |
get_device_attr(name) | Look up a device claim attribute |
get_local_attr(name) | Look up a local attribute |
is_member_of(sid) | Check token SID membership |
is_device_member_of(sid) | Check device SID membership |
EmptyAttributeContext is a provided no-op implementation returning
None/false for all lookups.
The evaluator has a depth limit of 1024 to prevent stack overflow from crafted inputs.
Error handling
parse_conditional returns ConditionalParseError with these variants:
| Variant | Meaning |
|---|---|
InvalidSignature | Data doesn’t start with artx |
UnexpectedEnd | Truncated data |
UnknownToken(u8) | Unrecognized token byte |
StackUnderflow | Malformed RPN: not enough operands |
Binary serialization
Security descriptors can be serialized to/from the MS-DTYP self-relative binary format:
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, SecurityDescriptor, AccessMask, Sid};
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.allow(Sid::everyone(), AccessMask::FILE_GENERIC_READ)
.build();
// Serialize to binary
let bytes = sd.to_bytes();
// Parse back
let recovered = SecurityDescriptor::from_bytes(&bytes).unwrap();
assert_eq!(recovered.owner(), sd.owner());
}
The parser validates SID sub-authority counts, minimum ACE sizes, AclSize bounds, and rejects overlapping component offset regions.
ACL inheritance
The inheritance engine propagates ACEs from parent to child objects:
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid, AceFlags};
use win_sd::inheritance::create_child_sd;
let parent = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.allow_with_flags(
Sid::administrators(),
AccessMask::FILE_ALL_ACCESS,
AceFlags::CONTAINER_INHERIT | AceFlags::OBJECT_INHERIT,
)
.build();
// Create a child directory SD
let child = create_child_sd(&parent, true);
assert!(child.dacl().is_some());
// Inherited ACEs have the INHERITED_ACE flag set
let ace = &child.dacl().unwrap().entries()[0];
assert!(ace.is_inherited());
// INHERIT_ONLY is cleared: the ACE applies to the child
assert!(!ace.is_inherit_only());
}
Inheritance rules:
- Containers: ACEs with
CONTAINER_INHERITare inherited. ACEs with onlyOBJECT_INHERITpass through asINHERIT_ONLY. - Non-containers: ACEs with
OBJECT_INHERITare inherited, with all inheritance flags cleared. NO_PROPAGATE_INHERITstops further propagation.INHERIT_ONLYon the parent means “skip the parent, apply to children” — it is cleared on the inherited copy.
Access tokens
The AccessToken consolidates all access-check inputs:
#![allow(unused)]
fn main() {
use win_sd::Sid;
use win_sd::token::{AccessToken, Privilege};
use win_sd::IntegrityLevel;
let token = AccessToken::new(Sid::from_authority(5, vec![21, 100, 200, 300, 1001]))
.with_groups([Sid::administrators(), Sid::everyone()])
.with_integrity(IntegrityLevel::High)
.with_privilege(Privilege::SeSecurityPrivilege);
assert_eq!(token.integrity_level(), IntegrityLevel::High);
assert!(token.has_privilege(Privilege::SeSecurityPrivilege));
// Restricted tokens limit the second DACL walk
let restricted = token.with_restricted_sids(vec![Sid::authenticated_users()]);
assert!(restricted.is_restricted());
}
Resource attribute claims
ClaimSecurityAttribute models the CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1
structure used in SYSTEM_RESOURCE_ATTRIBUTE_ACE:
#![allow(unused)]
fn main() {
use win_sd::claims::{ClaimSecurityAttribute, ClaimValueType};
use win_sd::conditional::ClaimValue;
let attr = ClaimSecurityAttribute::new(
"Department",
ClaimValueType::String,
vec![ClaimValue::String("Engineering".to_string())],
);
assert_eq!(attr.name(), "Department");
}
acls-rs Bridge
The bridge module connects Windows access masks to the algebraic permission
system in acls-rs. A PermissionMapping defines how bitmask values
translate to named permissions — different object types use different
mappings because the same bit positions carry different semantics.
PermissionMapping
The same bit can mean different things depending on the object type:
| Bit | File | Active Directory | Registry |
|---|---|---|---|
| 0x0001 | file_read_data | create_child | query_value |
| 0x0010 | file_write_ea | read_prop | notify |
| 0x0020 | file_execute | write_prop | create_link |
| 0x0100 | file_write_attributes | control_access | — |
A PermissionMapping holds a namespace and a table of (bit, action_name)
entries. Three pre-defined mappings cover the common cases:
#![allow(unused)]
fn main() {
use win_sd::bridge::{file_mapping, ad_mapping, registry_mapping};
let file = file_mapping(); // namespace "win", file semantics
let ad = ad_mapping(); // namespace "ad", AD/DS semantics
let reg = registry_mapping(); // namespace "reg", registry semantics
}
Standard rights (bits 0x10000–0x100000: delete, read_control,
write_dac, write_owner, synchronize) are shared across all three
mappings.
Custom mappings can be built at runtime:
#![allow(unused)]
fn main() {
use acls_rs::permission::PermissionMapping;
let custom = PermissionMapping::new("myapp")
.add(0x01, "view")
.add(0x02, "edit")
.add(0x04, "admin");
}
AccessMask ↔ PermissionSet
The default (no-argument) methods use the file mapping:
#![allow(unused)]
fn main() {
use win_sd::AccessMask;
use acls_rs::permission::AtomicPermission;
let set = AccessMask::FILE_GENERIC_READ.to_permission_set();
assert!(set.contains(&AtomicPermission::new("win", "file_read_data")));
assert!(set.contains(&AtomicPermission::new("win", "read_control")));
let mask = AccessMask::from_permission_set(&set);
assert!(mask.contains(AccessMask::FILE_READ_DATA));
}
For AD or registry objects, pass a mapping:
#![allow(unused)]
fn main() {
use win_sd::AccessMask;
use win_sd::bridge::ad_mapping;
use acls_rs::permission::AtomicPermission;
let mask = AccessMask::new(0x0030); // READ_PROP | WRITE_PROP
let set = mask.to_permission_set_with(&ad_mapping());
assert!(set.contains(&AtomicPermission::new("ad", "read_prop")));
assert!(set.contains(&AtomicPermission::new("ad", "write_prop")));
// Round-trip through the same mapping
let recovered = AccessMask::from_permission_set_with(&set, &ad_mapping());
assert_eq!(mask, recovered);
}
SecurityDescriptor ↔ PermissionSet
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid};
use win_sd::bridge::ad_mapping;
use acls_rs::permission::AtomicPermission;
let sd = SecurityDescriptorBuilder::new()
.owner(Sid::local_system())
.allow(Sid::everyone(), AccessMask::new(0x000F01B7))
.build();
// File semantics (default)
let file_set = sd.to_permission_set(&[Sid::everyone()]);
assert!(file_set.contains(&AtomicPermission::new("win", "file_read_data")));
// AD semantics
let ad_set = sd.to_permission_set_with(&[Sid::everyone()], &ad_mapping());
assert!(ad_set.contains(&AtomicPermission::new("ad", "read_prop")));
assert!(ad_set.contains(&AtomicPermission::new("ad", "write_prop")));
assert!(ad_set.contains(&AtomicPermission::new("ad", "create_child")));
}
GrantDenialPair with explicit denials
Unlike POSIX ACLs (which have no explicit denials), Windows security descriptors can have both grants and denials:
#![allow(unused)]
fn main() {
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid};
use acls_rs::permission::AtomicPermission;
let sd = SecurityDescriptorBuilder::new()
.deny(Sid::anonymous(), AccessMask::FILE_WRITE_DATA)
.allow(Sid::everyone(), AccessMask::FILE_ALL_ACCESS)
.build();
let gdp = sd.to_grant_denial_pair(&[Sid::anonymous(), Sid::everyone()]);
assert!(gdp.denials.contains(&AtomicPermission::new("win", "file_write_data")));
let effective = gdp.effective_permissions();
assert!(!effective.contains(&AtomicPermission::new("win", "file_write_data")));
assert!(effective.contains(&AtomicPermission::new("win", "file_read_data")));
}
The _with variant accepts a mapping:
#![allow(unused)]
fn main() {
use win_sd::bridge::ad_mapping;
use win_sd::{SecurityDescriptorBuilder, AccessMask, Sid};
use acls_rs::permission::AtomicPermission;
let sd = SecurityDescriptorBuilder::new()
.deny(Sid::anonymous(), AccessMask::new(0x0020))
.allow(Sid::everyone(), AccessMask::new(0x000F01B7))
.build();
let gdp = sd.to_grant_denial_pair_with(
&[Sid::anonymous(), Sid::everyone()],
ad_mapping(),
);
assert!(gdp.denials.contains(
&AtomicPermission::new("ad", "write_prop"),
));
}
Cross-namespace comparison
Both posix-acls and win-sd expose permissions through PermissionSet,
enabling cross-system comparison:
#![allow(unused)]
fn main() {
use acls_rs::permission::AtomicPermission;
// These are in different namespaces — the caller maps between them
let nt_read = AtomicPermission::new("win", "file_read_data");
let posix_read = AtomicPermission::new("posix", "read");
let ad_read = AtomicPermission::new("ad", "read_prop");
// They are distinct values — no implicit equivalence
assert_ne!(nt_read, posix_read);
assert_ne!(nt_read, ad_read);
}
For direct translation between NT and POSIX permissions, see NT-POSIX Translation. For AD schema analysis with cross-model bridging, see AD Schema Access Control.
Mapping reference
File mapping (file_mapping(), namespace "win")
| Bit | Action |
|---|---|
| 0x00010000 | delete |
| 0x00020000 | read_control |
| 0x00040000 | write_dac |
| 0x00080000 | write_owner |
| 0x00100000 | synchronize |
| 0x0001 | file_read_data |
| 0x0002 | file_write_data |
| 0x0004 | file_append_data |
| 0x0008 | file_read_ea |
| 0x0010 | file_write_ea |
| 0x0020 | file_execute |
| 0x0040 | file_delete_child |
| 0x0080 | file_read_attributes |
| 0x0100 | file_write_attributes |
AD mapping (ad_mapping(), namespace "ad")
| Bit | Action | MS-DTYP name |
|---|---|---|
| 0x0001 | create_child | ADS_RIGHT_DS_CREATE_CHILD |
| 0x0002 | delete_child | ADS_RIGHT_DS_DELETE_CHILD |
| 0x0004 | list_children | ADS_RIGHT_ACTRL_DS_LIST |
| 0x0008 | self_write | ADS_RIGHT_DS_SELF |
| 0x0010 | read_prop | ADS_RIGHT_DS_READ_PROP |
| 0x0020 | write_prop | ADS_RIGHT_DS_WRITE_PROP |
| 0x0040 | delete_tree | ADS_RIGHT_DS_DELETE_TREE |
| 0x0080 | list_object | ADS_RIGHT_DS_LIST_OBJECT |
| 0x0100 | control_access | ADS_RIGHT_DS_CONTROL_ACCESS |
Plus the 5 standard rights shared with file mapping.
Registry mapping (registry_mapping(), namespace "reg")
| Bit | Action |
|---|---|
| 0x0001 | query_value |
| 0x0002 | set_value |
| 0x0004 | create_sub_key |
| 0x0008 | enumerate_sub_keys |
| 0x0010 | notify |
| 0x0020 | create_link |
Plus the 5 standard rights.
NT ↔ POSIX ACL Translation
Translating between Windows Security Descriptors and POSIX ACLs is the core
problem in Samba, NFS-ACL, and CIFS-to-NFSv4 interoperability. The
nt_posix_translate example demonstrates three approaches, from lossy to
lossless.
Run the example:
cargo run -p bac-examples --example nt_posix_translate
The impedance mismatch
Windows NT ACLs and POSIX.1e ACLs model different permission universes:
| Aspect | NT | POSIX |
|---|---|---|
| Permission granularity | 32-bit mask (14+ distinct rights) | 3-bit rwx |
| Deny support | Explicit deny ACEs | None |
| Inheritance | Per-ACE flags (CI, OI, NP, IO) | Default ACL only |
| Audit | SACL with audit ACEs | None |
| Integrity | Mandatory integrity labels | None |
| Principal identifier | SID (structured binary) | String (user/group name) |
The mapping from NT to POSIX is inherently lossy — information is destroyed in both directions.
Path 1: Direct translation (lossy)
The simplest approach maps each allow ACE to a POSIX entry:
FILE_READ_DATA→ readFILE_WRITE_DATA | FILE_APPEND_DATA→ writeFILE_EXECUTE→ execute
SIDs are mapped to POSIX names via an identity map (analogous to Samba’s idmap or SSSD).
What’s lost: deny ACEs (no POSIX equivalent), standard rights (READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE), extended attributes (FILE_READ_EA, FILE_WRITE_EA), FILE_DELETE_CHILD, inheritance flags, SACL, integrity labels.
Path 2: Out-of-band metadata (lossless)
Like Samba’s security.NTACL extended attribute: store the original NT SD
binary alongside the POSIX ACL. The POSIX ACL is the live access control
for POSIX clients, but the full SD is preserved for lossless restoration
when a Windows client requests it.
┌─────────────┐ ┌──────────────────┐
│ POSIX ACL │ │ security.NTACL │
│ (live) │ │ (268 bytes blob) │
│ user::rwx │ │ Full SD with │
│ group::rw- │ │ deny ACEs, SACL, │
│ other::r-- │ │ control flags... │
└─────────────┘ └──────────────────┘
Pros: Complete preservation of the original SD. Restore is always
lossless.
Cons: Requires out-of-band storage. The POSIX ACL and NT SD can
diverge if modified independently.
Path 3: PermissionSet with opaque masks (lossless per-ACE)
Encode the full 32-bit AccessMask per-ACE as nt_raw:SID:0xMASK inside the
acls-rs PermissionSet, alongside the decomposed win:* permissions. No
out-of-band storage needed — everything lives within the PermissionSet model.
PermissionSet {
win:file_read_data ← decomposed (for querying)
win:read_control ← decomposed
...
nt_raw:S-1-5-32-544:0x001F01FF ← original mask (for restoration)
nt_raw:S-1-1-0:0x00000081 ← original mask
}
Pros: Self-contained, no out-of-band storage. Lossless for the ACEs
that match the token.
Cons: Only preserves allow ACEs for the queried token’s SIDs (not the
full SD). Deny ACEs and SACL are not captured.
Choosing an approach
| Approach | Fidelity | Storage | Use case |
|---|---|---|---|
| Direct | Lossy | None | Quick mapping, POSIX-only consumers |
| Out-of-band | Full SD | xattr/sidecar | Samba, dual-protocol file servers |
| PermissionSet | Per-ACE | In-memory | Application-level policy comparison |
AD Schema Access Control
Active Directory stores its schema — class and attribute definitions — as
LDIF records. Each class carries a defaultSecurityDescriptor in SDDL
that controls access to instances of that class. Attributes can belong to
property sets via attributeSecurityGUID, which are referenced by Object
ACEs for property-level access control.
The ad_schema_access example parses the real Windows Server v1903 AD DS
schema, evaluates access using win-sd, translates the SDDL ACEs into
LDAP ACIs using ldap-acis, and compares the two models through the
shared acls-rs PermissionSet bridge.
Run the example:
cargo run -p bac-examples --example ad_schema_access
Crates used
| Crate | Role |
|---|---|
ldif-parser | Parse LDIF schema files into LdapEntry structures |
win-sd | Parse SDDL, evaluate access checks, hierarchical object type list |
ldap-acis | Build ACIs, evaluate LDAP authorization, generate 389DS ACI text |
acls-rs | PermissionSet bridge between the two access control models, with PermissionMapping for AD-semantic bit names |
Schema data
The example ships with the official Microsoft AD DS schema for Windows Server v1903 (4 LDIF files, ~1.5 MB). The schema contains:
- 269 classes (239 structural) — 264 with
defaultSecurityDescriptor - 1499 attributes — 206 grouped into 17 property sets
What the example does
1. Parse LDIF
Uses LdifParser::parse() from the ldif-parser crate to load the schema
files. Binary values (GUIDs) encoded as base64 in the LDIF are decoded
into win_sd::Guid using the MS GUID format (little-endian first three
fields).
2. Parse SDDL security descriptors
Each class’s defaultSecurityDescriptor is parsed with
win_sd::parse_with_domain() using a DomainContext so domain-relative
aliases (DA, EA, CA, etc.) resolve correctly.
Of 264 classes with a defaultSecurityDescriptor attribute:
- 263 parse successfully
- 1 (
domainDNS) has an empty value — see Inheritance below
An empty defaultSecurityDescriptor means the class does not define its
own default SD. Instances inherit their security descriptor entirely from
the parent container in the directory tree. For domainDNS this makes
sense: it is the top-level domain object (dc=example,dc=com) whose SD
is set by the domain provisioning process, not by a schema template.
2b. Inheritance demonstration
The example simulates what happens when a domainDNS instance is created
under a parent container whose ACEs carry CONTAINER_INHERIT|OBJECT_INHERIT
flags. It calls create_child_sd() and verifies the inherited ACEs:
Simulated parent (domain root with CI|OI) → 3 ACEs inherited:
(allow;;;DA) [full rights] inherited=true
(allow;;;SY) [full rights] inherited=true
(allow;;;AU) [READ_PROP|...] inherited=true
Access check on inherited SD:
Domain Admins READ_PROP|WRITE_PROP → GRANTED
Auth Users READ_PROP|WRITE_PROP → DENIED
Auth Users READ_PROP only → GRANTED
Note: AD schema defaultSecurityDescriptor values typically do not include
inheritance flags (CI/OI) because they are templates for new objects.
Inheritance in a live directory comes from the parent container’s actual SD,
which administrators or group policy augment with inheritable ACEs.
3. Access matrix
For key classes (user, computer, organizationalUnit, group), the
example evaluates access for common AD principals and reports which
AD-specific rights each principal holds:
| Right code | Mask bit | Meaning |
|---|---|---|
RP | 0x0010 | Read property |
WP | 0x0020 | Write property |
CC | 0x0001 | Create child |
DC | 0x0002 | Delete child |
LC | 0x0004 | List children |
LO | 0x0080 | List object |
CR | 0x0100 | Control access (extended right) |
Domain Admins and Local System typically get full rights; Authenticated Users get read-only access.
4. Property set mapping
Attributes with an attributeSecurityGUID belong to a property set. The
example builds a map from property set GUID to the list of attributes it
protects. For example:
- Personal Information (
77b5b886-...):streetAddress,homePostalAddress,telephoneNumber, and 60+ more - Account Restrictions (
4c164200-...):accountExpires,userAccountControl,pwdLastSet - Group Membership (
bc0ac240-...):member,memberOf
Object ACEs with a property set GUID in the object_type field grant or
deny access to all attributes in that set.
5. Hierarchical access check
For the user class, the example builds an ObjectTypeEntry list with the
class GUID at level 0 and property set GUIDs at level 1, then runs
check_access_by_type_list() — the MS-DTYP AccessCheckByTypeResultList
algorithm. Each property set gets its own grant/deny result.
6. SDDL → LDAP ACI translation
Each DACL ACE from the organizationalUnit class is translated into an
equivalent ldap_acis::Aci using AciBuilder. The translation maps:
| SDDL concept | LDAP ACI concept |
|---|---|
| SID alias (DA, SY, AU) | Bind rule (groupdn, userdn, userdn = "ldap:///all") |
| Allow/Deny ACE type | allow/deny |
READ_PROP, LIST_CHILDREN | read, search |
WRITE_PROP | write (modify) |
CREATE_CHILD | add |
DELETE_CHILD, DELETE | delete |
Each original SDDL ACE is shown alongside its translated 389 Directory Server ACI:
SDDL ACE: (A;;0x000F01FF;;;DA) [READ_PROP|WRITE_PROP|...|WRITE_OWNER]
LDAP ACI: (target = "ldap:///dc=example,dc=com")
(version 3.0;acl "ou-ace-1";
allow (read,search,write,add,delete)
groupdn = "ldap:///cn=Domain Admins,cn=Users,dc=example,dc=com";)
7. Cross-model comparison
The cross-model comparison begins by showing the DA-specific ACE from the
OU’s defaultSecurityDescriptor and its translated LDAP ACI side by side,
then evaluates the same access request through both models.
Two principals are tested:
- Authenticated User — read-only access (matches
BindRule::Authenticated) - Domain Admin — full access (matches
BindRule::GroupDnviamemberofon the bind entry)
As Authenticated User (jdoe):
Read win-sd=GRANTED ldap-acis=GRANTED
Write win-sd=DENIED ldap-acis=DENIED
As Domain Admin (admin, member of DA group):
Read win-sd=GRANTED ldap-acis=GRANTED
Write win-sd=GRANTED ldap-acis=GRANTED
Add win-sd=GRANTED ldap-acis=GRANTED
Delete win-sd=GRANTED ldap-acis=GRANTED
PermissionSet bridge
The bridge shows the same “Domain Admin has full access” expressed in each
model’s vocabulary. The win-sd side uses ad_mapping() to produce
AD-semantic permission names; the ldap-acis side authorizes each LDAP
operation type separately and collects the granted operations:
PermissionSet bridge (Domain Admin):
win-sd (ad): {control_access, create_child, delete_child,
list_children, list_object, read_prop, write_prop}
ldap-acis: {read, search, modify, add, delete}
The two vocabularies are not 1:1 — AD has finer-grained rights
(list_children vs list_object, control_access) while LDAP ACI
operations (read, search, modify) are coarser. The
PermissionMapping mechanism (see acls-rs Bridge) lets each
model define its own namespace so both can coexist in a single
PermissionSet without name collisions.
Limitations
The translation is inherently lossy in both directions:
- Object ACEs with GUIDs are not translated to LDAP ACIs (LDAP ACIs have no equivalent of property-set-scoped access).
- Standard rights (
READ_CONTROL,WRITE_DAC,WRITE_OWNER) have no direct LDAP ACI equivalent. - Inheritance flags (CI, OI, NP, IO) are collapsed into LDAP scope
(
subtree). - Deny ACEs translate directly, but LDAP ACI deny evaluation semantics differ from Windows SD (LDAP uses last-match with priority; Windows uses first-match with deny-first ordering).
These gaps demonstrate why interoperability between AD and LDAP directory servers is a hard problem — the same data can be modeled, but the access control semantics diverge at the edges.
Python Bindings
The acls-rs crate provides Python bindings via PyO3, allowing you to use the algebraically-correct permissions system from Python with native Rust performance.
Installation
Prerequisites
- Python 3.8 or higher
- Rust 1.70 or higher (for building from source)
- maturin for building
Building from Source
# Install maturin
pip install maturin
# Navigate to the acls-python crate (Python bindings are in a separate crate)
cd crates/acls-python
# Build and install in development mode
maturin develop
# Or build a release wheel
maturin build --release
Quick Start
from acls_rs import AtomicPermission, RuleBuilder, AbacPolicy
# Create a policy
policy = AbacPolicy()
# Define permissions
read = AtomicPermission("file", "read")
write = AtomicPermission("file", "write")
# Create a rule: engineers can read files (fluent API)
rule = (RuleBuilder()
.grant(read, {"department": "engineering"})
.build())
policy.add_rule(rule)
# Evaluate against a context
context = {"department": "engineering"}
permissions = policy.evaluate(context)
for perm in permissions:
print(f"Allowed: {perm}")
API Reference
AtomicPermission
Represents an atomic permission with a namespace (resource type) and action.
AtomicPermission(namespace: str, action: str)
Parameters:
namespace: The resource type (e.g., “file”, “user”, “document”)action: The operation (e.g., “read”, “write”, “delete”)
Methods:
namespace() → str: Get the namespaceaction() → str: Get the action
Example:
perm = AtomicPermission("file", "read")
print(perm) # file:read
print(perm.namespace()) # file
print(perm.action()) # read
RuleBuilder
Builder for creating ABAC rules with grants and denials.
RuleBuilder()
Methods:
grant(permission, attributes) -> RuleBuilder
Add a grant condition to the rule. Returns self for method chaining.
permission(AtomicPermission): The permission to grantattributes(dict[str, str]): Required attribute matches
Returns: RuleBuilder - Returns self to enable fluent chaining
deny(permission, attributes) -> RuleBuilder
Add a denial condition to the rule. Returns self for method chaining.
permission(AtomicPermission): The permission to denyattributes(dict[str, str]): Required attribute matches
Returns: RuleBuilder - Returns self to enable fluent chaining
build()
Build the rule and consume the builder.
Returns: AttributeRule
Example:
# Fluent API for chaining method calls
rule = (RuleBuilder()
.grant(
AtomicPermission("file", "read"),
{"department": "engineering"}
)
.deny(
AtomicPermission("file", "delete"),
{"level": "junior"}
)
.build())
policy.add_rule(rule)
Subject
Represents a user with optional role memberships and explicit permission grants/denials.
Subject(name: str)
Parameters:
name: The user’s identifier
Methods:
add_role(role: str) -> None: Add a role membership.grant(perm: AtomicPermission) -> None: Explicitly grant a permission to this subject.revoke(perm: AtomicPermission) -> None: Remove an explicit grant.deny(perm: AtomicPermission) -> None: Explicitly deny a permission (takes precedence over grants).effective_permissions() -> PermissionSet: Return the net effective permissions after applying all grants and denials.
Builder pattern:
user = (Subject.builder("alice")
.role("developers")
.grant(AtomicPermission("file", "read"))
.build())
Example:
from acls_rs import Subject, AtomicPermission
user = Subject("alice")
user.add_role("developers")
user.grant(AtomicPermission("file", "read"))
user.deny(AtomicPermission("secrets", "read"))
perms = user.effective_permissions()
print(AtomicPermission("file", "read") in perms) # True
print(AtomicPermission("secrets", "read") in perms) # False
PermissionSet
An unordered set of AtomicPermission values with set-algebraic operations.
PermissionSet()
Methods:
insert(perm: AtomicPermission) -> None: Insert a permission.remove(perm: AtomicPermission) -> bool: Remove a permission; returnsTrueif it was present.union(other: PermissionSet) -> PermissionSet: Return the union of two sets.intersection(other: PermissionSet) -> PermissionSet: Return the intersection of two sets.difference(other: PermissionSet) -> PermissionSet: Return permissions inselfthat are not inother.
PermissionSet supports in for membership tests and for perm in ps for iteration.
Builder pattern:
ps = (PermissionSet.builder()
.insert(AtomicPermission("file", "read"))
.insert(AtomicPermission("file", "write"))
.build())
Example:
from acls_rs import PermissionSet, AtomicPermission
ps = PermissionSet()
ps.insert(AtomicPermission("file", "read"))
ps.insert(AtomicPermission("file", "write"))
other = PermissionSet()
other.insert(AtomicPermission("file", "write"))
other.insert(AtomicPermission("file", "delete"))
print(AtomicPermission("file", "read") in ps) # True
print(list(ps.intersection(other))) # [file:write]
removed = ps.remove(AtomicPermission("file", "read"))
print(removed) # True
GrantDenialPair
Holds a paired set of granted and denied permissions. Denials always take precedence over grants.
from acls_rs import GrantDenialPair, PermissionSet
pair = GrantDenialPair(grants=PermissionSet(), denials=PermissionSet())
Methods:
grant(perm: AtomicPermission) -> None: Add a permission to the grants set.revoke(perm: AtomicPermission) -> None: Remove a permission from the grants set.deny(perm: AtomicPermission) -> None: Add a permission to the denials set.undeny(perm: AtomicPermission) -> None: Remove a permission from the denials set.effective_permissions() -> PermissionSet: Return grants minus denials.
Example:
from acls_rs import GrantDenialPair, PermissionSet, AtomicPermission
pair = GrantDenialPair(grants=PermissionSet(), denials=PermissionSet())
pair.grant(AtomicPermission("file", "read"))
pair.grant(AtomicPermission("file", "write"))
pair.deny(AtomicPermission("file", "write")) # takes precedence
effective = pair.effective_permissions()
print(AtomicPermission("file", "read") in effective) # True
print(AtomicPermission("file", "write") in effective) # False
TemporalPermission
An AtomicPermission with an optional validity window defined by millisecond timestamps.
TemporalPermission(perm: AtomicPermission, valid_from: int | None = None, valid_until: int | None = None)
Raises: ValueError if valid_from > valid_until.
Static factory methods:
TemporalPermission.valid_for_duration(perm, duration_ms: int)— active from now forduration_msmilliseconds.TemporalPermission.valid_from(perm, from_ts: int)— active fromfrom_tsonwards.TemporalPermission.valid_until(perm, deadline: int)— active untildeadline.
Methods:
is_currently_valid() -> bool: Active at the current wall-clock time.is_valid_at(timestamp: int) -> bool: Active at the given millisecond timestamp.
TemporalPermissionSet
A collection of TemporalPermission values. Evaluates which permissions are currently active or active at a given point in time.
from acls_rs import TemporalPermissionSet
tps = TemporalPermissionSet()
Methods:
add(tp: TemporalPermission) -> None: Add a temporal permission.remove(perm: AtomicPermission) -> bool: Remove all temporal entries forperm; returnsTrueif any were present.effective_now() -> PermissionSet: Permissions that are currently valid.effective_at(timestamp: int) -> PermissionSet: Permissions valid at the given millisecond timestamp.
Example:
from acls_rs import TemporalPermission, TemporalPermissionSet, AtomicPermission, current_timestamp_millis
now = current_timestamp_millis()
perm = AtomicPermission("file", "read")
tp = TemporalPermission(perm, valid_from=now, valid_until=now + 3600000)
tps = TemporalPermissionSet()
tps.add(tp)
print(AtomicPermission("file", "read") in tps.effective_now()) # True
print(AtomicPermission("file", "read") in tps.effective_at(now + 7200000)) # False
PermissionDelta
Represents a set of changes (additions and removals) to a PermissionSet. Useful for computing and applying incremental permission updates.
from acls_rs import PermissionDeltaBuilder
delta = (PermissionDeltaBuilder()
.grant(AtomicPermission("file", "write"))
.remove(AtomicPermission("file", "read"))
.build())
Methods:
apply_to(ps: PermissionSet) -> PermissionSet: Apply the delta topsand return the resulting set.invert() -> PermissionDelta: Return a delta that undoes this one.is_empty() -> bool: True if the delta contains no changes.total_changes() -> int: Total number of individual add/remove operations.
PermissionEffect
Previews or applies the effect of a sequence of grant and revoke operations on a base PermissionSet.
from acls_rs import PermissionEffectBuilder, PermissionSet, AtomicPermission
builder = (PermissionEffectBuilder(PermissionSet())
.grant(AtomicPermission("file", "write"))
.revoke(AtomicPermission("file", "read")))
preview = builder.preview_effect() # PermissionEffect (non-consuming preview)
has_changes = builder.has_changes() # bool
grants, effect = builder.build() # tuple[GrantDenialPair, PermissionEffect]
Methods on the builder:
grant(perm: AtomicPermission) -> PermissionEffectBuilder: Stage a grant.revoke(perm: AtomicPermission) -> PermissionEffectBuilder: Stage a revocation.preview_effect() -> PermissionEffect: Compute the effect without consuming the builder.has_changes() -> bool: True if any grants or revocations have been staged.build() -> tuple[GrantDenialPair, PermissionEffect]: Finalise and return the pair plus the resulting effect.
RbacPolicy
Role-Based Access Control policy. Used directly for role management and also consumed by ComposedPolicy in the hbac_rs and abac_rs modules.
from acls_rs import RbacPolicy, Role, AtomicPermission
rbac = RbacPolicy()
Methods:
add_role(role: Role) -> None: Add a role definition.get_role(name: str) -> Role | None: Retrieve a role by name.remove_role(name: str) -> Role | None: Remove and return a role.roles() -> list[Role]: Return all roles.
Role
A named collection of granted and denied permissions, with optional parent role inheritance.
role = Role("developers",
grants=[AtomicPermission("code", "read"), AtomicPermission("code", "write")],
denials=[])
Parameters:
name(str): Role identifiergrants(list[AtomicPermission]): Permissions this role grantsdenials(list[AtomicPermission]): Permissions this role explicitly denies
Methods:
with_parent(parent_name: str) -> Role: Declare a parent role for inheritance (returnsself).
Example:
from acls_rs import RbacPolicy, Role, AtomicPermission
# Build role hierarchy
employee = Role("employees",
grants=[AtomicPermission("office", "enter")],
denials=[])
developer = (Role("developers",
grants=[AtomicPermission("code", "read"), AtomicPermission("code", "write")],
denials=[])
.with_parent("employees"))
rbac = RbacPolicy()
rbac.add_role(employee)
rbac.add_role(developer)
print(rbac.get_role("developers") is not None) # True
current_timestamp_millis
Utility function that returns the current wall-clock time as a millisecond Unix timestamp.
from acls_rs import current_timestamp_millis
now = current_timestamp_millis()
future = now + 3600 * 1000 # one hour from now
AbacPolicy
Attribute-Based Access Control policy engine.
AbacPolicy()
Methods:
add_rule(rule) -> AbacPolicy
Add a rule to the policy. Returns self for method chaining (fluent API).
rule(AttributeRule): The rule to add
Returns: AbacPolicy - Returns self to enable fluent chaining
evaluate(context)
Evaluate the policy against an attribute context.
context(dict[str, str]): The attribute context
Returns: list[AtomicPermission] - The effective permissions after conflict resolution
rule_count()
Get the number of rules in the policy.
Returns: int
Example:
# Non-fluent style
policy = AbacPolicy()
policy.add_rule(rule1.build())
policy.add_rule(rule2.build())
# Fluent style - chain rule additions
policy = (AbacPolicy()
.add_rule(rule1.build())
.add_rule(rule2.build()))
context = {"department": "sales", "level": "manager"}
permissions = policy.evaluate(context)
print(f"Policy has {policy.rule_count()} rules")
Examples
Basic ABAC Evaluation
from acls_rs import AtomicPermission, RuleBuilder, AbacPolicy
policy = AbacPolicy()
# Define permissions
read = AtomicPermission("document", "read")
write = AtomicPermission("document", "write")
# Rule: Managers can read and write (fluent API)
manager_rule = (RuleBuilder()
.grant(read, {"role": "manager"})
.grant(write, {"role": "manager"})
.build())
policy.add_rule(manager_rule)
# Rule: Employees can only read (fluent API)
employee_rule = (RuleBuilder()
.grant(read, {"role": "employee"})
.build())
policy.add_rule(employee_rule)
# Evaluate for a manager
manager_perms = policy.evaluate({"role": "manager"})
print([str(p) for p in manager_perms])
# ['document:read', 'document:write']
# Evaluate for an employee
employee_perms = policy.evaluate({"role": "employee"})
print([str(p) for p in employee_perms])
# ['document:read']
Multi-Attribute Matching
ABAC rules support multiple attributes. All attributes in the rule must match for the permission to apply.
from acls_rs import AtomicPermission, RuleBuilder, AbacPolicy
policy = AbacPolicy()
# Rule: Senior security engineers can access secrets (fluent API)
secret_rule = (RuleBuilder()
.grant(
AtomicPermission("secrets", "read"),
{
"department": "engineering",
"team": "security",
"level": "senior"
}
)
.build())
policy.add_rule(secret_rule)
# All attributes match - access granted
context1 = {
"department": "engineering",
"team": "security",
"level": "senior"
}
result1 = policy.evaluate(context1)
print(len(result1)) # 1
# Missing "team" attribute - access denied
context2 = {
"department": "engineering",
"level": "senior"
}
result2 = policy.evaluate(context2)
print(len(result2)) # 0
Denial Rules
Denials take precedence over grants in the default conflict resolution strategy.
from acls_rs import AtomicPermission, RuleBuilder, AbacPolicy
policy = AbacPolicy()
delete = AtomicPermission("file", "delete")
# Grant delete to admins (fluent API)
admin_rule = (RuleBuilder()
.grant(delete, {"role": "admin"})
.build())
policy.add_rule(admin_rule)
# But deny delete to read-only admins (fluent API)
readonly_rule = (RuleBuilder()
.deny(delete, {"readonly": "true"})
.build())
policy.add_rule(readonly_rule)
# Regular admin can delete
result1 = policy.evaluate({"role": "admin", "readonly": "false"})
print(len(result1)) # 1
# Read-only admin cannot delete (denial wins)
result2 = policy.evaluate({"role": "admin", "readonly": "true"})
print(len(result2)) # 0
Dynamic Role Assignment
from acls_rs import AtomicPermission, RuleBuilder, AbacPolicy
policy = AbacPolicy()
# Define permissions
read = AtomicPermission("api", "read")
write = AtomicPermission("api", "write")
admin = AtomicPermission("api", "admin")
# Viewers can read (fluent API)
viewer_rule = (RuleBuilder()
.grant(read, {"role": "viewer"})
.build())
policy.add_rule(viewer_rule)
# Editors can read and write (fluent API)
editor_rule = (RuleBuilder()
.grant(read, {"role": "editor"})
.grant(write, {"role": "editor"})
.build())
policy.add_rule(editor_rule)
# Admins can do everything (fluent API)
admin_rule = (RuleBuilder()
.grant(read, {"role": "admin"})
.grant(write, {"role": "admin"})
.grant(admin, {"role": "admin"})
.build())
policy.add_rule(admin_rule)
# Evaluate for different roles
def check_permissions(role):
perms = policy.evaluate({"role": role})
print(f"{role}: {[str(p) for p in perms]}")
check_permissions("viewer")
# viewer: ['api:read']
check_permissions("editor")
# editor: ['api:read', 'api:write']
check_permissions("admin")
# admin: ['api:admin', 'api:read', 'api:write']
Performance
The Python bindings use PyO3’s zero-cost abstractions, providing:
- Native speed: Policy evaluation runs at Rust performance
- Minimal overhead: Type conversions happen only at the Python/Rust boundary
- Memory efficiency: Rules and policies are stored in Rust memory
For high-throughput scenarios, consider:
- Building your policy once and reusing it for multiple evaluations
- Batching context evaluations if possible
- Using the Rust API directly for maximum performance
See Also
- ABAC Policies in acls-rs - Core ABAC concepts
- ABAC Rules - Detailed rule semantics
- API Documentation - Full Rust API reference
HBAC Python Bindings
The hbac-rs crate provides Python bindings for FreeIPA Host-Based Access Control (HBAC) rule evaluation via PyO3, allowing you to evaluate HBAC policies from Python with native Rust performance.
Installation
Prerequisites
- Python 3.8 or higher
- Rust 1.70 or higher (for building from source)
- maturin for building
Building from Source
# Install maturin
pip install maturin
# Navigate to the hbac-python crate (Python bindings are in a separate crate)
cd crates/hbac-python
# Build and install in development mode
maturin develop
# Or build a release wheel
maturin build --release
Quick Start
from hbac_rs import HbacRuleBuilder, HbacRequest, HbacPolicy, Subject
# Create a policy
policy = HbacPolicy()
# Create a rule: allow admins to access all services on all hosts (fluent API)
rule = (HbacRuleBuilder("allow_admins")
.user_group("admins")
.host_category_all()
.service_category_all()
.enabled(True)
.build())
policy.add_rule(rule)
# Create a user with group membership
user = Subject("alice")
user.add_group("admins")
# Create a request
request = HbacRequest(user, "server.example.com", "sshd")
# Evaluate
result = policy.evaluate(request)
print(result.is_allowed()) # True
API Reference
Subject
Represents a user attempting access.
Subject(name: str)
Parameters:
name: The user’s identifier
Methods:
add_group(group)
Add a group membership to the user.
group(str): Group name
Returns: None
groups()
Get all group memberships.
Returns: list[str]
name()
Get the user’s name.
Returns: str
Example:
user = Subject("alice")
user.add_group("admins")
user.add_group("developers")
print(user.name()) # alice
print(user.groups()) # ['admins', 'developers']
HbacRuleBuilder
Builder for creating HBAC rules.
HbacRuleBuilder(name: str)
Parameters:
name: The rule identifier
Methods:
enabled(enabled) -> HbacRuleBuilder
Set whether the rule is enabled. Returns self for method chaining.
enabled(bool): Enable or disable the rule
Returns: HbacRuleBuilder - Returns self to enable fluent chaining
deny() -> HbacRuleBuilder
Mark this as a deny rule (default is allow). Returns self for method chaining.
Returns: HbacRuleBuilder - Returns self to enable fluent chaining
User Dimension
All methods return HbacRuleBuilder for fluent chaining:
user(user: str) -> HbacRuleBuilder: Add a specific useruser_group(group: str) -> HbacRuleBuilder: Add a user groupuser_category_all() -> HbacRuleBuilder: Match all users
Host Dimension
All methods return HbacRuleBuilder for fluent chaining:
host(host: str) -> HbacRuleBuilder: Add a specific hosthost_group(group: str) -> HbacRuleBuilder: Add a host grouphost_category_all() -> HbacRuleBuilder: Match all hosts
Service Dimension
All methods return HbacRuleBuilder for fluent chaining:
service(service: str) -> HbacRuleBuilder: Add a specific serviceservice_group(group: str) -> HbacRuleBuilder: Add a service groupservice_category_all() -> HbacRuleBuilder: Match all services
build()
Build the rule.
Returns: HbacRule
Raises: ValueError if the rule configuration is invalid
Example:
# Allow engineers to SSH to development servers (fluent API)
hbac_rule = (HbacRuleBuilder("dev_ssh_access")
.user_group("engineers")
.host_group("development")
.service("sshd")
.enabled(True)
.build())
HbacRequest
Represents an access request to evaluate.
HbacRequest(user: Subject, targethost: str, service: str)
Parameters:
user: The user attempting accesstargethost: The target host being accessedservice: The service being accessed
Methods:
add_targethost_group(group)
Add a host group membership.
group(str): Host group name
Returns: None
add_service_group(group)
Add a service group membership.
group(str): Service group name
Returns: None
targethost()
Get the target host.
Returns: str
service()
Get the service.
Returns: str
Example:
user = Subject("alice")
user.add_group("admins")
request = HbacRequest(user, "db-01.prod", "postgresql")
request.add_targethost_group("production")
request.add_targethost_group("databases")
request.add_service_group("database-services")
HbacPolicy
HBAC policy evaluation engine.
HbacPolicy()
Methods:
add_rule(rule) -> HbacPolicy
Add a rule to the policy. Returns self for method chaining (fluent API).
rule(HbacRule): The rule to add
Returns: HbacPolicy - Returns self to enable fluent chaining
Raises: ValueError if adding the rule fails
evaluate(request)
Evaluate a request against the policy.
request(HbacRequest): The access request
Returns: HbacEvaluationResult
check_access(request)
Quick check if access is allowed.
request(HbacRequest): The access request
Returns: bool - True if allowed, False otherwise
remove_rule(name) -> None
Remove a rule from the policy by name.
name(str): The rule identifier
Returns: None
enable_rule(name) -> None
Enable a previously disabled rule.
name(str): The rule identifier
Returns: None
disable_rule(name) -> None
Disable a rule without removing it.
name(str): The rule identifier
Returns: None
load_rules(rules) -> HbacPolicy
Bulk-load a list of rules. Returns self for method chaining.
rules(list[HbacRule]): Rules to add
Returns: HbacPolicy
rules() -> list[HbacRule]
Get all rules in the policy.
Returns: list[HbacRule]
rule_count() -> int
Get the number of rules in the policy.
Returns: int
clear() -> None
Remove all rules from the policy.
Returns: None
max_rules() -> int
Get the configured rule limit. Returns 0 if there is no limit.
Returns: int
stats() -> CacheStats
Get evaluation cache statistics.
Returns: CacheStats
evaluate_at(request, timestamp) -> HbacEvaluationResult
Evaluate a request against the policy at a specific millisecond timestamp. Useful for testing temporal rules at a known point in time.
request(HbacRequest): The access requesttimestamp(int): Millisecond timestamp
Returns: HbacEvaluationResult
evaluate_detailed(request) -> HbacEvaluationResult
Evaluate a request with full rule tracking. Populates matched_rules() and not_matched_rules() on the result. Slightly slower than evaluate() due to tracking overhead.
request(HbacRequest): The access request
Returns: HbacEvaluationResult
evaluate_detailed_at(request, timestamp) -> HbacEvaluationResult
Evaluate at a specific millisecond timestamp with full rule tracking.
request(HbacRequest): The access requesttimestamp(int): Millisecond timestamp
Returns: HbacEvaluationResult
Example:
# Non-fluent style
policy = HbacPolicy()
policy.add_rule(rule1.build())
policy.add_rule(rule2.build())
# Fluent style - chain rule additions
policy = (HbacPolicy()
.add_rule(rule1.build())
.add_rule(rule2.build()))
request = HbacRequest(user, "server", "sshd")
if policy.check_access(request):
print("Access allowed")
# Bulk-load and inspect
policy2 = HbacPolicy().load_rules([rule1.build(), rule2.build()])
print(f"Policy has {policy2.rule_count()} rules (limit: {policy2.max_rules() or 'none'})")
# Detailed evaluation with rule tracking
result = policy.evaluate_detailed(request)
print("Matched:", result.matched_rules())
print("Not matched:", result.not_matched_rules())
# Cache statistics
stats = policy.stats()
print(f"Memory: {stats.memory_bytes} bytes")
HbacEvaluationResult
Result of an HBAC evaluation.
Methods:
is_allowed()
Check if access is allowed.
Returns: bool
is_denied()
Check if access is explicitly denied.
Returns: bool
warnings() -> list[str]
Return warning messages generated during evaluation. A non-empty list means the access decision may not reflect full policy intent — for example, when RBAC role resolution failed and the decision was made with degraded information.
Returns: list[str]
has_warnings() -> bool
Check whether any warnings were generated.
Returns: bool
matched_rules() -> list[str]
Return the names of rules that matched the request. Only populated when the request was evaluated via evaluate_detailed() or evaluate_detailed_at().
Returns: list[str]
not_matched_rules() -> list[str]
Return the names of rules that were candidates but did not match the request. Only populated when evaluated via evaluate_detailed() or evaluate_detailed_at().
Returns: list[str]
Example:
result = policy.evaluate_detailed(request)
if result.is_allowed():
print("Access granted")
print("Matched rules:", result.matched_rules())
elif result.is_denied():
print("Access explicitly denied")
print("Candidates that did not match:", result.not_matched_rules())
if result.has_warnings():
print("Degraded decision — warnings:", result.warnings())
HbacRequestBuilder
Fluent builder for constructing HbacRequest objects. Use this instead of the HbacRequest constructor when you want to build requests incrementally.
HbacRequestBuilder()
Methods:
All methods return HbacRequestBuilder for fluent chaining:
user(subject: Subject) -> HbacRequestBuilder: Set the user (required)targethost(host: str) -> HbacRequestBuilder: Set the target host (required)service(svc: str) -> HbacRequestBuilder: Set the service (required)targethost_group(group: str) -> HbacRequestBuilder: Add a host group membershipservice_group(group: str) -> HbacRequestBuilder: Add a service group membership
build() -> HbacRequest
Build the request.
Returns: HbacRequest
Raises: ValueError if user, targethost, or service have not been set.
Example:
from hbac_rs import HbacRequestBuilder, Subject
user = Subject("alice")
user.add_group("admins")
request = (HbacRequestBuilder()
.user(user)
.targethost("server.example.com")
.service("sshd")
.targethost_group("production")
.service_group("secure-services")
.build())
TemporalHbacRule
Wraps an HbacRule with a time-based validity window. A temporal rule only participates in evaluation when the wall-clock time (or the timestamp passed to evaluate_at) falls within its window.
TemporalHbacRule(rule: HbacRule, valid_from: int | None = None, valid_until: int | None = None)
Parameters:
rule: The underlyingHbacRulevalid_from: Millisecond timestamp at which the rule becomes active (inclusive).Nonemeans active from the beginning of time.valid_until: Millisecond timestamp at which the rule expires (exclusive).Nonemeans never expires.
Raises: ValueError if valid_from > valid_until.
Static factory methods:
TemporalHbacRule.valid_until(rule, until: int) -> TemporalHbacRule: Rule active until the given timestamp.TemporalHbacRule.valid_from(rule, from_ts: int) -> TemporalHbacRule: Rule active from the given timestamp onwards.TemporalHbacRule.valid_for_duration(rule, duration_ms: int) -> TemporalHbacRule: Rule active forduration_msmilliseconds starting from now.
Methods:
is_currently_active() -> bool
Check whether the rule is active at the current wall-clock time.
Returns: bool
is_active_at(timestamp: int) -> bool
Check whether the rule would be active at the given millisecond timestamp.
Returns: bool
Example:
from hbac_rs import HbacRuleBuilder, TemporalHbacRule, HbacPolicy, current_timestamp_millis
base_rule = (HbacRuleBuilder("temp_access")
.user_group("contractors")
.host_group("staging")
.service("sshd")
.enabled(True)
.build())
now = current_timestamp_millis()
one_hour = 3600 * 1000
# Valid only for the next hour
temp_rule = TemporalHbacRule.valid_for_duration(base_rule, duration_ms=one_hour)
# Valid until a specific deadline
deadline_ms = now + 7 * 24 * 3600 * 1000 # 7 days from now
temp_rule2 = TemporalHbacRule(base_rule, valid_until=deadline_ms)
policy = HbacPolicy()
policy.add_temporal_rule(temp_rule)
policy.add_temporal_rule(temp_rule2)
print(temp_rule.is_currently_active()) # True
print(temp_rule.is_active_at(now + one_hour + 1)) # False
HbacResource
Models a protected resource that requires a specific AtomicPermission from acls_rs. Useful when HBAC rules are combined with permission-based access control.
Methods:
can_access(subject: Subject) -> bool
Check whether the subject has the required permission to access this resource.
Returns: bool
grant_access(subject: Subject) -> bool
Grant the required permission to the subject.
Returns: bool
deny_access(subject: Subject) -> bool
Explicitly deny the required permission for the subject.
Returns: bool
required_permission() -> AtomicPermission
Return the AtomicPermission this resource requires.
Returns: AtomicPermission (from acls_rs)
HbacResourceBuilder
Fluent builder for HbacResource.
Example:
from acls_rs import AtomicPermission
from hbac_rs import HbacResourceBuilder, Subject
resource = (HbacResourceBuilder()
.required_permission(AtomicPermission("db", "read"))
.build())
user = Subject("alice")
resource.grant_access(user)
print(resource.can_access(user)) # True
CacheStats
Statistics about evaluation cache performance and memory usage, returned by HbacPolicy.stats().
Fields (read-only):
stats.rule_count # int — total number of rules in the policy
stats.memory_bytes # int — estimated memory usage in bytes
stats.hit_count # int — number of cache hits
stats.miss_count # int — number of cache misses
Example:
stats = policy.stats()
total = stats.hit_count + stats.miss_count
hit_rate = stats.hit_count / total if total else 0.0
print(f"Cache hit rate: {hit_rate:.1%}")
print(f"Memory usage: {stats.memory_bytes} bytes")
current_timestamp_millis
Utility function that returns the current wall-clock time as a millisecond Unix timestamp. Use this with evaluate_at(), TemporalHbacRule, and TemporalPermission.
from hbac_rs import current_timestamp_millis
now = current_timestamp_millis()
future = now + 3600 * 1000 # one hour from now
Policy Composition
ComposedPolicy lets you combine an HbacPolicy (host-based access control) with an acls_rs.RbacPolicy (role-based permissions) into a single evaluation step.
CompositionMode
Determines how the HBAC and RBAC results are combined.
Static factory methods:
CompositionMode.and_mode()— Both HBAC and RBAC must allow (defense in depth).CompositionMode.or_mode()— Either HBAC or RBAC allowing is sufficient.CompositionMode.hbac_first()— HBAC is the primary policy; RBAC is consulted only when HBAC has no applicable rules.CompositionMode.rbac_first()— RBAC is the primary policy; HBAC is consulted only when RBAC has no applicable rules.
ComposedPolicy
from hbac_rs import ComposedPolicy, CompositionMode, HbacPolicy, HbacRequestBuilder, HbacRuleBuilder, Subject
import acls_rs
# Build HBAC policy
hbac = HbacPolicy()
hbac.add_rule(
HbacRuleBuilder("allow_devs")
.user_group("developers")
.host_group("staging")
.service("sshd")
.enabled(True)
.build()
)
# Build RBAC policy
rbac = acls_rs.RbacPolicy()
dev_role = acls_rs.Role("developers",
grants=[acls_rs.AtomicPermission("host", "ssh")],
denials=[])
rbac.add_role(dev_role)
# Compose — both must allow
mode = CompositionMode.and_mode()
policy = ComposedPolicy(hbac, rbac, mode)
# Evaluate
user = Subject("alice")
user.add_group("developers")
request = (HbacRequestBuilder()
.user(user)
.targethost("staging-01.example.com")
.service("sshd")
.targethost_group("staging")
.build())
result = policy.evaluate(request)
if result.has_warnings():
print("Degraded decision:", result.warnings())
if result.is_allowed():
print("Access granted")
Composition modes at a glance:
| Mode | Behaviour |
|---|---|
and_mode() | Both HBAC and RBAC must allow. Highest security. |
or_mode() | Either HBAC or RBAC allowing is sufficient. Useful during migration. |
hbac_first() | HBAC drives the decision; RBAC is the fallback. |
rbac_first() | RBAC drives the decision; HBAC is the fallback. |
Examples
Basic SSH Access Control
from hbac_rs import HbacRuleBuilder, HbacRequest, HbacPolicy, Subject
policy = HbacPolicy()
# Rule 1: Allow admins SSH access to all servers (fluent API)
admin_rule = (HbacRuleBuilder("admin_ssh")
.user_group("admins")
.host_category_all()
.service("sshd")
.enabled(True)
.build())
policy.add_rule(admin_rule)
# Rule 2: Allow developers SSH to dev servers only (fluent API)
dev_rule = (HbacRuleBuilder("dev_ssh")
.user_group("developers")
.host_group("development")
.service("sshd")
.enabled(True)
.build())
policy.add_rule(dev_rule)
# Test admin access
admin_user = Subject("bob")
admin_user.add_group("admins")
admin_request = HbacRequest(admin_user, "prod-server-01", "sshd")
print(policy.check_access(admin_request)) # True
# Test developer access to production (denied)
dev_user = Subject("alice")
dev_user.add_group("developers")
prod_request = HbacRequest(dev_user, "prod-server-01", "sshd")
print(policy.check_access(prod_request)) # False
# Test developer access to development (allowed)
dev_request = HbacRequest(dev_user, "dev-server-01", "sshd")
dev_request.add_targethost_group("development")
print(policy.check_access(dev_request)) # True
Multiple Services
from hbac_rs import HbacRuleBuilder, HbacRequest, HbacPolicy, Subject
policy = HbacPolicy()
# Allow database admins to access multiple database services (fluent API)
db_rule = (HbacRuleBuilder("database_access")
.user_group("dba")
.host_group("databases")
.service("postgresql")
.service("mysql")
.service("mongodb")
.enabled(True)
.build())
policy.add_rule(db_rule)
# Test PostgreSQL access
dba = Subject("charlie")
dba.add_group("dba")
pg_request = HbacRequest(dba, "db-pg-01", "postgresql")
pg_request.add_targethost_group("databases")
print(policy.check_access(pg_request)) # True
# Test MySQL access
mysql_request = HbacRequest(dba, "db-mysql-01", "mysql")
mysql_request.add_targethost_group("databases")
print(policy.check_access(mysql_request)) # True
Deny Rules
from hbac_rs import HbacRuleBuilder, HbacRequest, HbacPolicy, Subject
policy = HbacPolicy()
# Allow rule: engineers can access development (fluent API)
allow_rule = (HbacRuleBuilder("allow_dev")
.user_group("engineers")
.host_group("development")
.service_category_all()
.enabled(True)
.build())
policy.add_rule(allow_rule)
# Deny rule: suspended users cannot access anything (fluent API)
deny_rule = (HbacRuleBuilder("deny_suspended")
.user_group("suspended")
.host_category_all()
.service_category_all()
.deny()
.enabled(True)
.build())
policy.add_rule(deny_rule)
# Engineer with normal access
engineer = Subject("dave")
engineer.add_group("engineers")
request = HbacRequest(engineer, "dev-01", "sshd")
request.add_targethost_group("development")
print(policy.check_access(request)) # True
# Suspended engineer (deny takes precedence)
suspended = Subject("eve")
suspended.add_group("engineers")
suspended.add_group("suspended")
request = HbacRequest(suspended, "dev-01", "sshd")
request.add_targethost_group("development")
result = policy.evaluate(request)
print(result.is_denied()) # True
Category Matching
from hbac_rs import HbacRuleBuilder, HbacRequest, HbacPolicy, Subject
policy = HbacPolicy()
# Emergency access: allow on-call engineers to access everything (fluent API)
emergency_rule = (HbacRuleBuilder("emergency_access")
.user_group("oncall")
.host_category_all()
.service_category_all()
.enabled(True)
.build())
policy.add_rule(emergency_rule)
# On-call engineer can access any service on any host
oncall = Subject("frank")
oncall.add_group("oncall")
# Access production database
request1 = HbacRequest(oncall, "prod-db-01", "postgresql")
print(policy.check_access(request1)) # True
# Access web server
request2 = HbacRequest(oncall, "web-server", "httpd")
print(policy.check_access(request2)) # True
Performance
The Python bindings use PyO3’s zero-cost abstractions with the single-threaded HbacPolicyLocal variant:
- Native speed: Rule evaluation runs at Rust performance
- Minimal overhead: Type conversions only at the Python/Rust boundary
- Optimized caching: 5-layer evaluation pipeline with LRU cache and Bloom filters
For high-throughput scenarios:
- Build your policy once and reuse it for multiple evaluations
- Add all rules before starting evaluations (triggers pipeline optimization)
- Consider batching if possible
See Also
- HBAC Rules - Detailed rule semantics
- HBAC Evaluation - Policy evaluation details
- HBAC Caching - Performance optimization
- Python Bindings for ACLS - Core ABAC bindings
Python Bindings: ABAC
The abac-rs crate provides Python bindings for generic Attribute-Based Access Control (ABAC) evaluation. This allows you to use the high-performance Rust ABAC engine from Python applications.
Installation
# Using maturin (recommended for development)
cd crates/abac-python
maturin develop
# Or build and install a wheel
maturin build --release
pip install target/wheels/abac_rs-*.whl
See the Installation Guide for detailed setup instructions.
Quick Start
from abac_rs import AbacRuleBuilder, AbacRequest, AbacPolicy, AttributeType
# Create a policy
policy = AbacPolicy()
# Add a rule: engineers can read production databases (using fluent API)
rule = (AbacRuleBuilder("allow-engineers-prod-read")
.dimension_values("user", [AttributeType.string("group:engineers")])
.dimension_values("resource", [AttributeType.string("prod:db-01")])
.dimension_values("action", [AttributeType.string("read")])
.enabled(True)
.build())
policy.add_rule(rule)
# Evaluate a request
request = AbacRequest()
request.add_attribute("user", AttributeType.string("alice"),
[AttributeType.string("group:engineers")])
request.add_attribute("resource", AttributeType.string("prod:db-01"), [])
request.add_attribute("action", AttributeType.string("read"), [])
if policy.evaluate(request):
print("Access granted!")
API Reference
AttributeType
Represents a typed attribute value that can be used in ABAC rules and requests.
Methods
AttributeType.string(value: str) -> AttributeType
Create a string attribute.
attr = AttributeType.string("group:engineers")
AttributeType.integer(value: int) -> AttributeType
Create an integer attribute.
attr = AttributeType.integer(42)
AttributeType.float(value: float) -> AttributeType
Create a floating-point attribute.
attr = AttributeType.float(3.14)
AttributeType.ip_addr(value: str) -> AttributeType
Create an IP address attribute.
attr = AttributeType.ip_addr("192.168.1.1")
AttributeType.ip_cidr(value: str) -> AttributeType
Create an IP CIDR range attribute.
attr = AttributeType.ip_cidr("10.0.0.0/8")
AbacRuleBuilder
Builder for creating ABAC rules.
Constructor
builder = AbacRuleBuilder(name: str)
Methods
dimension_values(dimension: str, values: list[AttributeType]) -> AbacRuleBuilder
Add specific attribute values for a dimension. Returns self for method chaining.
builder.dimension_values("user", [
AttributeType.string("group:engineers"),
AttributeType.string("group:seniors")
])
dimension_all(dimension: str) -> AbacRuleBuilder
Mark a dimension to match any value (wildcard). Returns self for method chaining.
builder.dimension_all("user") # Matches any user
enabled(is_enabled: bool) -> AbacRuleBuilder
Enable or disable the rule (default: disabled). Returns self for method chaining.
builder.enabled(True)
deny() -> AbacRuleBuilder
Mark this as a deny rule (default: allow rule). Returns self for method chaining.
builder.deny()
build() -> AbacRule
Build the final rule.
rule = builder.build()
AbacRule
Represents a compiled ABAC rule. Created via AbacRuleBuilder.build().
Methods
name() -> str
Get the rule name.
print(rule.name()) # "allow-engineers-prod-read"
is_enabled() -> bool
Check if the rule is enabled.
if rule.is_enabled():
print("Rule is active")
is_deny() -> bool
Check if this is a deny rule.
if rule.is_deny():
print("This rule denies access")
dimension_names() -> list[str]
Get all dimension names in this rule.
dims = rule.dimension_names() # ["user", "resource", "action"]
get_dimension(dimension: str) -> list[AttributeType] | None
Get the specific values for a dimension. Returns None if the dimension uses wildcard (match all).
users = rule.get_dimension("user")
if users:
print(f"Rule matches users: {users}")
dimension_is_all(dimension: str) -> bool
Check if a dimension is set to match all values (wildcard).
if rule.dimension_is_all("resource"):
print("Rule applies to all resources")
to_json() -> str
Serialize the rule to JSON format.
json_str = rule.to_json()
# Save to file or transfer over network
AbacRule.from_json(json: str) -> AbacRule (static method)
Deserialize a rule from JSON.
rule = AbacRule.from_json(json_str)
print(rule.name())
AbacRequest
Represents an access request with multiple dimensions and attributes.
Constructor
request = AbacRequest()
Methods
add_attribute(dimension: str, primary: AttributeType, secondary: list[AttributeType]) -> None
Add an attribute to a dimension. The primary attribute is the main value, and secondary attributes are additional values (like group memberships).
# User with group memberships
request.add_attribute(
"user",
AttributeType.string("alice"),
[AttributeType.string("group:engineers"), AttributeType.string("group:seniors")]
)
# Resource with no additional attributes
request.add_attribute(
"resource",
AttributeType.string("prod:db-01"),
[]
)
# Action
request.add_attribute(
"action",
AttributeType.string("read"),
[]
)
dimensions() -> list[str]
Get all dimension names in the request.
dims = request.dimensions() # ["user", "resource", "action"]
get_value(dimension: str) -> AttributeType | None
Get the primary value for a dimension.
user = request.get_value("user")
if user:
print(f"Request from user: {user}")
get_groups(dimension: str) -> list[AttributeType]
Get the secondary values (groups/memberships) for a dimension.
groups = request.get_groups("user") # ["group:engineers", "group:seniors"]
has_dimension(dimension: str) -> bool
Check if a dimension exists in the request.
if request.has_dimension("user"):
print("User dimension is set")
AbacPolicy
The main policy engine that evaluates requests against rules.
Constructors
# Create empty policy with default settings
policy = AbacPolicy()
# Create policy with custom cache size
policy = AbacPolicy.with_cache_size(2048)
# Create policy with custom rule capacity and cache size
policy = AbacPolicy.with_capacity(rules_capacity=100, cache_size=1000)
# Create policy with maximum rule limit
policy = AbacPolicy.with_max_rules(50)
Methods
add_rule(rule: AbacRule) -> AbacPolicy
Add a single rule to the policy. Returns self for method chaining (fluent API).
# Non-fluent style
policy.add_rule(rule)
# Fluent style - chain multiple rules
policy = (AbacPolicy()
.add_rule(rule1)
.add_rule(rule2)
.add_rule(rule3))
load_rules(rules: list[AbacRule]) -> AbacPolicy
Add multiple rules in one batch (more efficient than individual add_rule() calls). Returns self for method chaining.
rules = [rule1, rule2, rule3]
# Non-fluent style
policy.load_rules(rules)
# Fluent style
policy = AbacPolicy().load_rules(rules)
rule_count() -> int
Get the number of rules in the policy.
print(f"Policy has {policy.rule_count()} rules")
stats() -> CacheStats
Get policy statistics including cache performance metrics.
stats = policy.stats()
print(f"Cache fill rate: {stats.cache_fill_rate():.2%}")
print(f"Memory usage: {stats.memory_bytes} bytes")
enable_rule(name: str) -> bool
Enable a rule by name. Returns True if the rule was found and enabled.
if policy.enable_rule("allow-engineers"):
print("Rule enabled")
disable_rule(name: str) -> bool
Disable a rule by name. Returns True if the rule was found and disabled.
if policy.disable_rule("deny-contractors"):
print("Rule disabled")
remove_rule(name: str) -> bool
Remove a rule from the policy. Returns True if the rule was found and removed.
if policy.remove_rule("old-rule"):
print("Rule removed")
clear() -> None
Remove all rules from the policy.
policy.clear()
get_rule(name: str) -> AbacRule | None
Get a rule by name.
rule = policy.get_rule("allow-engineers")
if rule:
print(f"Found rule: {rule.name()}")
has_rule(name: str) -> bool
Check if a rule exists in the policy.
if policy.has_rule("allow-engineers"):
print("Rule exists")
rules() -> list[AbacRule]
Get all rules in the policy.
for rule in policy.rules():
print(f"Rule: {rule.name()}")
export_rules() -> str
Export all rules to JSON format.
json_data = policy.export_rules()
with open('policy.json', 'w') as f:
f.write(json_data)
import_rules(json: str) -> None
Import rules from JSON. Rules are loaded in batch using load_rules().
with open('policy.json') as f:
json_data = f.read()
policy.import_rules(json_data)
evaluate(request: AbacRequest) -> Decision
Evaluate a request against the policy.
decision = policy.evaluate(request)
if decision.is_allowed():
print("Access granted")
Evaluation logic:
- If any deny rule matches, access is denied
- If any allow rule matches (and no deny rules match), access is granted
- If no rules match, access is denied (default deny)
evaluate_at(request: AbacRequest, timestamp: int) -> Decision
Evaluate a request at a specific millisecond timestamp. Temporal rules are checked against timestamp instead of the current wall-clock time. Useful for testing, auditing, or pre-computing decisions for a known future point.
from abac_rs import current_timestamp_millis
future = current_timestamp_millis() + 3600 * 1000 # 1 hour from now
decision = policy.evaluate_at(request, future)
if decision.is_allowed():
print("Access will be granted in one hour")
max_rules() -> int
Get the configured rule limit. Returns 0 if there is no limit (the default).
print(f"Rule limit: {policy.max_rules() or 'none'}")
Use AbacPolicy.with_max_rules(n) to create a policy with a rule cap:
policy = AbacPolicy.with_max_rules(50)
print(policy.max_rules()) # 50
AbacPolicyThreadSafe
Thread-safe variant of AbacPolicy using Mutex for concurrent access from multiple Python threads.
API: Same as AbacPolicy - all constructors and methods are identical.
from threading import Thread
from abac_rs import AbacPolicyThreadSafe, AbacRequest, AttributeType
# Create thread-safe policy
policy = AbacPolicyThreadSafe()
# Use from multiple threads
def worker(policy, user_id):
request = AbacRequest()
request.add_attribute("user", AttributeType.string(f"user{user_id}"), [])
decision = policy.evaluate(request)
print(f"User {user_id}: {decision}")
threads = [Thread(target=worker, args=(policy, i)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
TemporalAbacRule
Temporal rules add time-based validity to ABAC rules.
Constructors
# Rule valid between specific timestamps
temporal = TemporalAbacRule(rule, valid_from=1000, valid_until=2000)
# Rule valid until a specific time
temporal = TemporalAbacRule.valid_until(rule, until_timestamp)
# Rule valid for a duration from now
temporal = TemporalAbacRule.valid_for_duration(rule, duration_ms=3600000) # 1 hour
# Rule valid from a specific time onwards
temporal = TemporalAbacRule.valid_from(rule, from_timestamp)
Methods
is_currently_valid() -> bool
Check if the rule is valid at the current time.
if temporal.is_currently_valid():
print("Rule is active")
is_valid_at(timestamp: int) -> bool
Check if the rule is valid at a specific timestamp.
if temporal.is_valid_at(future_time):
print("Rule will be valid")
inner() -> AbacRule
Get the underlying ABAC rule.
rule = temporal.inner()
print(rule.name())
Helper Function
current_timestamp_millis() -> int
Get the current timestamp in milliseconds.
from abac_rs import current_timestamp_millis
now = current_timestamp_millis()
future = now + 3600000 # 1 hour from now
Policy Composition
ComposedPolicy allows you to combine ABAC and RBAC policies for hybrid access control.
Important: Import acls_rs and hbac_rs before using ComposedPolicy:
import acls_rs # Import first
import hbac_rs # Import first
from abac_rs import ComposedPolicy, CompositionMode
CompositionMode
Determines how ABAC and RBAC results are combined.
Static Methods
CompositionMode.and_mode() -> CompositionMode
Both ABAC and RBAC must allow. Use for defense in depth.
from abac_rs import CompositionMode
mode = CompositionMode.and_mode()
CompositionMode.or_mode() -> CompositionMode
Either ABAC or RBAC allowing is sufficient.
mode = CompositionMode.or_mode()
CompositionMode.abac_first() -> CompositionMode
ABAC takes precedence. If ABAC has matching rules, use that decision. Otherwise fall back to RBAC.
mode = CompositionMode.abac_first()
CompositionMode.rbac_first() -> CompositionMode
RBAC takes precedence. If user has roles with relevant permissions, use that decision. Otherwise fall back to RBAC.
mode = CompositionMode.rbac_first()
ComposedPolicy
Unified policy combining ABAC and RBAC evaluation.
Constructor
from acls_rs import RbacPolicy, Role, AtomicPermission
from abac_rs import ComposedPolicy, CompositionMode, AbacRuleBuilder, AttributeType
from hbac_rs import Subject
# Create ABAC rules
abac_rules = []
rule = (AbacRuleBuilder("allow-engineers-prod-read")
.dimension_values("user", [AttributeType.string("group:engineers")])
.dimension_values("resource", [AttributeType.string("database")])
.dimension_values("action", [AttributeType.string("read")])
.enabled(True)
.build())
abac_rules.append(rule)
# Create RBAC policy
rbac = RbacPolicy()
engineer = Role("engineer",
grants=[AtomicPermission("database", "read"),
AtomicPermission("database", "write")],
denials=[])
rbac.add_role(engineer)
# Compose policies with an RbacPolicy instance
policy = ComposedPolicy(abac_rules, rbac, CompositionMode.and_mode())
Methods
evaluate(request: AbacRequest, subject: Subject) -> Decision
Evaluate a request against both ABAC and RBAC policies.
from hbac_rs import Subject
from abac_rs import AbacRequest, AttributeType
# Create subject with group memberships
user = Subject("alice")
user.add_group("engineer")
# Create request
request = AbacRequest()
request.add_attribute("user", AttributeType.string("alice"),
[AttributeType.string("group:engineers")])
request.add_attribute("resource", AttributeType.string("database"), [])
request.add_attribute("action", AttributeType.string("read"), [])
# Evaluate against composed policy
decision = policy.evaluate(request, user)
if decision.is_allowed():
print("Access granted by both ABAC and RBAC")
RBAC Permission Mapping:
ComposedPolicy extracts resource and action attributes from the ABAC request to form the RBAC permission:
resourceattribute → permission namespaceactionattribute → permission action
For example, resource="database" and action="read" maps to checking the database:read permission in RBAC.
If these attributes are missing, RBAC evaluation is skipped and only the ABAC result is used.
mode() -> CompositionMode
Get the current composition mode.
current_mode = policy.mode()
set_mode(mode: CompositionMode) -> None
Change the composition mode at runtime.
policy.set_mode(CompositionMode.or_mode())
rbac_policy() -> RbacPolicy
Get a reference to the RBAC policy (read-only).
rbac = policy.rbac_policy()
num_roles = len(rbac.roles()) # Count roles
invalidate_permission_cache() -> None
Invalidate the RBAC permission cache. Call this after modifying the RBAC policy externally.
policy.invalidate_permission_cache()
Complete Example
import acls_rs # Import first
import hbac_rs # Import first
from abac_rs import (
ComposedPolicy, CompositionMode, AbacRuleBuilder,
AbacRequest, AttributeType
)
from acls_rs import RbacPolicy, Role, AtomicPermission
from hbac_rs import Subject
# ABAC: attribute-based rules for fine-grained control
abac_rules = []
# Engineers can read production databases
rule1 = (AbacRuleBuilder("allow-engineers-prod-read")
.dimension_values("user", [AttributeType.string("group:engineers")])
.dimension_values("resource", [AttributeType.string("database")])
.dimension_values("action", [AttributeType.string("read")])
.enabled(True)
.build())
abac_rules.append(rule1)
# RBAC: role-based permissions for organizational hierarchy
rbac = RbacPolicy()
engineer_role = Role("engineer",
grants=[
AtomicPermission("database", "read"),
AtomicPermission("database", "write"),
],
denials=[])
rbac.add_role(engineer_role)
dba_role = Role("dba",
grants=[
AtomicPermission("database", "read"),
AtomicPermission("database", "write"),
AtomicPermission("database", "delete"),
],
denials=[])
rbac.add_role(dba_role)
# Compose with AND mode: both must agree
policy = ComposedPolicy(abac_rules, rbac, CompositionMode.and_mode())
# Test 1: Engineer reading production database
alice = Subject("alice")
alice.add_group("engineer")
request1 = AbacRequest()
request1.add_attribute("user", AttributeType.string("alice"),
[AttributeType.string("group:engineers")])
request1.add_attribute("resource", AttributeType.string("database"), [])
request1.add_attribute("action", AttributeType.string("read"), [])
decision1 = policy.evaluate(request1, alice)
assert decision1.is_allowed() # ABAC allows (rule matches) AND RBAC allows (role grants permission)
# Test 2: Engineer trying to delete (RBAC denies, no ABAC rule)
request2 = AbacRequest()
request2.add_attribute("user", AttributeType.string("alice"),
[AttributeType.string("group:engineers")])
request2.add_attribute("resource", AttributeType.string("database"), [])
request2.add_attribute("action", AttributeType.string("delete"), [])
decision2 = policy.evaluate(request2, alice)
assert decision2.is_denied() # ABAC denies (no matching rule) AND RBAC denies (engineer lacks delete)
# Test 3: DBA deleting (has RBAC permission but no ABAC rule)
bob = Subject("bob")
bob.add_group("dba")
request3 = AbacRequest()
request3.add_attribute("user", AttributeType.string("bob"),
[AttributeType.string("group:dba")])
request3.add_attribute("resource", AttributeType.string("database"), [])
request3.add_attribute("action", AttributeType.string("delete"), [])
decision3 = policy.evaluate(request3, bob)
assert decision3.is_denied() # ABAC denies (no rule for DBA delete), so AND mode denies
Use Cases
Defense in Depth (AND mode):
- Both ABAC and RBAC must approve
- Fine-grained attribute checks + role verification
- Maximum security for critical resources
Convenience First (OR mode):
- Either ABAC or RBAC allowing is sufficient
- Useful during migration from one model to another
- Fallback mechanism when one policy is incomplete
Hybrid (ABAC First / RBAC First):
- Use primary policy where it has coverage
- Fall back to secondary policy otherwise
- Gradual migration from RBAC → ABAC or vice versa
CacheStats
Statistics about policy cache performance and memory usage.
Important: The ABAC policy uses a multi-layer optimization strategy. When faster optimization layers are available (deny_index for deny-only policies, compiled evaluator for certain rule patterns), the LRU cache is bypassed entirely. This means cache_entries may remain 0 even under heavy load — this is expected and indicates optimal performance.
Fields (read-only)
stats.rule_count # Total number of rules
stats.enabled_rule_count # Number of enabled rules
stats.cache_capacity # Maximum cache entries
stats.cache_entries # Current cache entries (may be 0 if optimized away)
stats.memory_bytes # Estimated memory usage
Methods
cache_fill_rate() -> float
Get the cache utilization as a ratio (0.0 to 1.0).
Note: Returns 0.0 when optimization layers bypass caching.
fill_rate = stats.cache_fill_rate()
print(f"Cache is {fill_rate * 100:.1f}% full")
Decision
Result of a policy evaluation.
Methods
is_allowed() -> bool
Check if access is allowed.
if decision.is_allowed():
print("Access granted")
is_denied() -> bool
Check if access is denied.
if decision.is_denied():
print("Access denied")
Error Handling
The abac-rs Python bindings provide specific exception types for different error scenarios:
PolicyError
Raised when policy operations fail (e.g., too many rules).
from abac_rs import AbacPolicy, PolicyError
try:
policy = AbacPolicy.with_max_rules(10)
# Try to load more than max
policy.load_rules(many_rules)
except PolicyError as e:
print(f"Policy error: {e}")
RequestError
Raised when request operations fail (e.g., too many attributes).
from abac_rs import AbacRequest, AttributeType, RequestError
try:
request = AbacRequest()
# Try to add too many attributes (max 100)
for i in range(150):
request.add_attribute(f"dim{i}", AttributeType.string("value"), [])
except RequestError as e:
print(f"Request error: {e}")
TemporalError
Raised when temporal rule creation fails (e.g., invalid time window).
from abac_rs import TemporalAbacRule, TemporalError
try:
# Invalid: from >= until
temporal = TemporalAbacRule(rule, valid_from=2000, valid_until=1000)
except TemporalError as e:
print(f"Temporal error: {e}")
Examples
Database Access Control
from abac_rs import AbacRuleBuilder, AbacRequest, AbacPolicy, AttributeType
policy = AbacPolicy()
# Rule: Engineers can read production databases (fluent API)
rule1 = (AbacRuleBuilder("allow-engineers-prod-read")
.dimension_values("user", [AttributeType.string("group:engineers")])
.dimension_values("resource", [
AttributeType.string("prod:db-01"),
AttributeType.string("prod:db-02")
])
.dimension_values("action", [AttributeType.string("read")])
.enabled(True)
.build())
policy.add_rule(rule1)
# Rule: DBAs have full access (fluent API)
rule2 = (AbacRuleBuilder("allow-dba-all")
.dimension_values("user", [AttributeType.string("group:dba")])
.dimension_values("resource", [
AttributeType.string("prod:db-01"),
AttributeType.string("prod:db-02")
])
.dimension_values("action", [
AttributeType.string("read"),
AttributeType.string("write"),
AttributeType.string("delete")
])
.enabled(True)
.build())
policy.add_rule(rule2)
# Rule: Deny junior engineers from modifying production (fluent API)
rule3 = (AbacRuleBuilder("deny-junior-prod-write")
.dimension_values("user", [AttributeType.string("group:junior")])
.dimension_values("resource", [
AttributeType.string("prod:db-01"),
AttributeType.string("prod:db-02")
])
.dimension_values("action", [
AttributeType.string("write"),
AttributeType.string("delete")
])
.deny()
.enabled(True)
.build())
policy.add_rule(rule3)
# Test: Engineer reading production (allowed)
request = AbacRequest()
request.add_attribute("user", AttributeType.string("alice"),
[AttributeType.string("group:engineers")])
request.add_attribute("resource", AttributeType.string("prod:db-01"), [])
request.add_attribute("action", AttributeType.string("read"), [])
assert policy.evaluate(request) == True
# Test: Junior engineer trying to delete (denied by explicit deny rule)
request2 = AbacRequest()
request2.add_attribute("user", AttributeType.string("charlie"),
[AttributeType.string("group:junior"),
AttributeType.string("group:engineers")])
request2.add_attribute("resource", AttributeType.string("prod:db-01"), [])
request2.add_attribute("action", AttributeType.string("delete"), [])
assert policy.evaluate(request2) == False # Deny rule takes precedence
Multi-Type Attributes
ABAC supports multiple attribute types:
from abac_rs import AttributeType
# String attributes (most common)
group = AttributeType.string("group:engineers")
resource = AttributeType.string("prod:db-01")
# Integer attributes (for numeric IDs, priorities, etc.)
priority = AttributeType.integer(5)
user_id = AttributeType.integer(1234)
# Float attributes (for weights, scores, etc.)
score = AttributeType.float(0.95)
threshold = AttributeType.float(3.14)
# IP addresses
client_ip = AttributeType.ip_addr("192.168.1.100")
# IP CIDR ranges
internal_network = AttributeType.ip_cidr("10.0.0.0/8")
Custom Dimensions
ABAC is not limited to user/resource/action. You can use any dimension names:
from abac_rs import AbacRuleBuilder, AbacRequest, AbacPolicy, AttributeType
policy = AbacPolicy()
# Rule with custom dimensions (fluent API)
rule = (AbacRuleBuilder("time-based-access")
.dimension_values("user", [AttributeType.string("group:operators")])
.dimension_values("operation", [AttributeType.string("deploy")])
.dimension_values("environment", [AttributeType.string("staging")])
.dimension_values("time", [AttributeType.string("business-hours")])
.enabled(True)
.build())
policy.add_rule(rule)
# Request with custom dimensions
request = AbacRequest()
request.add_attribute("user", AttributeType.string("bob"),
[AttributeType.string("group:operators")])
request.add_attribute("operation", AttributeType.string("deploy"), [])
request.add_attribute("environment", AttributeType.string("staging"), [])
request.add_attribute("time", AttributeType.string("business-hours"), [])
if policy.evaluate(request):
print("Deployment authorized")
Custom Matchers
Custom matchers allow you to implement domain-specific matching logic beyond the default exact matching. This enables predicates, ranges, CIDR matching, and other custom semantics.
Matcher Protocol
A custom matcher is any Python callable (function, lambda, or class with __call__) that takes three arguments and returns a boolean:
def my_matcher(rule_value, request_value, request_groups):
"""
Args:
rule_value: List of AttributeType from rule, or None for wildcard
request_value: AttributeType from request
request_groups: List of AttributeType group memberships
Returns:
bool: True if request matches rule requirement
"""
# Custom matching logic here
return matches
Registering Matchers
Register custom matchers for specific dimensions before adding rules:
from abac_rs import AbacPolicy, AttributeType, AbacRuleBuilder
policy = AbacPolicy()
# Define a range matcher for numeric attributes
def range_matcher(rule_value, request_value, request_groups):
if rule_value is None:
return True # Wildcard matches everything
# Expect rule to contain [min, max] bounds
if len(rule_value) != 2:
return False
# Extract values (simplified - production code needs type checking)
min_val = rule_value[0] # AttributeType
max_val = rule_value[1]
req_val = request_value
# Custom range check logic here
return True # Simplified
# Register matcher for 'priority' dimension
policy.register_matcher("priority", range_matcher)
# Now add rules using that dimension
rule = (
AbacRuleBuilder("priority-access")
.dimension_values("priority", [
AttributeType.integer(1), # min
AttributeType.integer(10) # max
])
.dimension_values("action", [AttributeType.string("access")])
.enabled(True)
.build()
)
policy.add_rule(rule)
CIDR Matcher Example
import ipaddress
def cidr_matcher(rule_value, request_value, request_groups):
"""Match IP addresses against CIDR ranges."""
if rule_value is None:
return True
# Parse request IP (simplified)
request_ip = ipaddress.ip_address("192.168.1.100")
# Check if IP is in any CIDR range
for cidr in rule_value:
network = ipaddress.ip_network("192.168.0.0/16")
if request_ip in network:
return True
return False
policy.register_matcher("client_ip", cidr_matcher)
Predicate Matcher Example
import re
def regex_matcher(rule_value, request_value, request_groups):
"""Match strings against regex patterns."""
if rule_value is None:
return True
# Get request string value (simplified)
request_str = str(request_value)
# Check against all patterns
for pattern in rule_value:
pattern_str = str(pattern)
if re.match(pattern_str, request_str):
return True
return False
policy.register_matcher("resource_path", regex_matcher)
Important Notes
- Register before adding rules: Matchers must be registered before adding rules that use them
- Performance: Custom matchers are slower than exact matching and disable Bloom filter optimization
- Error handling: Matcher exceptions are caught and treated as non-matches (deny by default)
- Thread safety: Matchers must be thread-safe if using
AbacPolicyThreadSafe - GIL constraints: Matchers are called from Rust code with GIL held, so keep them fast
Complete Example
See crates/abac-python/examples/custom_matchers.py for a complete working example with range, CIDR, and predicate matchers.
Performance
The Python bindings use the same high-performance Rust engine as the native library:
- Zero-copy evaluation: Minimal overhead for crossing the Python/Rust boundary
- Compiled policies: Rules are pre-compiled for fast evaluation
- Bitmap indexing: Deny rules use bitmap-based fast path
- JIT optimization: Optional JIT compilation for maximum performance
For high-throughput applications, create the policy once and reuse it for multiple evaluations:
# Initialize policy once (expensive)
policy = AbacPolicy()
for rule in rules:
policy.add_rule(rule)
# Evaluate many requests (fast)
for request in requests:
result = policy.evaluate(request)
Differences from Rust API
The Python bindings closely follow the Rust API but with some Pythonic adjustments:
- Builder pattern: Methods return
selffor fluent chaining, just like the Rust API - Type constructors: Use static methods like
AttributeType.string()instead of enum variants - No lifetime parameters: Memory management is automatic via Python GC
- Thread safety: Policies use single-threaded variants (safe with Python GIL)
Examples Directory
See crates/abac-python/examples/ for complete working examples:
# Basic ABAC usage
python3 crates/abac-python/examples/basic_usage.py
# Advanced features (IP types, wildcards, introspection)
python3 crates/abac-python/examples/advanced_features.py
# Temporal rules and thread-safe policies
python3 crates/abac-python/examples/temporal_and_threading.py
# JSON serialization and persistence
python3 crates/abac-python/examples/serialization.py
# Custom matcher functions
python3 crates/abac-python/examples/custom_matchers.py
See Also
- ABAC Rust Documentation - Native Rust API
- ABAC Policy Composition - Composing ABAC with RBAC in Rust
- acls-rs Python Bindings - Foundation library Python API
- hbac-rs Python Bindings - HBAC-specific Python API
- Installation Guide - Setup instructions
POSIX ACL Python Bindings
The posix-acls-python crate provides Python bindings for POSIX ACL
(POSIX.1e) modeling via PyO3, allowing you to build,
parse, validate, and check POSIX ACLs from Python with native Rust
performance.
Installation
Prerequisites
- Python 3.8 or higher
- Rust 1.70 or higher (for building from source)
- maturin for building
Building from Source
# Install maturin
pip install maturin
# Navigate to the posix-acls-python crate
cd crates/posix-acls-python
# Build and install in development mode
maturin develop
# Or build a release wheel
maturin build --release
Quick Start
from posix_acls import PosixPerm, AclBuilder
# Build an ACL for a shared project directory
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.build())
# Serialise to getfacl text format
print(acl.to_getfacl())
# Check access -- bob can read but not execute
result = acl.check_access("bob", ["staff"], PosixPerm.R.bits)
print(result) # Granted(effective=rw-)
print(bool(result)) # True
result = acl.check_access("bob", ["staff"], PosixPerm.X.bits)
print(bool(result)) # False
Differences from Rust API
The Python bindings closely mirror the Rust API with a few adjustments:
- Permission parameters are integers: builder methods and mutation
methods accept
int(the raw permission bits 0–7), notPosixPermobjects. UsePosixPerm.RWX.bitsto get the integer value. This avoids wrapping overhead on hot paths. - Special bits use booleans:
special_bits(suid, sgid, sticky)takes threeboolarguments instead of aSpecialBitsobject. - Builder pattern: all builder methods return
selffor fluent chaining, same as Rust. - Errors are ValueError: all error conditions (parse failures, invalid
ACL structure, policy errors, out-of-range permission bits) raise
ValueErrorwith a descriptive message. Permission bits are validated at the boundary: values outside 0–7 are rejected immediately. - No lifetime parameters: Python’s garbage collector handles memory.
API Reference
PosixPerm
The rwx permission triple. Immutable, hashable, and equality-comparable.
PosixPerm(read: bool = False, write: bool = False, execute: bool = False)
Parameters:
read: set the read bitwrite: set the write bitexecute: set the execute bit
Class attributes (constants):
PosixPerm.NONE,PosixPerm.R,PosixPerm.W,PosixPerm.X,PosixPerm.RW,PosixPerm.RX,PosixPerm.WX,PosixPerm.RWX
Properties (read-only):
read(bool): whether the read bit is setwrite(bool): whether the write bit is setexecute(bool): whether the execute bit is setbits(int): the raw 3-bit value (0–7)
Methods:
contains(other: PosixPerm) -> bool
Check whether this permission set is a superset of other.
mask_with(mask: PosixPerm) -> PosixPerm
Apply an effective-rights mask (bitwise AND).
Operators:
p1 & p2: bitwise AND (intersection)p1 | p2: bitwise OR (union)bool(p):Trueif notNONEp1 == p2: equality comparisonhash(p): hashable (can be used as dict key)
Example:
from posix_acls import PosixPerm
# Constants
print(PosixPerm.RWX) # rwx
print(PosixPerm.R) # r--
print(PosixPerm.NONE) # ---
# Constructor
rw = PosixPerm(read=True, write=True)
print(rw) # rw-
# Operators
print(PosixPerm.R | PosixPerm.W) # rw-
print(PosixPerm.RWX & PosixPerm.RX) # r-x
# Containment
print(PosixPerm.RWX.contains(PosixPerm.R)) # True
print(PosixPerm.R.contains(PosixPerm.W)) # False
# Bits for passing to builders
print(PosixPerm.RWX.bits) # 7
print(PosixPerm.R.bits) # 4
# Truthiness
print(bool(PosixPerm.NONE)) # False
print(bool(PosixPerm.R)) # True
SpecialBits
SUID, SGID, and sticky bits. Immutable and hashable.
SpecialBits(suid: bool = False, sgid: bool = False, sticky: bool = False)
Parameters:
suid: set the set-user-ID bitsgid: set the set-group-ID bitsticky: set the sticky bit
Class attributes: SpecialBits.NONE
Properties (read-only):
suid(bool)sgid(bool)sticky(bool)
Operators:
bool(s):Trueif any bit is set
Example:
from posix_acls import SpecialBits
sgid = SpecialBits(sgid=True)
print(sgid) # -s-
print(sgid.sgid) # True
print(sgid.suid) # False
print(sgid.sticky) # False
print(bool(SpecialBits.NONE)) # False
print(bool(sgid)) # True
AclEntry
A single POSIX ACL entry (tag + permissions). Read-only – instances are
created internally by PosixAcl and returned from entries and
default_entries.
Properties (read-only):
tag(str): the ACL tag string (e.g.,"user::rwx","user:bob:rw-","mask::rwx")perms(PosixPerm): the permission triple
Operators:
e1 == e2: equality comparison
Example:
from posix_acls import AclBuilder, PosixPerm
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.R.bits)
.build())
for entry in acl.entries:
print(entry) # user::rwx, group::r-x, other::r--
AccessResult
Result of a POSIX ACL access check. Truthy when access is granted, so you
can use it directly in if statements.
Properties (read-only):
granted(bool): whether the requested permissions are fully heldmatched_tag(str): which ACL entry determined the resulteffective_perms(PosixPerm): actual permissions after masking
Operators:
bool(result): returnsgranted(soif result:works naturally)
Example:
from posix_acls import AclBuilder, PosixPerm
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.build())
result = acl.check_access("alice", [], PosixPerm.RWX.bits)
print(result) # Granted(effective=rwx)
print(result.matched_tag) # user::
print(result.effective_perms) # rwx
result = acl.check_access("eve", [], PosixPerm.R.bits)
print(result) # Denied(effective=---)
if not result:
print("Access denied")
PosixAcl
A complete POSIX ACL for a file or directory.
PosixAcl(owner: str, owning_group: str)
Parameters:
owner: the file owner nameowning_group: the owning group name
Creates a minimal ACL with UserObj, GroupObj, and Other all set to
NONE.
Properties (read-only):
owner(str)owning_group(str)special_bits(SpecialBits)entries(list[AclEntry]): access ACL entriesdefault_entries(list[AclEntry]): default (inherited) ACL entriesmask(PosixPerm): the effective-rights maskis_extended(bool):Trueif named user/group entries exist
Mutation methods:
set_owner_perms(bits: int) -> None
Set the owner (user::) permissions. Raises ValueError if bits is
not in 0–7.
set_group_perms(bits: int) -> None
Set the owning group (group::) permissions. Raises ValueError if
bits is not in 0–7.
set_other_perms(bits: int) -> None
Set the other (other::) permissions. Raises ValueError if bits is
not in 0–7.
set_special_bits(suid: bool, sgid: bool, sticky: bool) -> None
Set the SUID/SGID/sticky bits.
add_user(name: str, bits: int) -> None
Add or update a named user entry. Raises ValueError if bits is not
in 0–7.
add_group(name: str, bits: int) -> None
Add or update a named group entry. Raises ValueError if bits is not
in 0–7.
remove_user(name: str) -> None
Remove a named user entry.
remove_group(name: str) -> None
Remove a named group entry.
set_mask(bits: int) -> None
Set the mask entry directly. Raises ValueError if bits is not in
0–7.
compute_mask() -> PosixPerm
Recompute the mask from current entries, set it, and return the new value.
Query methods:
validate() -> None
Check structural validity.
Raises: ValueError if the ACL is invalid (missing entries, missing mask,
etc.)
check_access(user: str, groups: list[str], required_bits: int) -> AccessResult
Run the POSIX.1e access-check algorithm.
user: the requesting user’s namegroups: the user’s group membershipsrequired_bits: permission bits to check (usePosixPerm.R.bitsetc.)
Returns: AccessResult
Raises: ValueError if required_bits is not in 0–7
to_mode() -> int
Convert to a Unix mode word (12-bit integer).
to_getfacl() -> str
Serialise to getfacl(1) text format.
Raises: ValueError if the ACL is invalid
Class methods:
PosixAcl.from_mode(owner: str, owning_group: str, mode: int) -> PosixAcl
Build a minimal ACL from a Unix mode word.
PosixAcl.from_getfacl(text: str) -> PosixAcl
Parse a getfacl(1) text block.
Raises: ValueError on parse failure
Example:
from posix_acls import PosixAcl, PosixPerm
# Build from mode word
acl = PosixAcl.from_mode("root", "root", 0o644)
print(f"mode = 0o{acl.to_mode():03o}") # mode = 0o644
# Parse getfacl text
text = """\
# owner: alice
# group: devs
user::rwx
user:carol:r--
group::r-x
mask::r-x
other::---
"""
parsed = PosixAcl.from_getfacl(text)
print(parsed.owner) # alice
print(parsed.is_extended) # True
print(parsed.mask) # r-x
# Direct construction
acl = PosixAcl("alice", "devs")
acl.set_owner_perms(PosixPerm.RWX.bits)
acl.set_group_perms(PosixPerm.RX.bits)
acl.set_other_perms(PosixPerm.NONE.bits)
acl.add_user("bob", PosixPerm.RW.bits)
acl.compute_mask()
acl.validate() # raises if invalid
AclBuilder
Fluent builder for PosixAcl. Auto-computes the mask entry when named
entries are present.
AclBuilder(owner: str, owning_group: str)
Parameters:
owner: the file owner nameowning_group: the owning group name
All builder methods accept int for permission bits (use PosixPerm.R.bits
etc.) and return self for fluent chaining. Permission bits are validated:
values outside 0–7 raise ValueError.
Methods:
All return AclBuilder for fluent chaining (raise ValueError if
bits is not in 0–7):
owner_perms(bits: int) -> AclBuilderowning_group_perms(bits: int) -> AclBuilderother_perms(bits: int) -> AclBuilderspecial_bits(suid: bool, sgid: bool, sticky: bool) -> AclBuilderadd_user(name: str, bits: int) -> AclBuilder: upserts (last call wins)add_group(name: str, bits: int) -> AclBuilder: upserts (last call wins)default_owner_perms(bits: int) -> AclBuilderdefault_owning_group_perms(bits: int) -> AclBuilderdefault_other_perms(bits: int) -> AclBuilderdefault_user(name: str, bits: int) -> AclBuilder: upserts (last call wins)default_group(name: str, bits: int) -> AclBuilder: upserts (last call wins)
build() -> PosixAcl
Build the PosixAcl. The mask is auto-computed when named entries are
present.
Returns: PosixAcl
Example:
from posix_acls import AclBuilder, PosixPerm
# Fluent builder pattern
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.add_group("ops", PosixPerm.R.bits)
.build())
print(acl.owner) # alice
print(acl.is_extended) # True
print(acl.mask) # rwx (auto-computed)
acl.validate() # passes
# With default ACL (for directories)
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.default_owner_perms(PosixPerm.RWX.bits)
.default_owning_group_perms(PosixPerm.RX.bits)
.default_other_perms(PosixPerm.NONE.bits)
.default_user("bob", PosixPerm.RW.bits)
.build())
print(len(acl.default_entries)) # 5 (user::, user:bob:, group::, mask::, other::)
UserEntry
A user in the user/group model.
UserEntry(name: str, groups: list[str] = [])
Parameters:
name: the user’s login namegroups: initial group memberships (default: empty)
Properties (read-only):
name(str)groups(list[str])
Methods:
add_group(group: str) -> None
Add a group membership.
Example:
from posix_acls import UserEntry
alice = UserEntry("alice", ["devs", "ops"])
print(alice.name) # alice
print(alice.groups) # ['devs', 'ops']
bob = UserEntry("bob")
bob.add_group("devs")
print(bob.groups) # ['devs']
GroupEntry
A group in the user/group model. Immutable.
GroupEntry(name: str)
Parameters:
name: the group name
Properties (read-only):
name(str)
Example:
from posix_acls import GroupEntry
devs = GroupEntry("devs")
print(devs.name) # devs
UserGroupModel
Directory of users and groups for policy validation.
UserGroupModel()
Creates an empty model.
Methods:
add_user(user: UserEntry) -> None
Add a user. Replaces any existing entry with the same name.
add_group(group: GroupEntry) -> None
Add a group. Replaces any existing entry with the same name.
get_user(name: str) -> UserEntry | None
Look up a user by name.
get_group(name: str) -> GroupEntry | None
Look up a group by name.
groups_of(user: str) -> list[str]
Return the group memberships of a user, or an empty list if the user is not in the model.
Operators:
len(model): total count of users and groupsbool(model):Trueif not empty
Example:
from posix_acls import UserEntry, GroupEntry, UserGroupModel
model = UserGroupModel()
model.add_user(UserEntry("alice", ["devs"]))
model.add_user(UserEntry("bob", ["devs"]))
model.add_group(GroupEntry("devs"))
model.add_group(GroupEntry("ops"))
print(model.get_user("alice").name) # alice
print(model.groups_of("alice")) # ['devs']
print(model.get_user("unknown")) # None
print(len(model)) # 4
print(bool(model)) # True
AclPolicy
High-level ACL policy: assigns permissions to users and groups and derives
a concrete PosixAcl validated against a UserGroupModel. Immutable –
built via AclPolicyBuilder.
Class methods:
AclPolicy.builder() -> AclPolicyBuilder
Create a new builder.
Methods:
generate(model: UserGroupModel) -> PosixAcl
Generate a PosixAcl from this policy, validating that every named user
and group in the grant list exists in the model.
model: the user/group directory to validate against
Returns: PosixAcl
Raises: ValueError if a named user or group is absent from the model
Example:
from posix_acls import (
PosixPerm, UserEntry, GroupEntry, UserGroupModel, AclPolicy
)
model = UserGroupModel()
model.add_user(UserEntry("alice", ["devs"]))
model.add_user(UserEntry("bob", ["devs"]))
model.add_group(GroupEntry("devs"))
policy = (AclPolicy.builder()
.owner("alice")
.owning_group("devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.build())
acl = policy.generate(model)
acl.validate()
print(acl.owner) # alice
print(acl.is_extended) # True
AclPolicyBuilder
Fluent builder for AclPolicy. Idiomatically reached via
AclPolicy.builder().
AclPolicyBuilder()
All builder methods accept int for permission bits and return self for
fluent chaining. Permission bits are validated: values outside 0–7 raise
ValueError.
Methods:
All return AclPolicyBuilder for fluent chaining (raise ValueError if
bits is not in 0–7):
owner(owner: str) -> AclPolicyBuilderowning_group(group: str) -> AclPolicyBuilderowner_perms(bits: int) -> AclPolicyBuilderowning_group_perms(bits: int) -> AclPolicyBuilderother_perms(bits: int) -> AclPolicyBuilderadd_user(name: str, bits: int) -> AclPolicyBuilder: upserts (last call wins)add_group(name: str, bits: int) -> AclPolicyBuilder: upserts (last call wins)default_owner_perms(bits: int) -> AclPolicyBuilderdefault_owning_group_perms(bits: int) -> AclPolicyBuilderdefault_other_perms(bits: int) -> AclPolicyBuilderdefault_user(name: str, bits: int) -> AclPolicyBuilder: upserts (last call wins)default_group(name: str, bits: int) -> AclPolicyBuilder: upserts (last call wins)
build() -> AclPolicy
Validate and build the policy. Requires owner and owning_group to be
set.
Returns: AclPolicy
Raises: ValueError if owner or owning_group is missing
Examples
Building and Checking Access
from posix_acls import PosixPerm, AclBuilder
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.add_group("ops", PosixPerm.R.bits)
.build())
# Owner bypasses the mask
result = acl.check_access("alice", [], PosixPerm.RWX.bits)
print(result.granted) # True
print(result.matched_tag) # user::
# Named user is masked
result = acl.check_access("bob", ["staff"], PosixPerm.R.bits)
print(result.granted) # True
print(result.effective_perms) # rw-
# Group match
result = acl.check_access("charlie", ["ops"], PosixPerm.R.bits)
print(result.granted) # True
# Other fallback -- denied
result = acl.check_access("eve", [], PosixPerm.R.bits)
print(result.granted) # False
Parsing and Round-Tripping
from posix_acls import PosixAcl
text = """\
# owner: alice
# group: devs
# flags: -s-
user::rwx
user:bob:rw-
group::r-x
group:ops:r--
mask::rwx
other::r--
"""
# Parse
acl = PosixAcl.from_getfacl(text)
print(acl.owner) # alice
print(acl.special_bits) # -s-
# Round-trip
text2 = acl.to_getfacl()
acl2 = PosixAcl.from_getfacl(text2)
print(acl2.owner) # alice
print(len(acl2.entries)) # 6
Mode Word Conversion
from posix_acls import PosixAcl, AclBuilder, PosixPerm
# Build from a mode word
acl = PosixAcl.from_mode("alice", "devs", 0o755)
print(f"0o{acl.to_mode():03o}") # 0o755
# Extended ACLs: stat() group bits reflect the mask
acl = (AclBuilder("alice", "devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.R.bits)
.add_user("bob", PosixPerm.RW.bits)
.build())
print(f"0o{acl.to_mode():03o}") # 0o774 (group bits = mask = rwx)
Policy-Driven ACLs
from posix_acls import (
PosixPerm, UserEntry, GroupEntry, UserGroupModel, AclPolicy
)
# Define the organisation
model = UserGroupModel()
model.add_user(UserEntry("alice", ["devs"]))
model.add_user(UserEntry("bob", ["devs"]))
model.add_user(UserEntry("carol", ["ops"]))
model.add_group(GroupEntry("devs"))
model.add_group(GroupEntry("ops"))
# Build a policy
policy = (AclPolicy.builder()
.owner("alice")
.owning_group("devs")
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
.add_group("ops", PosixPerm.R.bits)
.build())
# Generate a concrete ACL
acl = policy.generate(model)
acl.validate()
print(acl.to_getfacl())
# Validation catches unknown principals
bad_policy = (AclPolicy.builder()
.owner("alice")
.owning_group("devs")
.add_user("unknown", PosixPerm.R.bits)
.build())
try:
bad_policy.generate(model)
except ValueError as e:
print(f"Policy error: {e}") # unknown user: "unknown"
Default ACLs for Directories
from posix_acls import AclBuilder, PosixPerm
# A shared project directory with inheritable permissions
acl = (AclBuilder("alice", "devs")
# Access ACL (the directory itself)
.owner_perms(PosixPerm.RWX.bits)
.owning_group_perms(PosixPerm.RX.bits)
.other_perms(PosixPerm.NONE.bits)
.add_user("bob", PosixPerm.RW.bits)
# Default ACL (inherited by new files)
.default_owner_perms(PosixPerm.RWX.bits)
.default_owning_group_perms(PosixPerm.RX.bits)
.default_other_perms(PosixPerm.NONE.bits)
.default_user("bob", PosixPerm.RW.bits)
.special_bits(False, True, False) # SGID for group inheritance
.build())
print(f"Access entries: {len(acl.entries)}")
print(f"Default entries: {len(acl.default_entries)}")
print(f"SGID: {acl.special_bits.sgid}") # True
print(acl.to_getfacl())
Error Handling
All errors from the Rust layer are raised as ValueError with a
descriptive message:
from posix_acls import PosixAcl, AclBuilder, AclPolicy, PosixPerm
# Out-of-range permission bits
try:
AclBuilder("alice", "devs").owner_perms(8) # bits must be 0-7
except ValueError as e:
print(f"Validation error: {e}") # permission bits must be 0-7, got 8
# Parse error
try:
PosixAcl.from_getfacl("not valid acl text")
except ValueError as e:
print(f"Parse error: {e}")
# Validation error
acl = PosixAcl("alice", "devs")
acl.set_owner_perms(PosixPerm.RWX.bits)
acl.set_group_perms(PosixPerm.RX.bits)
acl.set_other_perms(PosixPerm.NONE.bits)
acl.add_user("bob", PosixPerm.RW.bits)
# Forgot to compute/set mask
try:
acl.validate()
except ValueError as e:
print(f"Validation error: {e}")
# Policy builder error
try:
AclPolicy.builder().build() # no owner or owning_group
except ValueError as e:
print(f"Build error: {e}")
Performance
The Python bindings use PyO3’s zero-cost abstractions:
- Native speed: ACL construction, access checks, and parsing all run at Rust performance.
- Minimal overhead: type conversions happen only at the Python/Rust boundary. Permission parameters are raw integers to avoid wrapping overhead.
- Zero-copy where possible: string properties like
ownerandowning_groupare returned as borrowed references when the GIL allows.
For high-throughput scenarios, build your ACL or policy once and reuse it
for multiple access checks. The AclBuilder and AclPolicyBuilder
produce validated ACLs in a single call, so prefer them over incremental
PosixAcl mutation.
See Also
- posix-acls: POSIX ACL Modeling – the Rust crate documentation with detailed coverage of permissions, access checks, and the policy layer.
- Permissions – lattice algebra and the
rwxpermission model. - Access Checks – the POSIX.1e access-check algorithm in detail.
- Python Bindings: ACLS – Python bindings for the core acls-rs crate.
Python Bindings: Windows Security Descriptors
The win-sd Python package (win_sd) provides PEP 561 typed bindings for
the win-sd Rust crate. Install with maturin or pip.
Installation
# From the repository
cd crates/win-sd-python
maturin develop
# Or build a wheel
maturin build --release
pip install target/wheels/win_sd-*.whl
Quick start
from win_sd import (
Sid, AccessMask, SecurityDescriptorBuilder, SecurityDescriptor,
IntegrityLevel, IntegrityPolicy, AccessToken, Privilege,
GenericMapping, Guid, DomainContext,
)
# Build a security descriptor
sd = (SecurityDescriptorBuilder()
.owner(Sid.local_system())
.group(Sid.administrators())
.deny(Sid.anonymous(), AccessMask.FILE_ALL_ACCESS)
.allow(Sid.administrators(), AccessMask.FILE_ALL_ACCESS)
.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
.build())
# Generate and parse SDDL
sddl = sd.to_sddl()
recovered = SecurityDescriptor.from_sddl(sddl)
assert recovered.owner == sd.owner
# Simple access check
result = sd.check_access([Sid.administrators(), Sid.everyone()],
AccessMask.FILE_GENERIC_READ)
assert result.granted
# Full access check with token
token = (AccessToken(Sid.administrators())
.with_groups([Sid.everyone()])
.with_integrity(IntegrityLevel.HIGH))
result = sd.check_access_full(token, AccessMask.FILE_GENERIC_READ)
assert result.granted
SIDs
from win_sd import Sid
# Parse from string
sid = Sid("S-1-5-32-544")
assert str(sid) == "S-1-5-32-544"
assert sid == Sid.administrators()
# Well-known SIDs
everyone = Sid.everyone()
system = Sid.local_system()
anon = Sid.anonymous()
users = Sid.users()
auth = Sid.authenticated_users()
Access masks
from win_sd import AccessMask
# Named constants
read = AccessMask.FILE_GENERIC_READ
write = AccessMask.FILE_GENERIC_WRITE
all_access = AccessMask.FILE_ALL_ACCESS
# Bitwise operations
rw = read | write
assert rw.contains(read)
assert rw.contains(write)
# Hex representation
print(all_access) # "0x001F01FF"
# Emptiness: idiomatic Python uses truthiness
mask = AccessMask.NONE
assert not mask # Pythonic
assert mask.is_empty() # explicit alternative
assert bool(AccessMask.FILE_READ_DATA) # non-empty is truthy
GUIDs
from win_sd import Guid
guid = Guid("bf967aba-0de6-11d0-a285-00aa003049e2")
assert str(guid) == "bf967aba-0de6-11d0-a285-00aa003049e2"
assert guid != Guid.ZERO
ACE types and entries
All 21 MS-DTYP ACE type variants are available as class constants:
from win_sd import Ace, AceType, AceFlags, AccessMask, Sid, Guid
# Basic ACEs
allow = Ace.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
deny = Ace.deny(Sid.anonymous(), AccessMask.FILE_ALL_ACCESS)
audit = Ace.audit(Sid.everyone(), AccessMask.FILE_WRITE_DATA)
# Object ACEs with GUIDs
guid = Guid("bf967aba-0de6-11d0-a285-00aa003049e2")
obj_ace = Ace.allow_object(Sid.administrators(), AccessMask(0x0010), guid, None)
assert obj_ace.ace_type.is_object_type
assert obj_ace.object_type == guid
# Mandatory integrity label ACEs
from win_sd import IntegrityLevel, IntegrityPolicy
label = Ace.mandatory_label(IntegrityLevel.HIGH, IntegrityPolicy.NO_WRITE_UP)
assert label.integrity_level() == IntegrityLevel.HIGH
assert label.ace_type.is_mandatory_label
# Scoped policy ACEs
sp = Ace.scoped_policy(Sid("S-1-5-21-100-200-300-999"))
assert sp.scoped_policy_sid() is not None
# ACE type classification
assert AceType.ACCESS_ALLOWED.is_allow
assert AceType.ACCESS_DENIED_OBJECT.is_deny
assert AceType.ACCESS_DENIED_OBJECT.is_object_type
assert AceType.ACCESS_ALLOWED_CALLBACK.is_callback
assert AceType.SYSTEM_MANDATORY_LABEL.is_sacl_type
# Validation
ace = Ace.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
ace.validate() # raises ValueError on structural issues
Security descriptor builder
from win_sd import (
SecurityDescriptorBuilder, AccessMask, Sid, AceFlags,
IntegrityLevel, IntegrityPolicy, Guid,
)
sd = (SecurityDescriptorBuilder()
.owner(Sid.local_system())
.group(Sid.administrators())
# Basic ACEs
.deny(Sid.anonymous(), AccessMask.FILE_ALL_ACCESS)
.allow(Sid.administrators(), AccessMask.FILE_ALL_ACCESS)
# ACEs with inheritance flags
.allow_with_flags(
Sid.everyone(),
AccessMask.FILE_GENERIC_READ,
AceFlags.CONTAINER_INHERIT | AceFlags.OBJECT_INHERIT,
)
# Object ACEs with GUIDs
.allow_object(
Sid.administrators(),
AccessMask(0x0010),
Guid("bf967aba-0de6-11d0-a285-00aa003049e2"),
None,
)
# Mandatory integrity label
.mandatory_label(IntegrityLevel.HIGH, IntegrityPolicy.NO_WRITE_UP)
# Protected DACL
.protected_dacl(True)
.build())
sd.validate() # raises ValueError if invalid
Access checks
Three levels of access check, from simple to full:
from win_sd import (
SecurityDescriptorBuilder, SecurityDescriptor, AccessMask, Sid,
Guid, AccessToken, Privilege, IntegrityLevel, GenericMapping,
)
sd = (SecurityDescriptorBuilder()
.owner(Sid.local_system())
.deny(Sid.anonymous(), AccessMask.FILE_ALL_ACCESS)
.allow(Sid.administrators(), AccessMask.FILE_ALL_ACCESS)
.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
.build())
# Simple: SID list + desired mask
result = sd.check_access(
[Sid.administrators(), Sid.everyone()],
AccessMask.FILE_GENERIC_READ,
)
assert result.granted
# Object: adds GUID filtering for Object ACEs
result = sd.check_access_object(
[Sid.administrators()],
AccessMask(0x0010),
Guid("bf967aba-0de6-11d0-a285-00aa003049e2"),
)
# Full: token with integrity, privileges, generic mapping
token = (AccessToken(Sid.administrators())
.with_groups([Sid.everyone()])
.with_integrity(IntegrityLevel.HIGH)
.with_privilege(Privilege.SE_SECURITY_PRIVILEGE))
result = sd.check_access_full(
token,
AccessMask.FILE_ALL_ACCESS,
None, # no object type GUID
GenericMapping.file(), # expand generic rights
)
assert result.granted
print(f"Granted: {result.granted_mask}, Denied: {result.denied_mask}")
SDDL with domain context
from win_sd import Sid, DomainContext, SecurityDescriptor
# Domain SID must be S-1-5-21-X-Y-Z format
domain = DomainContext(Sid("S-1-5-21-100-200-300"))
# Parse with domain-relative aliases (DA = Domain Admins)
sd = SecurityDescriptor.from_sddl_with_domain(
"O:DAG:DUD:(A;;FA;;;DA)", domain
)
assert sd.owner.rid == 512 # Domain Admins RID
# Generate uses domain aliases where possible
sddl = sd.to_sddl_with_domain(domain)
assert "DA" in sddl
SDDL alias functions
from win_sd import sid_from_alias, sid_to_alias, rights_from_codes, rights_to_codes, AccessMask
# SID aliases
sid = sid_from_alias("BA") # BUILTIN\Administrators
assert sid_to_alias(sid) == "BA"
# Rights aliases
mask = rights_from_codes("GRGW")
assert mask.contains(AccessMask.GENERIC_READ)
assert rights_to_codes(AccessMask.FILE_ALL_ACCESS) == "FA"
Binary serialization
from win_sd import SecurityDescriptor, SecurityDescriptorBuilder, AccessMask, Sid
sd = (SecurityDescriptorBuilder()
.owner(Sid.local_system())
.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
.build())
# To/from bytes (self-relative format)
data = sd.to_bytes()
recovered = SecurityDescriptor.from_bytes(data)
assert recovered.owner == sd.owner
Conditional expressions
Callback ACEs carry conditional expressions that are evaluated during
access checks. The ConditionalExpr class handles parsing, generation,
binary serialization, and evaluation.
Parsing from SDDL text
from win_sd import ConditionalExpr
# Simple comparison
expr = ConditionalExpr.from_sddl('@User.dept == "Sales"')
print(expr.to_sddl()) # @User.dept == "Sales"
# Membership check
expr = ConditionalExpr.from_sddl("Member_of{SID(BA)}")
# Logical operators
expr = ConditionalExpr.from_sddl(
'@User.dept == "Sales" && Exists @User.clearance'
)
# Composite literals
expr = ConditionalExpr.from_sddl(
'@User.roles Contains {"admin", "auditor"}'
)
# Not / existence
expr = ConditionalExpr.from_sddl("!(@User.disabled == 1)")
expr = ConditionalExpr.from_sddl("Exists @Resource.classification")
Supported SDDL syntax:
| Element | Examples |
|---|---|
| Attribute references | @User.name, @Resource.name, @Device.name, @Local.name |
| Comparison | ==, !=, <, <=, >, >= |
| Set operators | Contains, Any_of |
| Logical | &&, ||, !(...) |
| Existence | Exists @User.attr |
| Membership | Member_of{SID(BA)}, Not_Member_of{...}, Member_of_Any{...}, Device_Member_of{...} and 4 more variants |
| Literals | integers, "strings", SID(BA), SID(S-1-5-...), {"a", "b"} |
Binary format
Conditional expressions use the MS-DTYP binary RPN format (prefixed with
the bytes 0x61 0x72 0x74 0x78). Round-trip through binary:
from win_sd import ConditionalExpr, parse_conditional_binary, write_conditional_binary
expr = ConditionalExpr.from_sddl('@User.dept == "Engineering"')
# Serialize to binary
data = expr.to_bytes()
assert isinstance(data, bytes)
# Parse back
expr2 = ConditionalExpr.from_bytes(data)
assert expr2.to_sddl() == expr.to_sddl()
# Module-level convenience functions
data = write_conditional_binary(expr)
expr3 = parse_conditional_binary(data)
Evaluation
evaluate() takes a plain Python dict and returns True, False, or
None (unknown — attribute missing or type mismatch):
from win_sd import ConditionalExpr
expr = ConditionalExpr.from_sddl('@User.dept == "Sales"')
# Attribute match
assert expr.evaluate({"user": {"dept": "Sales"}}) is True
assert expr.evaluate({"user": {"dept": "Engineering"}}) is False
# Missing attribute → Unknown
assert expr.evaluate({"user": {}}) is None
The context dict accepts these keys:
| Key | Value type | Purpose |
|---|---|---|
"user" | dict[str, str] | User claim attributes |
"resource" | dict[str, str] | Resource attributes |
"device" | dict[str, str] | Device claim attributes |
"local" | dict[str, str] | Local attributes |
"groups" | list[str] | SID strings for Member_of checks |
from win_sd import ConditionalExpr
# Membership check
expr = ConditionalExpr.from_sddl("Member_of{SID(BA)}")
assert expr.evaluate({"groups": ["S-1-5-32-544"]}) is True
assert expr.evaluate({"groups": ["S-1-5-32-545"]}) is False
# Existence check
expr = ConditionalExpr.from_sddl("Exists @User.clearance")
assert expr.evaluate({"user": {"clearance": "secret"}}) is True
assert expr.evaluate({"user": {}}) is False
# Logical AND
expr = ConditionalExpr.from_sddl(
'@User.dept == "Sales" && @User.region == "EMEA"'
)
assert expr.evaluate({"user": {"dept": "Sales", "region": "EMEA"}}) is True
assert expr.evaluate({"user": {"dept": "Sales", "region": "APAC"}}) is False
Limitations: The Python evaluate context passes attribute values as
strings. Integer comparisons (>=, <, etc.) against SDDL integer
literals will return None because the types don’t match. For integer
comparisons, use the Rust API with a custom AttributeContext.
Extracting conditions from callback ACEs
The Ace.condition property parses the conditional expression from a
callback ACE’s application data:
from win_sd import SecurityDescriptor
sd = SecurityDescriptor.from_sddl(
'D:(XA;;FR;;;WD;(@User.dept == "Sales"))'
)
ace = sd.dacl.entries()[0]
if ace.ace_type.is_callback:
cond = ace.condition
if cond is not None:
print(cond.to_sddl()) # @User.dept == "Sales"
str/repr
str(expr) returns the SDDL text form. repr(expr) returns
ConditionalExpr('...').
from win_sd import ConditionalExpr
expr = ConditionalExpr.from_sddl("Exists @User.clearance")
print(str(expr)) # Exists @User.clearance
print(repr(expr)) # ConditionalExpr('Exists @User.clearance')
acls-rs bridge: PermissionMapping
The bridge connects Windows access masks to acls-rs PermissionSet
values. A PermissionMapping defines how bitmask values translate
to named permissions — different object types need different mappings
because the same bit positions carry different semantics.
Pre-defined mappings
from win_sd import PermissionMapping
file_map = PermissionMapping.file() # namespace "win"
ad_map = PermissionMapping.ad() # namespace "ad"
reg_map = PermissionMapping.registry() # namespace "reg"
print(ad_map.namespace) # "ad"
| Mapping | Namespace | Bit 0x0010 means | Bit 0x0020 means |
|---|---|---|---|
file() | "win" | file_write_ea | file_execute |
ad() | "ad" | read_prop | write_prop |
registry() | "reg" | notify | create_link |
Custom mappings
from win_sd import PermissionMapping
custom = (PermissionMapping("myapp")
.add(0x01, "view")
.add(0x02, "edit")
.add(0x04, "admin"))
perms = custom.to_permission_set(0x05) # view + admin
assert custom.from_permission_set(perms) == 0x05
AccessMask with mapping
The to_permission_set() and from_permission_set() methods accept
an optional mapping parameter. Without it, the default file mapping
is used:
from win_sd import AccessMask, PermissionMapping, AtomicPermission
# Default: file semantics
mask = AccessMask(0x0010)
file_perms = mask.to_permission_set()
assert AtomicPermission("win", "file_write_ea") in file_perms
# AD semantics: same bit, different meaning
ad_perms = mask.to_permission_set(PermissionMapping.ad())
assert AtomicPermission("ad", "read_prop") in ad_perms
# Round-trip through a mapping
ad = PermissionMapping.ad()
mask = AccessMask(0x0030) # READ_PROP | WRITE_PROP
perms = mask.to_permission_set(ad)
recovered = AccessMask.from_permission_set(perms, ad)
assert recovered == mask
SecurityDescriptor with mapping
from win_sd import (
AccessMask, PermissionMapping, SecurityDescriptorBuilder, Sid,
AtomicPermission,
)
sd = (SecurityDescriptorBuilder()
.owner(Sid.administrators())
.allow(Sid.everyone(), AccessMask(0x000F01B7))
.build())
# Effective permissions with AD semantics
ad = PermissionMapping.ad()
perms = sd.effective_permissions([Sid.everyone()], ad)
assert AtomicPermission("ad", "read_prop") in perms
assert AtomicPermission("ad", "write_prop") in perms
assert AtomicPermission("ad", "create_child") in perms
# GrantDenialPair with AD semantics
gd = sd.grant_denial_pair([Sid.everyone()], ad)
for p in gd.effective_permissions():
print(p) # ad:read_prop, ad:write_prop, ...
PermissionSet and GrantDenialPair
from win_sd import (
AtomicPermission, PermissionSet, GrantDenialPair,
)
# Build sets
ps = PermissionSet()
ps.insert(AtomicPermission("ad", "read_prop"))
ps.insert(AtomicPermission("ad", "write_prop"))
assert len(ps) == 2
assert AtomicPermission("ad", "read_prop") in ps
# Set operations
a = PermissionSet()
a.insert(AtomicPermission("ad", "read_prop"))
a.insert(AtomicPermission("ad", "write_prop"))
b = PermissionSet()
b.insert(AtomicPermission("ad", "write_prop"))
b.insert(AtomicPermission("ad", "delete_child"))
union = a | b # 3 permissions
inter = a & b # write_prop only
diff = a - b # read_prop only
# Iteration
for perm in a:
print(f"{perm.namespace()}:{perm.action()}")
# GrantDenialPair
grants = PermissionSet()
grants.insert(AtomicPermission("ad", "read_prop"))
grants.insert(AtomicPermission("ad", "write_prop"))
denials = PermissionSet()
denials.insert(AtomicPermission("ad", "write_prop"))
gd = GrantDenialPair(grants, denials)
effective = gd.effective_permissions()
assert AtomicPermission("ad", "read_prop") in effective
assert AtomicPermission("ad", "write_prop") not in effective
ACL inheritance
from win_sd import SecurityDescriptorBuilder, AccessMask, Sid, AceFlags
parent = (SecurityDescriptorBuilder()
.owner(Sid.local_system())
.allow_with_flags(
Sid.administrators(),
AccessMask.FILE_ALL_ACCESS,
AceFlags.CONTAINER_INHERIT | AceFlags.OBJECT_INHERIT,
)
.build())
# Create a child directory SD with inherited ACEs
child = parent.create_child(is_container=True)
assert child.dacl is not None
assert child.dacl.entries()[0].is_inherited
Principal model and policy
from win_sd import (
Sid, AccessMask, PrincipalModel, SdPolicyBuilder,
)
# Define principals and groups
domain = Sid("S-1-5-21-100-200-300")
alice = Sid("S-1-5-21-100-200-300-1001")
admins = Sid("S-1-5-21-100-200-300-512")
model = PrincipalModel()
model.add_group(admins, "Domain Admins")
model.add_principal(alice, "alice", [admins])
# Build a policy and generate an SD
builder = SdPolicyBuilder()
builder.owner(Sid.local_system())
builder.group(Sid.administrators())
builder.allow(admins, AccessMask.FILE_ALL_ACCESS)
policy = builder.build()
sd = policy.generate(model)
sd.validate()
Integrity levels and tokens
from win_sd import IntegrityLevel, IntegrityPolicy, AccessToken, Sid, Privilege
# Levels with natural ordering
assert IntegrityLevel.LOW != IntegrityLevel.HIGH
# Convert to/from SID
sid = IntegrityLevel.MEDIUM.to_sid()
level = IntegrityLevel.from_sid(sid)
assert level == IntegrityLevel.MEDIUM
# Build tokens with integrity and privileges
token = (AccessToken(Sid("S-1-5-21-100-200-300-1001"))
.with_groups([Sid.administrators(), Sid.everyone()])
.with_integrity(IntegrityLevel.HIGH)
.with_privilege(Privilege.SE_SECURITY_PRIVILEGE)
.with_privilege(Privilege.SE_TAKE_OWNERSHIP_PRIVILEGE))
assert token.integrity_level == IntegrityLevel.HIGH
print(token.all_sids())
Generic mapping
from win_sd import AccessMask, GenericMapping
# Expand generic rights to object-specific bits
mapping = GenericMapping.file()
expanded = mapping.map_mask(AccessMask.GENERIC_READ)
assert expanded.contains(AccessMask.FILE_READ_DATA)
# Pre-defined mappings
_ = GenericMapping.directory()
_ = GenericMapping.registry_key()
WinAcl operations
from win_sd import SecurityDescriptorBuilder, AccessMask, Sid
sd = (SecurityDescriptorBuilder()
.allow(Sid.everyone(), AccessMask.FILE_GENERIC_READ)
.deny(Sid.anonymous(), AccessMask.FILE_ALL_ACCESS)
.build())
dacl = sd.dacl
print(f"{dacl.ace_count()} ACEs, canonical: {dacl.is_canonical()}")
# Get a canonically sorted copy
sorted_dacl = dacl.canonicalized()
assert sorted_dacl.is_canonical()
Type stubs
The package ships PEP 561 type stubs (__init__.pyi) with full signatures
for all 21 classes and 4 module functions. Type checkers (mypy, pyright)
will see complete type information.
# mypy will check these types at analysis time
from win_sd import Sid, AccessMask, AccessToken, SecurityDescriptor
def check_write(sd: SecurityDescriptor, token: AccessToken) -> bool:
result = sd.check_access_full(token, AccessMask.FILE_WRITE_DATA)
return result.granted
abac-wasm: Browser & Node.js
abac-wasm provides WebAssembly bindings for the abac-rs engine, bringing attribute-based access control to browser and Node.js environments through a JSON-based API.
Quick Start
import init, { AbacPolicyWasm } from 'abac-wasm';
await init();
const policy = new AbacPolicyWasm();
// Add a rule
policy.addRuleFromJson(JSON.stringify({
name: "allow-engineers-read",
dimensions: {
role: [{ type: "String", value: "engineer" }],
action: [{ type: "String", value: "read" }],
resource: "all"
},
rule_type: "Allow",
enabled: true
}));
// Evaluate a request
const result = policy.evaluateJson(JSON.stringify({
dimensions: {
role: { value: { type: "String", value: "engineer" } },
action: { value: { type: "String", value: "read" } },
resource: { value: { type: "String", value: "dashboard" } }
}
}));
const decision = JSON.parse(result);
console.log(decision.allowed); // true
Rule Builder
Use AbacRuleBuilderWasm to construct rules without manual JSON:
import { AbacRuleBuilderWasm } from 'abac-wasm';
const builder = new AbacRuleBuilderWasm("allow-admin-all");
builder.addDimension("role", JSON.stringify([
{ type: "String", value: "admin" }
]));
builder.addDimensionAll("resource"); // wildcard — matches any resource
builder.setEnabled(true);
const ruleJson = builder.buildJson();
policy.addRuleFromJson(ruleJson);
Custom Matchers
Register JavaScript callbacks for custom matching logic on specific dimensions. This is useful for threshold comparisons, range checks, or any matching that goes beyond exact equality:
policy.registerMatcher("temperature_high", (ruleValue, requestValue, requestGroups) => {
if (ruleValue === "all") return true;
if (!Array.isArray(ruleValue)) return false;
const actual = requestValue.type === "Float" ? requestValue.value : null;
if (actual == null) return false;
return ruleValue.some(t => {
const threshold = t.type === "Float" ? t.value : null;
return threshold != null && actual >= threshold;
});
});
Error Handling
All errors are structured JavaScript objects with type and message fields:
try {
policy.addRuleFromJson("invalid json");
} catch (e) {
console.log(e.type); // "JsonError"
console.log(e.message); // parsing error details
}
Error types: JsonError (malformed JSON), PolicyError (rule limit or
conflict), ValidationError (invalid field values).
API Reference
See the full API listing in API Documentation.
For build instructions, see WebAssembly Builds.
hbac-wasm: Browser & Node.js
hbac-wasm provides WebAssembly bindings for the hbac-rs engine, bringing FreeIPA-style host-based access control to browser and Node.js environments through a JSON-based API.
Quick Start
import init, { HbacPolicyWasm } from 'hbac-wasm';
await init();
const policy = new HbacPolicyWasm();
// Add a rule allowing all users SSH access
policy.addRuleFromJson(JSON.stringify({
name: "allow-ssh",
user_category: "all",
host_category: "all",
services: ["sshd"],
rule_type: "Allow",
enabled: true
}));
// Check access
const allowed = policy.checkAccessJson(JSON.stringify({
user: "alice",
user_groups: ["admins"],
targethost: "server.example.com",
service: "sshd"
}));
console.log(allowed); // true
Rule Builder
Use HbacRuleBuilderWasm to construct rules without manual JSON:
import { HbacRuleBuilderWasm } from 'hbac-wasm';
const builder = new HbacRuleBuilderWasm("allow-web-admins");
builder.addUserGroups(JSON.stringify(["admins"]));
builder.hostCategoryAll();
builder.addServices(JSON.stringify(["httpd", "nginx"]));
builder.setEnabled(true);
const ruleJson = builder.buildJson();
policy.addRuleFromJson(ruleJson);
Request Builder
Build requests programmatically with HbacRequestWasm:
import { HbacRequestWasm } from 'hbac-wasm';
const request = new HbacRequestWasm("alice", "server.example.com", "sshd");
request.addUserGroups(JSON.stringify(["admins", "engineers"]));
request.addTargethostGroups(JSON.stringify(["production"]));
const json = request.toJson();
const allowed = policy.checkAccessJson(json);
Detailed Evaluation
Get matched and unmatched rule names for debugging:
const result = policy.evaluateDetailedJson(JSON.stringify({
user: "alice",
targethost: "server.example.com",
service: "sshd"
}));
const detail = JSON.parse(result);
console.log(detail.allowed); // true
console.log(detail.matched_rules); // ["allow-ssh"]
console.log(detail.not_matched_rules); // []
Error Handling
All errors are structured JavaScript objects with type and message fields:
try {
policy.addRuleFromJson("invalid json");
} catch (e) {
console.log(e.type); // "JsonError"
console.log(e.message); // parsing error details
}
Error types: JsonError (malformed JSON), PolicyError (rule limit or
conflict), ValidationError (invalid field values).
Differences from hbac-rs
- No JIT compilation (cranelift is excluded from WASM builds)
- Single-threaded only (
HbacPolicyLocalbackend, notHbacPolicy) - JSON-based API instead of native Rust types
API Reference
See the full API listing in API Documentation.
For build instructions, see WebAssembly Builds.
Performance Testing Framework
Your access control system is built: permissions, roles, attributes, temporal rules, HBAC, ABAC, and policy composition. Before deploying to production, you need to answer three questions:
- Can the system handle your request volume at acceptable latency?
- Does enabling caching, JIT, or host filtering actually improve your workload?
- Has a code change introduced a performance regression?
The bac-perf tool answers all three. It generates realistic rule sets for
both HBAC and ABAC, runs benchmarks under controlled conditions, and compares
results across implementations and runs.
Installation
cargo build --release --package perf-testing
The binary is at target/release/bac-perf.
Workflow overview
A typical performance validation session has three steps:
# 1. Generate a fixture matching your production profile
bac-perf generate --bac-type hbac --count 1000 --distribution sssd-prod \
--output prod_1k --seed 42
# 2. Run benchmarks
bac-perf bench --bac-type hbac --fixture prod_1k --scenario all --cache
# 3. Compare against a baseline
bac-perf compare baseline.json current.json
For ABAC, replace --bac-type hbac with --bac-type abac. You can also
benchmark ABAC using HBAC fixtures for direct performance comparison (see
Cross-BAC Benchmarking).
Architecture
graph TD
A[perf-testing/] --> B[synthesis/<br/>Rule generation]
A --> C[fixtures/<br/>JSON persistence]
A --> D[runner/<br/>Benchmark execution]
A --> E[cli/<br/>Command-line interface]
- Synthesis generates HBAC and ABAC rules using configurable distribution presets.
- Fixtures persist generated rules as compressed JSON for reproducible benchmarks. HBAC fixtures can be benchmarked with both implementations.
- Runner executes benchmark scenarios and collects latency, throughput, and memory metrics for both HBAC and ABAC.
- CLI ties everything together through the
bac-perfcommand.
Quick example
Generate 1,000 HBAC rules with the SSSD production distribution, run all benchmark scenarios with caching enabled, and save the results:
bac-perf generate --bac-type hbac --count 1000 --distribution sssd-prod --output hbac_1k_prod --seed 42
bac-perf bench \
--bac-type hbac \
--fixture hbac_1k_prod \
--scenario all \
--cache \
--format json \
--output results/hbac_baseline.json
For ABAC:
bac-perf generate --bac-type abac --count 1000 --distribution sssd-prod --output abac_1k_prod --seed 42
bac-perf bench \
--bac-type abac \
--fixture abac_1k_prod \
--scenario all \
--cache \
--format json \
--output results/abac_baseline.json
Example terminal output:
Scenario: single_request_latency
Setup:
Rules loaded: 1000
Memory usage: 454000 bytes (0.5 KB/rule)
Index build time: 0.6 ms
Results:
Requests: 10000
Throughput: 3.5M req/s
Latency:
Mean: 280 ns
Median: 270 ns
P95: 350 ns
P99: 410 ns
See Performance Results for benchmark data across all rule counts from 100 to 1,000,000.
Distribution presets
Three presets model different deployment profiles:
| Preset | user=all | host=all | svc=all | deny % | Groups |
|---|---|---|---|---|---|
sssd-prod | 15% | 30% | 40% | 5% | dense (avg 10) |
dev | 40% | 50% | 60% | 1% | sparse (avg 3) |
high-security | 5% | 10% | 15% | 20% | very specific |
Choose the preset that most closely matches your production rule distribution.
Benchmark scenarios
| Scenario | What it measures |
|---|---|
single-latency | Request latency with warm cache (10K matching requests) |
uncached-latency | Raw evaluation without cache benefit (10K unique requests) |
throughput | Sustained load with 80/20 match/non-match mix |
build-time | Policy load and index construction time |
all | Run all of the above |
Output formats
terminal(default) – human-readable tables.json– machine-readable, suitable forbac-perf compare.csv– spreadsheet-compatible.markdown– documentation-ready tables.
What to read next
- Generating Test Fixtures – creating HBAC and ABAC fixtures that match your production profile.
- Running Benchmarks – executing scenarios, interpreting results, and detecting regressions.
- Cross-BAC Benchmarking – comparing HBAC and ABAC performance using the same fixtures.
- CLI Reference – complete option reference for
bac-perf.
Generating Test Fixtures
Before benchmarking, you need a rule set that reflects your production
environment. The bac-perf generate command synthesizes HBAC and ABAC rules
with configurable distributions, entity pools, and seeded randomness so that
benchmarks are both realistic and reproducible.
Basic generation
# Generate HBAC fixture
bac-perf generate --bac-type hbac --count 1000 --output hbac_1k_prod \
--distribution sssd-prod
# Generate ABAC fixture (same distributions apply)
bac-perf generate --bac-type abac --count 1000 --output abac_1k_prod \
--distribution sssd-prod
This creates fixtures/{name}.json.gz – a gzip-compressed JSON file
containing 1,000 rules, entity pools (HBAC only), and generation metadata.
Distribution presets
Presets control how rules are distributed across the five generation patterns (universal-allow, user-specific, host-specific, service-specific, fully-specific) and how often category=all, deny rules, and disabled rules appear.
sssd-prod
Models a production SSSD deployment: most rules target specific entities with dense group membership.
- 15% user category=all, 30% host category=all, 40% service category=all
- 5% deny rules, 2% disabled rules
- Average 10 groups per dimension
dev
Models a development environment: permissive rules, many category=all dimensions.
- 40% user category=all, 50% host category=all, 60% service category=all
- 1% deny rules, 5% disabled rules
- Average 3 groups per dimension
high-security
Models a high-security environment: very specific rules with many explicit denials.
- 5% user category=all, 10% host category=all, 15% service category=all
- 20% deny rules, 1% disabled rules
- Average 1-2 entities per dimension
Reproducibility
Use --seed for deterministic generation. The same seed, count, distribution,
and BAC type always produce identical rules:
bac-perf generate --bac-type hbac --count 1000 --seed 42 --output run_a
bac-perf generate --bac-type hbac --count 1000 --seed 42 --output run_b
# run_a.json.gz and run_b.json.gz are identical
This is essential for regression testing: benchmark against the same fixture before and after a code change to isolate the performance impact.
HBAC vs ABAC fixtures
Both fixture types use the same distribution presets, but differ in structure:
| Aspect | HBAC | ABAC |
|---|---|---|
| Dimensions | Fixed: user, host, service | Mapped: user, resource, action |
| Entity pools | Included in metadata | Not included (generic) |
| Benchmark compatibility | HBAC only | Both HBAC and ABAC |
ABAC fixtures use the same distributions but map them to ABAC’s generic dimension model:
userdimension (same as HBAC)resourcedimension (equivalent to HBAC’stargethost)actiondimension (equivalent to HBAC’sservice)
This mapping preserves statistical properties while allowing ABAC benchmarks.
Entity pools
Generated rules draw from pools of synthetic entities:
| Entity | Default count |
|---|---|
| Users | 500 |
| User groups | 50 |
| Hosts | 200 |
| Host groups | 20 |
| Services | 30 |
| Service groups | 5 |
Pool sizes are fixed by the preset. Entity names follow the pattern
user_0001, host_0001, etc.
Output format
Fixtures are compressed JSON files with this structure:
{
"version": "1.0",
"bac_type": "hbac",
"metadata": {
"generated_at": "2025-01-20T10:30:00Z",
"rule_count": 1000,
"synthesis_config": {
"seed": 42,
"distributions": { "..." }
},
"entity_pools": {
"users": ["user_0001", "..."],
"hosts": ["host_0001", "..."]
}
},
"rules": [
{
"name": "rule_0001",
"enabled": true,
"user_category": "all",
"..."
}
]
}
Gzip compression reduces storage significantly (e.g., 1K rules: ~500 KB uncompressed to ~17 KB compressed).
Fixture management
# List all fixtures
bac-perf list
# Show detailed information (rule count, size, generation date)
bac-perf list --verbose
# Validate fixture integrity
bac-perf validate hbac_1k_prod
# Delete a fixture
rm fixtures/hbac_1k_prod.json.gz
Generating a test suite
A standard suite covers the range from quick tests to stress tests:
# HBAC fixtures
for size in 100 1000 10000 100000; do
bac-perf generate \
--bac-type hbac \
--count $size \
--distribution sssd-prod \
--output hbac_${size}_baseline \
--seed 42
done
# ABAC fixtures (optional, for native ABAC benchmarks)
for size in 100 1000 10000; do
bac-perf generate \
--bac-type abac \
--count $size \
--distribution sssd-prod \
--output abac_${size}_baseline \
--seed 42
done
Tip: You don’t need separate ABAC fixtures to benchmark ABAC performance. ABAC can benchmark HBAC fixtures directly through automatic conversion. See Cross-BAC Benchmarking.
Next steps
With fixtures generated, run benchmarks to measure latency, throughput, and memory under controlled conditions.
See Running Benchmarks.
Running Benchmarks
The bac-perf bench command measures evaluation latency, throughput, policy
build time, and memory usage against a generated fixture. This page covers
the available scenarios, how to interpret results, and how to detect
regressions.
Basic benchmarking
# Run all scenarios with caching enabled (HBAC)
bac-perf bench --bac-type hbac --fixture hbac_1k_prod --scenario all --cache
# Run a specific scenario
bac-perf bench --bac-type hbac --fixture hbac_1k_prod --scenario single-latency --cache
# Save results as JSON for later comparison
bac-perf bench \
--bac-type hbac \
--fixture hbac_1k_prod \
--scenario all \
--cache \
--format json \
--output results/hbac_baseline.json
# Run ABAC benchmarks
bac-perf bench --bac-type abac --fixture abac_1k_prod --scenario all --cache
Scenarios
single-latency
Measures individual request evaluation time with a warm cache.
- 10,000 matching requests with 1,000 warmup iterations.
- Reports latency percentiles (min, mean, median, P95, P99, max).
- Represents best-case performance: cache is warm, all requests match.
bac-perf bench --bac-type hbac --fixture hbac_1k_prod --scenario single-latency --cache
uncached-latency
Measures raw evaluation performance without cache benefit.
- 10,000 unique non-matching requests, no warmup.
- Cache is enabled but never hit (every request is unique).
- Isolates Bloom filter, decision tree, and sequential evaluation cost.
The latency distribution is typically bimodal: most requests are rejected quickly by the Bloom filter (sub-microsecond), while the few that pass through trigger O(n) sequential evaluation and produce high-latency outliers. This is why P95 can be lower than the mean.
bac-perf bench --fixture hbac_1k_prod --scenario uncached-latency
throughput
Sustained load with a mixed request profile.
- 10,000 requests (80% matching, 20% non-matching) with 1,000 warmup.
- Reports requests per second under realistic cache pressure.
bac-perf bench --fixture hbac_1k_prod --scenario throughput --cache
build-time
Policy initialization performance.
- Measures rule loading, deserialization, and index construction time.
- Reports memory allocation.
bac-perf bench --fixture hbac_1k_prod --scenario build-time
Comparing runs
Save results as JSON and use bac-perf compare to detect regressions:
# Baseline (before change)
bac-perf bench --fixture hbac_1k --scenario all --cache \
--format json --output baseline.json
# Current (after change)
bac-perf bench --fixture hbac_1k --scenario all --cache \
--format json --output current.json
# Compare
bac-perf compare baseline.json current.json
The comparison shows percentage changes for each metric with pass/fail indicators.
JIT comparison
Build with JIT support and compare:
cargo build --release --features jit -p perf-testing
# Without JIT
bac-perf bench --bac-type hbac --fixture hbac_10k --scenario uncached-latency \
--format json --output no_jit.json
# With JIT
bac-perf bench --bac-type hbac --fixture hbac_10k --scenario uncached-latency --jit \
--format json --output with_jit.json
bac-perf compare no_jit.json with_jit.json
JIT compilation provides improvement for uncached evaluation. It has no effect on cached lookups. See Performance Results for measured JIT impact.
Criterion integration
For statistical rigor, use the Criterion benchmarks:
cd crates/perf-testing
cargo bench
# View HTML reports
open target/criterion/report/index.html
Criterion provides outlier detection, variance analysis, historical comparison, and regression detection with statistical confidence intervals.
Interpreting results
Cached evaluation
With caching enabled, expect sub-microsecond mean latency across all rule counts. The optimization stack (Bloom filter, decision tree, indexed cache, deny-rule index) handles the heavy lifting.
Uncached evaluation
Without cache benefit, latency scales roughly linearly with rule count. The Bloom filter rejects most requests quickly, but false positives trigger sequential evaluation at O(n) cost.
Warning signs
- P99 much higher than mean (cached): investigate cache misses or system noise.
- P95 lower than mean (uncached): normal bimodal distribution from Bloom filter rejection.
- Cache hit rate below 90%: consider pre-warming or reviewing request diversity.
- Build time above 1 second: expected at 100K+ rules. Consider lazy loading for hot-reload scenarios.
See Performance Results for benchmark data across all rule counts.
Regression detection workflow
# 1. Generate a standard fixture (same seed for consistency)
bac-perf generate --count 1000 --distribution sssd-prod --output regression_1k --seed 42
# 2. Run on main branch
git checkout main
cargo build --release -p perf-testing
bac-perf bench --fixture regression_1k --scenario all --cache \
--format json --output before.json
# 3. Run on feature branch
git checkout feature-branch
cargo build --release -p perf-testing
bac-perf bench --fixture regression_1k --scenario all --cache \
--format json --output after.json
# 4. Compare
bac-perf compare before.json after.json
Scaling analysis
Test across multiple rule counts to understand how your workload scales:
for size in 100 1000 10000 100000; do
bac-perf bench --fixture hbac_${size}_baseline --scenario single-latency \
--cache --format json --output cached_${size}.json
bac-perf bench --fixture hbac_${size}_baseline --scenario uncached-latency \
--format json --output uncached_${size}.json
done
Output formats
| Format | Use case |
|---|---|
terminal | Interactive review (default) |
json | Input to bac-perf compare, CI pipelines |
csv | Spreadsheet analysis |
markdown | Documentation tables |
Next steps
For the complete list of command options, flags, and environment variables, see the CLI reference.
See CLI Reference.
Cross-BAC Benchmarking
You need to compare HBAC and ABAC performance on identical rule sets. The challenge: HBAC fixtures use a three-dimensional user/host/service model, while ABAC uses arbitrary dimensions. How can you benchmark both implementations fairly?
The bac-perf tool solves this through automatic conversion: ABAC can load
HBAC fixtures by mapping the three dimensions to equivalent ABAC dimensions.
This enables direct performance comparison without maintaining separate fixture
sets.
The mapping
HBAC’s fixed dimensions map 1:1 to ABAC dimensions:
| HBAC Dimension | ABAC Dimension | Example |
|---|---|---|
user | user | alice, group:admins |
targethost | resource | web01.example.com, group:prod_servers |
service | action | sshd, group:remote_access |
Groups are prefixed with group: in ABAC to distinguish them from primary
values. The conversion preserves:
- Semantic equivalence – same allow/deny decisions
- Rule types – Allow vs Deny
- Enabled status – active rules only
- Group membership – all groups converted
Basic cross-benchmark
Generate one HBAC fixture, benchmark it with both implementations:
# 1. Generate HBAC fixture
bac-perf generate --bac-type hbac --count 1000 --output shared_1k --seed 42
# 2. Benchmark with HBAC
bac-perf bench --bac-type hbac --fixture shared_1k --scenario throughput \
--format json --output hbac_results.json
# 3. Benchmark with ABAC (automatic conversion)
bac-perf bench --bac-type abac --fixture shared_1k --scenario throughput \
--format json --output abac_results.json
# 4. Compare
bac-perf compare hbac_results.json abac_results.json
The fixture is loaded once, converted automatically when used with ABAC, and benchmarked under identical conditions.
Complete comparison workflow
Compare all scenarios at multiple scales:
# Generate fixtures
for size in 100 1000 10000; do
bac-perf generate --bac-type hbac --count $size --output shared_${size} --seed 42
done
# Benchmark HBAC
for fixture in shared_100 shared_1000 shared_10000; do
bac-perf bench --bac-type hbac --fixture $fixture --scenario all \
--format json --output ${fixture}_hbac.json
done
# Benchmark ABAC (same fixtures)
for fixture in shared_100 shared_1000 shared_10000; do
bac-perf bench --bac-type abac --fixture $fixture --scenario all \
--format json --output ${fixture}_abac.json
done
# Compare each scale
for size in 100 1000 10000; do
echo "=== Comparison at ${size} rules ==="
bac-perf compare shared_${size}_hbac.json shared_${size}_abac.json
done
Example results
Baseline (Before ABAC Optimization)
Using a shared_1000 fixture with 1,000 rules, showing the initial
implementation gap:
HBAC (native):
Scenario: throughput
Results:
Throughput: 399K req/s
Mean latency: 2.50 µs
P95 latency: 3.50 µs
P99 latency: 3.92 µs
ABAC (baseline, unoptimized):
Scenario: throughput
Results:
Throughput: 4.2K req/s
Mean latency: 237 µs
P95 latency: 417 µs
P99 latency: 552 µs
Historical gap: HBAC was ~95× faster (baseline)
Current (After Bitmap Deny Index + Arc<str> Migration)
After implementing bitmap deny index in both engines, Arc<str> migration in
HBAC, and compiled evaluator in HBAC, there is no crossover – HBAC is
faster or equal at all scales on x86_64:
At 1,000 rules (x86_64, mixed workload):
HBAC: 5.24M req/s (191 ns mean)
ABAC: 4.02M req/s (249 ns mean)
Ratio: HBAC is 1.3× faster
At 100,000 rules (x86_64, mixed workload):
HBAC: 1.54M req/s (651 ns mean)
ABAC: 1.22M req/s (819 ns mean)
Ratio: HBAC is 1.3× faster
At 1,000,000 rules (x86_64, mixed workload):
HBAC: 201K req/s (4.98 µs mean)
ABAC: 192K req/s (5.21 µs mean)
Ratio: ~equal
Key insight: With bitmap deny index optimization ported to HBAC and
memory-optimized rule storage (index-based references, Arc<str> categories),
HBAC is faster or equal at all scales. ABAC’s advantage is N-dimensional
flexibility and 3.2x less memory (76 MB vs 241 MB at 1M rules).
Understanding the performance characteristics
Both engines now share bitmap deny index optimization. HBAC is faster at all scales due to its specialized three-dimensional structure:
| Aspect | HBAC | ABAC |
|---|---|---|
| Dimensions | Fixed (user, host, service) | Generic (N dimensions) |
| Index structure | Decision tree + bitmap deny index | Bitmap deny index + compiled eval |
| Attribute access | Direct struct fields | Pre-extracted array |
| Rule storage | Arc<str> + Box<[Arc<str>]> | Generic AttributeType sets |
| Deny index | AHashMap<Arc<str>, Vec<BitPos>> | AHashMap<AttributeType, Vec<BitPos>> |
| Memory @ 100K | 24.0 MB (0.25 KB/rule) | 7.6 MB (0.078 KB/rule) |
| Memory @ 1M | 240.7 MB | 76 MB (3.2x less) |
At all scales: HBAC’s specialization (direct field access, pre-compiled
decision tree, Arc<str> deduplication) provides a consistent advantage.
ABAC’s advantages are N-dimensional flexibility and 3.2x less memory per rule, not throughput.
Use cases
1. Migration planning
Measure the performance impact before migrating from HBAC to ABAC:
# Current HBAC performance
bac-perf bench --bac-type hbac --fixture prod_rules
# Expected ABAC performance
bac-perf bench --bac-type abac --fixture prod_rules
If the performance difference is acceptable, ABAC’s flexibility may justify the overhead. If not, stay with HBAC or optimize hot paths.
2. Regression detection
Validate that ABAC optimizations don’t break correctness:
# Baseline HBAC (known correct)
bac-perf bench --bac-type hbac --fixture test_5k --format json --output baseline.json
# ABAC after optimization changes
bac-perf bench --bac-type abac --fixture test_5k --format json --output current.json
# Compare -- decisions should be identical, latency may differ
bac-perf compare baseline.json current.json
3. Trade-off analysis
Quantify the flexibility vs performance trade-off:
# Generate at multiple scales
for size in 100 1000 10000; do
bac-perf generate --bac-type hbac --count $size --output scale_${size} --seed 42
# Benchmark both
bac-perf bench --bac-type hbac --fixture scale_${size} --scenario throughput -o hbac_${size}.json
bac-perf bench --bac-type abac --fixture scale_${size} --scenario throughput -o abac_${size}.json
done
# Extract throughput ratios
for size in 100 1000 10000; do
hbac_tput=$(jq '.requests_per_second' hbac_${size}.json)
abac_tput=$(jq '.requests_per_second' abac_${size}.json)
ratio=$(echo "scale=2; $hbac_tput / $abac_tput" | bc)
echo "${size} rules: HBAC is ${ratio}× faster than ABAC"
done
Conversion guarantees
The HBAC → ABAC converter provides:
✓ Semantic equivalence
Same allow/deny decisions for all requests.
✓ Rule preservation
Names, types (Allow/Deny), and enabled status preserved.
✓ Group membership
All user/host/service groups mapped to ABAC groups with group: prefix.
× NOT reversible
Native ABAC fixtures (4+ dimensions, custom types) cannot be converted to HBAC.
Limitations
What you CAN do
- Run HBAC fixtures with both HBAC and ABAC
- Compare performance on identical rule sets
- Validate ABAC correctness against HBAC baseline
- Measure overhead of generic vs specialized engines
What you CANNOT do
- Run native ABAC fixtures with HBAC (no reverse conversion)
- Use ABAC-specific features (4+ dimensions, IpCidr types, custom matchers) with HBAC fixtures
- Expect identical performance (ABAC is generic, HBAC is specialized)
Best practices
- Same seed – Always use
--seed 42for reproducible fixtures. - Same scenarios – Run the same benchmark scenarios for both implementations.
- Multiple runs – Repeat benchmarks 3-5 times and average results to reduce variance.
- Release mode – Always benchmark in
--releasemode. - Cache consistency – Use
--cachefor both or neither (not one).
What to read next
- CLI Reference – full option reference for
bac-perf. - Running Benchmarks – interpreting benchmark results.
- Performance Results – HBAC performance data across all scales.
CLI Reference
Complete reference for the bac-perf command-line tool.
Installation
cargo build --release --package perf-testing
The binary is at target/release/bac-perf.
Commands
generate
Generate HBAC or ABAC rule fixtures for benchmarking.
bac-perf generate [OPTIONS]
| Option | Description | Default |
|---|---|---|
--bac-type <TYPE> | BAC type: hbac or abac | hbac |
--count <N> | Number of rules to generate | required |
--output <NAME> | Output fixture name | required |
--distribution <PRESET> | Distribution preset: sssd-prod, dev, high-security | sssd-prod |
--seed <N> | Random seed for reproducibility | 42 |
--fixtures-dir <PATH> | Fixtures directory | fixtures |
# Generate HBAC fixture
bac-perf generate --bac-type hbac --count 1000 --output prod_1k \
--distribution sssd-prod --seed 42
# Generate ABAC fixture (same distributions)
bac-perf generate --bac-type abac --count 1000 --output abac_1k \
--distribution sssd-prod --seed 42
bench
Run performance benchmarks against a fixture.
bac-perf bench [OPTIONS]
| Option | Description | Default |
|---|---|---|
--bac-type <TYPE> | BAC type: hbac or abac | hbac |
--fixture <NAME> | Fixture to benchmark | required |
--scenario <NAME> | Benchmark scenario | all |
--cache | Enable caching | disabled |
--jit | Enable JIT compilation (HBAC only, requires --features jit build) | disabled |
--format <FORMAT> | Output format: terminal, json, csv, markdown | terminal |
--output <PATH> | Save results to file | stdout |
--fixtures-dir <PATH> | Fixtures directory | fixtures |
Scenarios:
| Scenario | Description |
|---|---|
single-latency | Request latency with warm cache (10K matching requests) |
uncached-latency | Raw evaluation without cache benefit (10K unique requests) |
throughput | Sustained load with 80/20 match/non-match mix |
build-time | Policy load and index construction time |
all | Run all scenarios |
# Benchmark HBAC
bac-perf bench --bac-type hbac --fixture prod_1k --scenario all --cache \
--format json --output results.json
# Benchmark ABAC using HBAC fixture (automatic conversion)
bac-perf bench --bac-type abac --fixture prod_1k --scenario all \
--format json --output abac_results.json
Note: ABAC can benchmark both ABAC and HBAC fixtures. When using an HBAC
fixture with --bac-type abac, rules are automatically converted from the
three-dimensional user/host/service model to the equivalent ABAC
user/resource/action dimensions. This enables direct performance comparison.
See Cross-BAC Benchmarking.
list
List available fixtures.
bac-perf list [OPTIONS]
| Option | Description | Default |
|---|---|---|
--verbose | Show rule count, size, and generation date | summary only |
--fixtures-dir <PATH> | Fixtures directory | fixtures |
bac-perf list --verbose
compare
Compare two benchmark runs to detect regressions.
bac-perf compare <BASELINE> <CURRENT> [OPTIONS]
| Argument | Description |
|---|---|
<BASELINE> | Path to baseline JSON results |
<CURRENT> | Path to current JSON results |
| Option | Description | Default |
|---|---|---|
--format <FORMAT> | Output format: terminal, markdown | terminal |
bac-perf compare baseline.json current.json
validate
Check fixture integrity (schema version, JSON structure, rule count consistency).
bac-perf validate <FIXTURE> [OPTIONS]
| Argument | Description |
|---|---|
<FIXTURE> | Fixture name to validate |
| Option | Description | Default |
|---|---|---|
--fixtures-dir <PATH> | Fixtures directory | fixtures |
bac-perf validate prod_1k
Environment variables
| Variable | Description |
|---|---|
BAC_PERF_FIXTURES_DIR | Override the default fixtures directory |
BAC_PERF_OUTPUT_FORMAT | Override the default output format |
export BAC_PERF_FIXTURES_DIR=/var/lib/bac-perf/fixtures
bac-perf list
Exit codes
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error (invalid arguments, file not found) |
| 2 | Validation error (fixture is invalid) |
| 3 | Benchmark error (evaluation failed) |
See also
Caching Architecture
This document describes the internal architecture of the hbac-rs caching and evaluation pipeline. For a tutorial introduction, see Caching System.
Overview
The caching system is designed for SSSD-like deployments where HBAC rules are periodically fetched from LDAP and evaluated locally. It provides:
- Pluggable sources for fetching rules from any backend
- Composable filters for host-specific rule selection
- Indexed caches with multi-dimensional lookups
- Disk persistence for offline operation
- Incremental updates for minimal network traffic
- A six-layer optimization stack for sub-microsecond evaluation
Pipeline Architecture
graph TD
A[RuleSource<br/>LDAP, file, or custom backend] --> B[RuleFilter<br/>Applicability filtering]
B --> C[IndexedCache<br/>Indexed memory + optional disk]
C --> D[Optimization Stack<br/>Bloom, decision tree, deny index]
D --> E[Evaluator<br/>Fast HBAC evaluation]
RulePipeline orchestrates these stages. Build one with
RulePipeline::builder():
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let pipeline = RulePipeline::builder()
.with_source(Box::new(ldap_source))
.with_filter(HostFilter::for_host("web01").with_group("webservers"))
.with_cache(IndexedCache::new())
.build();
}
Component Design
RuleSource
#![allow(unused)]
fn main() {
pub trait RuleSource {
fn fetch_all(&mut self) -> Result<Vec<HbacRule>, RuleSourceError>;
fn fetch_updated_since(&mut self, timestamp: u64) -> Result<Vec<HbacRule>, RuleSourceError>;
fn is_available(&self) -> bool;
}
}
fetch_updated_since enables incremental updates: the pipeline tracks the
last refresh timestamp and only fetches rules modified after that point.
CompositeSource provides fallback: if the primary source is unavailable
(is_available() returns false or fetch_all fails), it tries the next
source.
flowchart LR
CS[CompositeSource] --> S1{Source 1<br/>LDAP}
S1 -->|available| R1[Return rules]
S1 -->|unavailable / error| S2{Source 2<br/>File}
S2 -->|available| R2[Return rules]
S2 -->|unavailable| ERR[RuleSourceError]
RuleFilter
#![allow(unused)]
fn main() {
pub trait RuleFilter {
fn should_cache(&self, rule: &HbacRule) -> bool;
fn filter<'a>(&self, rules: &'a [HbacRule]) -> Vec<&'a HbacRule>;
fn filter_cloned(&self, rules: &[HbacRule]) -> Vec<HbacRule>;
}
}
filter and filter_cloned have default implementations that delegate to
should_cache. Override them for batch optimizations.
HostFilter is the most impactful filter. It caches a rule when any of
these conditions holds:
flowchart TD
RULE[Rule] --> C1{host dimension<br/>category=all?}
C1 -->|yes| CACHE[Cache rule]
C1 -->|no| C2{host name<br/>in rule's names?}
C2 -->|yes| CACHE
C2 -->|no| C3{any host group<br/>overlaps rule's groups?}
C3 -->|yes| CACHE
C3 -->|no| SKIP[Skip rule]
This typically reduces the working set by 95-99% in large deployments.
AndFilter and OrFilter compose filters: AndFilter requires all children
to accept; OrFilter accepts if any child accepts.
RuleCache and IndexedCache
#![allow(unused)]
fn main() {
pub trait RuleCache {
fn store(&mut self, rules: Vec<HbacRule>);
fn get(&self, name: &str) -> Option<&HbacRule>;
fn get_all(&self) -> Vec<&HbacRule>;
fn len(&self) -> usize;
fn clear(&mut self);
fn stats(&self) -> CacheStats;
}
}
IndexedCache implements RuleCache with multi-dimensional indexes:
flowchart TD
IC[IndexedCache] --> NM[Name Map<br/>AHashMap name → rule]
IC --> UI[UserIndex<br/>user/group → rule names]
IC --> HI[HostIndex<br/>host/group → rule names]
IC --> CI[CompositeIndex<br/>user+host → rule names]
Q[find_by_user_and_host] --> UI
Q --> HI
UI --> INT[Intersect<br/>candidate sets]
HI --> INT
INT --> RS[Small candidate set]
The find_by_user_and_host method intersects user and host candidates to
produce a small set of rules that could possibly match, avoiding full scans.
DiskCache (requires the serde feature) wraps a file path and provides
flush() to persist rules as JSON. On construction, it loads existing rules
from disk if the file exists.
RuleIndex
#![allow(unused)]
fn main() {
pub trait RuleIndex {
fn index(&mut self, rules: &[HbacRule]);
fn find_by_user(&self, user_id: &str, user_groups: &[String]) -> Vec<usize>;
fn find_by_host(&self, host: &str, host_groups: &[String]) -> Vec<usize>;
fn find_by_service(&self, service: &str, service_groups: &[String]) -> Vec<usize>;
fn clear(&mut self);
}
}
Each index maps entity names and group names to sets of rule indices. Rules
with category=all in the indexed dimension match every query. The index
returns rule indices (not rules themselves) – the caller resolves them
through the canonical rule slice. Internal maps use AHashMap<Arc<str>, Vec<usize>> for fast non-cryptographic hashing.
Optimization Stack
Evaluations pass through six layers in order. Each layer is skipped if a previous layer already produced a result.
flowchart TD
REQ[Incoming Request] --> L0{Layer 0<br/>Constant Result?}
L0 -->|yes| R0[Return stored result]
L0 -->|no| L1{Layer 1<br/>LRU Cache Hit?}
L1 -->|hit| R1[Return cached result]
L1 -->|miss| L2{Layer 2<br/>Bloom Filter}
L2 -->|definite no-match| R2[Return Deny]
L2 -->|maybe match| L3{Layer 3<br/>Decision Tree}
L3 -->|resolved| R3[Return result]
L3 -->|not available| L4{Layer 4<br/>Deny-Rule Index}
L4 -->|deny found| R4[Return Deny]
L4 -->|no deny| L5[Layer 5<br/>Composite Index]
L5 --> R5[Return result]
L3 -..->|optional| L6[Layer 6: JIT]
style L6 stroke-dasharray: 5 5
Layer 0: Constant-Result Fast Path
When the decision tree collapses to a single outcome (e.g., a universal deny rule exists, or a universal allow with no deny rules), the result is stored at build time and returned immediately. Bypasses all other layers.
Layer 1: LRU Memoization Cache
1,024-entry cache keyed by a u64 hash of (user, host, service, groups).
Group hashing uses order-independent wrapping addition so that the same set of
groups produces the same hash regardless of iteration order. Zero heap
allocations per lookup.
Layer 2: Bloom Filter Pre-screening
Fast negative check before full evaluation. Configured for a 1% false
positive rate. Effective for non-matching requests when no category=all
rules exist. In SSSD deployments where category=all is common, the Bloom
filter rarely rejects – but the cost of the check is negligible.
Layer 3: Decision Tree
Rules are compiled into a DecisionNode tree at load time. The tree
structure depends on the rule set:
- When a universal allow exists alongside specific deny rules, the tree stores only the deny rules, reducing evaluation from O(n) to O(d) where d is the deny rule count.
- Otherwise, the tree checks dimensions in sequence: user, then host, then service.
Layer 4: Deny-Rule Index
Indexes deny rules by user, host, and service dimensions using
AHashMap<Arc<str>, Vec<BitPos>> per dimension. Each deny rule is assigned a
BitPos (word index + u64 bitmask). On evaluation, per-dimension inverted
indexes produce bitmasks that are ANDed together; any surviving bit indicates a
deny match.
flowchart LR
U[User Candidates<br/>bitmask] --> INT[Bitmask<br/>Intersection]
H[Host Candidates<br/>bitmask] --> INT
INT --> SVC{Service<br/>Match?}
SVC -->|match| DENY[Deny]
SVC -->|no match| PASS[Pass to next layer]
This narrows the candidate set from potentially hundreds of deny rules to 2-3.
Layer 5: Composite Indexing (Fallback)
When the decision tree is not available (e.g., the rule set does not fit a
tree pattern), the evaluator falls back to CompositeIndex for candidate
selection. O(1) for category=all dimensions, O(g) for specific entities
where g is the group count.
Layer 6: JIT Compilation (optional)
Requires the jit feature flag. Compiles per-rule matching logic to native
code. Provides throughput improvement on uncached workloads; no benefit on
cached workloads where the LRU already dominates.
See Performance Results for measured impact of each layer.
SSSD Integration Pattern
The SSSD lifecycle has three phases: initialize from the fastest available source, refresh periodically in the background, and evaluate requests on the hot path.
flowchart TD
subgraph Init["Initialization"]
START[Start] --> DISK{Disk cache<br/>available?}
DISK -->|yes| LOAD[Load from disk]
DISK -->|no| LDAP[Full LDAP sync]
LOAD --> READY[Pipeline ready]
LDAP --> READY
end
subgraph Refresh["Background Refresh (every 5 min)"]
TIMER[Timer fires] --> INC[incremental_update]
INC -->|Ok| LOG[Log updated count]
INC -->|Err| WARN[Log warning<br/>keep cached rules]
end
subgraph Eval["Evaluation (hot path)"]
REQ[PAM request] --> BUILD[Build HbacRequest]
BUILD --> PIPE[pipeline.evaluate]
PIPE --> RESULT[Allow / Deny]
end
READY --> TIMER
READY --> REQ
Initialization
#![allow(unused)]
fn main() {
use hbac_rs::cache::*;
let hostname = get_hostname();
let host_groups = get_host_groups();
let mut pipeline = RulePipeline::builder()
.with_source(Box::new(ldap_source))
.with_filter(
HostFilter::for_host(&hostname)
.with_groups(host_groups)
)
.with_cache(IndexedCache::new())
.build();
// Try disk cache first, then full LDAP sync
let disk_cache = DiskCache::new("/var/cache/sssd/hbac")?;
let cached_rules: Vec<HbacRule> = disk_cache.get_all().into_iter().cloned().collect();
if !cached_rules.is_empty() {
pipeline.load(cached_rules);
} else {
pipeline.refresh()?;
}
}
Periodic Refresh
#![allow(unused)]
fn main() {
use std::time::Duration;
loop {
std::thread::sleep(Duration::from_secs(300));
match pipeline.incremental_update() {
Ok(count) => {
log::debug!("Updated {} rules", count);
}
Err(e) => {
log::warn!("Incremental update failed: {}", e);
}
}
}
}
Evaluation
#![allow(unused)]
fn main() {
use hbac_rs::prelude::*;
let mut subject = Subject::new(username);
subject.roles.extend(groups.iter().cloned());
let request = HbacRequest::new(subject, hostname, service);
let allowed = pipeline.evaluate(&request).is_allowed();
}
Memory Optimization
Host Filtering Impact
Only caching rules applicable to the local host dramatically reduces memory:
| Scenario | Rules in Cache | Approximate Memory |
|---|---|---|
| No filtering (10K total rules) | 10,000 | megabytes |
| HostFilter (100 applicable) | 100 | tens of KB |
Index Overhead
Optimization structures add less than 1% overhead on top of rule storage:
- LRU cache: 1,024 entries at ~64 bytes each
- Bloom filters: ~1 KB per 1,000 rules
- Decision trees: ~0.05 KB per deny rule
- Deny-rule index: ~1 KB per deny rule (bitmap indexes over user/host/service dimensions)
Trade-offs
| Strategy | Memory | Lookup Speed | Use Case |
|---|---|---|---|
| No cache | None | O(n) per request | Stateless gateways |
| No filter | High | Sub-microsecond | Small deployments (<1K rules) |
| HostFilter | Low | Sub-microsecond | SSSD on enrolled hosts |
| GlobalOnly filter | Minimal | Sub-microsecond | Restricted global policies |
See Performance Results for measured memory usage, build times, and evaluation latency at each scale.
Monitoring
#![allow(unused)]
fn main() {
let stats = pipeline.stats();
// Expose as metrics
metrics.gauge("hbac.rules.count", stats.rule_count);
metrics.gauge("hbac.cache.memory_bytes", stats.memory_bytes);
metrics.gauge("hbac.cache.hit_rate", stats.hit_rate());
// Log periodic summaries
log::info!(
"HBAC cache: {} rules, {} bytes, {:.1}% hit rate",
stats.rule_count,
stats.memory_bytes,
stats.hit_rate() * 100.0,
);
}
A sustained low hit rate may indicate excessive request diversity or a
misconfigured host filter. A sudden drop in rule_count may signal a
broken LDAP connection.
Cache Invalidation
Three approaches, used alone or in combination:
#![allow(unused)]
fn main() {
// Explicit: full reload from source
pipeline.refresh()?;
// Time-based: check staleness
if pipeline.last_update_timestamp() + max_cache_age < current_timestamp_millis() {
pipeline.refresh()?;
}
// Incremental: fetch only changed rules
pipeline.incremental_update()?;
}
See Also
- Caching System – tutorial introduction
- SSSD Integration – deployment patterns
- Performance Results – benchmark data
- API Reference – complete type listings
Performance Results
Measured performance for the hbac-rs, abac-rs, and ldap-acis evaluation engines
across rule counts from 100 to 1,000,000. All numbers are single-threaded,
release-mode, on synthetic workloads using the sssd-prod distribution preset.
Test Environments
ARM64 — Apple M4
- Platform: macOS 15.4 (ARM64)
- CPU: Apple M4 (10 cores, single-threaded benchmarks)
- Rust: 1.96.0
- Build:
cargo build --release(opt-level=3)
x86_64 — Intel Core i7-12800H
- Platform: Linux 6.15.8 (Fedora 42)
- CPU: 12th Gen Intel Core i7-12800H (14 cores, single-threaded benchmarks)
- Build:
cargo build --release(opt-level=3)
Performance is platform-dependent. Always benchmark on your target hardware.
Note: These results reflect the latest optimizations. See the Cross-BAC Benchmarking guide for detailed optimization journey and cross-engine comparisons.
All engines use AHash (fast non-cryptographic hashing) for all internal hash operations.
HBAC Performance
Cached (LRU-warm)
10,000 matching requests after 1,000 warmup requests. The LRU cache dominates.
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 | Memory |
|---|---|---|---|---|---|
| 100 | 7.85M r/s | 127 ns | 208 ns | 292 ns | 34 KB |
| 1,000 | 5.49M r/s | 182 ns | 292 ns | 375 ns | 334 KB |
| 10,000 | 4.07M r/s | 245 ns | 375 ns | 417 ns | 3.3 MB |
| 20,000 | 3.36M r/s | 298 ns | 459 ns | 542 ns | 6.6 MB |
| 50,000 | 1.68M r/s | 596 ns | 1.00 µs | 1.21 µs | 16.5 MB |
| 100,000 | 947K r/s | 1.06 µs | 1.83 µs | 2.21 µs | 33 MB |
| 250,000 | 428K r/s | 2.34 µs | 4.17 µs | 5.00 µs | 82.5 MB |
| 500,000 | 215K r/s | 4.64 µs | 8.33 µs | 9.96 µs | 165 MB |
| 1,000,000 | 109K r/s | 9.14 µs | 16.5 µs | 19.8 µs | 330 MB |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 | Memory |
|---|---|---|---|---|---|
| 100 | 16.0M r/s | 62 ns | 71 ns | 77 ns | 34 KB |
| 1,000 | 15.1M r/s | 66 ns | 75 ns | 82 ns | 334 KB |
| 10,000 | 13.9M r/s | 72 ns | 81 ns | 88 ns | 3.2 MB |
| 20,000 | 13.9M r/s | 72 ns | 81 ns | 87 ns | 6.4 MB |
| 50,000 | 14.1M r/s | 71 ns | 81 ns | 86 ns | 16.1 MB |
| 100,000 | 11.5M r/s | 87 ns | 99 ns | 106 ns | 32.3 MB |
| 250,000 | 11.7M r/s | 86 ns | 99 ns | 106 ns | 80.7 MB |
| 500,000 | 7.74M r/s | 129 ns | 151 ns | 166 ns | 161.5 MB |
| 1,000,000 | 11.3M r/s | 88 ns | 102 ns | 109 ns | 323.1 MB |
check_access (LRU-warm)
check_access() returns bool instead of HbacEvaluationResult, avoiding
the 3×Vec<String> allocation overhead.
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 19.2M r/s | 52 ns | 60 ns | 66 ns |
| 1,000 | 16.8M r/s | 60 ns | 70 ns | 78 ns |
| 10,000 | 15.9M r/s | 63 ns | 73 ns | 78 ns |
| 20,000 | 16.6M r/s | 60 ns | 70 ns | 75 ns |
| 50,000 | 13.8M r/s | 73 ns | 86 ns | 91 ns |
| 100,000 | 13.2M r/s | 76 ns | 88 ns | 95 ns |
| 250,000 | 13.6M r/s | 74 ns | 84 ns | 90 ns |
| 500,000 | 13.0M r/s | 77 ns | 90 ns | 98 ns |
| 1,000,000 | 12.7M r/s | 79 ns | 92 ns | 101 ns |
Uncached
10,000 unique non-matching requests, 0 warmup. Every request exercises the full evaluation path (Bloom filter, decision tree, deny-rule index). The first request at 10K+ triggers an index rebuild, creating a bimodal distribution (P95/P99 reflect steady-state; the mean includes the cold first request).
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 11.0M r/s | 91 ns | 166 ns | 167 ns |
| 1,000 | 8.15M r/s | 123 ns | 167 ns | 208 ns |
| 10,000 | 6.40M r/s | 156 ns | 125 ns | 167 ns |
| 20,000 | 3.88M r/s | 258 ns | 166 ns | 167 ns |
| 50,000 | 2.03M r/s | 492 ns | 125 ns | 167 ns |
| 100,000 | 1.06M r/s | 941 ns | 167 ns | 209 ns |
| 250,000 | 449K r/s | 2.23 µs | 250 ns | 292 ns |
| 500,000 | 223K r/s | 4.48 µs | 375 ns | 417 ns |
| 1,000,000 | 106K r/s | 9.42 µs | 625 ns | 708 ns |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 11.2M r/s | 89 ns | 118 ns | 174 ns |
| 1,000 | 6.50M r/s | 154 ns | 195 ns | 237 ns |
| 10,000 | 2.50M r/s | 400 ns | 302 ns | 379 ns |
| 20,000 | 1.27M r/s | 787 ns | 374 ns | 463 ns |
| 50,000 | 373K r/s | 2.68 µs | 908 ns | 1.21 µs |
| 100,000 | 249K r/s | 4.02 µs | 684 ns | 828 ns |
| 250,000 | 90K r/s | 11.1 µs | 786 ns | 946 ns |
| 500,000 | 47K r/s | 21.3 µs | 971 ns | 1.15 µs |
| 1,000,000 | 23K r/s | 43.7 µs | 1.25 µs | 1.44 µs |
Mixed Workload (Throughput)
80% matching + 20% non-matching, cache enabled, 1,000 warmup.
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 9.26M r/s | 108 ns | 167 ns | 250 ns |
| 1,000 | 6.37M r/s | 157 ns | 250 ns | 292 ns |
| 10,000 | 5.55M r/s | 180 ns | 292 ns | 334 ns |
| 20,000 | 3.65M r/s | 274 ns | 459 ns | 542 ns |
| 50,000 | 1.91M r/s | 523 ns | 1.00 µs | 1.21 µs |
| 100,000 | 1.05M r/s | 955 ns | 1.96 µs | 2.33 µs |
| 250,000 | 485K r/s | 2.06 µs | 4.33 µs | 5.21 µs |
| 500,000 | 249K r/s | 4.02 µs | 8.46 µs | 10.2 µs |
| 1,000,000 | 129K r/s | 7.74 µs | 16.4 µs | 19.7 µs |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 15.6M r/s | 64 ns | 73 ns | 78 ns |
| 1,000 | 14.8M r/s | 68 ns | 79 ns | 86 ns |
| 10,000 | 11.3M r/s | 88 ns | 101 ns | 111 ns |
| 20,000 | 11.7M r/s | 86 ns | 103 ns | 112 ns |
| 50,000 | 11.0M r/s | 91 ns | 108 ns | 122 ns |
| 100,000 | 9.58M r/s | 104 ns | 125 ns | 140 ns |
| 250,000 | 9.31M r/s | 107 ns | 130 ns | 189 ns |
| 500,000 | 9.87M r/s | 101 ns | 120 ns | 143 ns |
| 1,000,000 | 9.17M r/s | 109 ns | 131 ns | 176 ns |
ABAC Performance
With AHash optimization and bitmap-based deny indexing, ABAC delivers peak throughput at 1K–10K rules and scales inversely with deny rule count beyond that. On x86_64, throughput ranges from 5.7M r/s at 1K rules to 143K r/s at 1M rules, while using 4.2× less memory than HBAC. HBAC now uses the same bitmap deny index optimization and is faster at all scales, but ABAC provides N-dimensional flexibility.
Cached (Single Request Latency)
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 | Memory |
|---|---|---|---|---|---|
| 100 | 1.48M r/s | 0.67µs | 1.46µs | 2.17µs | 7.8 KB |
| 1,000 | 3.19M r/s | 0.31µs | 0.42µs | 0.50µs | 78 KB |
| 10,000 | 3.73M r/s | 0.27µs | 0.38µs | 0.46µs | 781 KB |
| 20,000 | 3.06M r/s | 0.33µs | 0.50µs | 0.62µs | 1.6 MB |
| 50,000 | 1.70M r/s | 0.59µs | 1.04µs | 1.29µs | 3.9 MB |
| 100,000 | 969K r/s | 1.03µs | 1.92µs | 2.42µs | 7.8 MB |
| 250,000 | 435K r/s | 2.30µs | 4.38µs | 5.38µs | 19.5 MB |
| 500,000 | 223K r/s | 4.48µs | 8.67µs | 10.9µs | 39 MB |
| 1,000,000 | 113K r/s | 8.84µs | 17.2µs | 21.3µs | 78 MB |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 | Memory |
|---|---|---|---|---|---|
| 100 | 2.22M r/s | 450 ns | 939 ns | 1.14 µs | 7.8 KB |
| 1,000 | 5.69M r/s | 176 ns | 227 ns | 247 ns | 78 KB |
| 10,000 | 3.97M r/s | 252 ns | 348 ns | 398 ns | 781 KB |
| 20,000 | 2.88M r/s | 348 ns | 559 ns | 710 ns | 1.5 MB |
| 50,000 | 1.67M r/s | 598 ns | 974 ns | 1.19 µs | 3.8 MB |
| 100,000 | 949K r/s | 1.05 µs | 1.73 µs | 2.15 µs | 7.6 MB |
| 250,000 | 546K r/s | 1.83 µs | 3.18 µs | 4.24 µs | 19.1 MB |
| 500,000 | 289K r/s | 3.46 µs | 6.16 µs | 9.22 µs | 38.1 MB |
| 1,000,000 | 143K r/s | 7.01 µs | 12.9 µs | 15.4 µs | 76.3 MB |
Uncached
10,000 unique non-matching requests, 0 warmup. The first request triggers compiled evaluator construction, dominating the mean latency. P95/P99 reflect steady-state uncached performance.
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 930K r/s | 1.08µs | 1.21µs | 1.33µs |
| 1,000 | 3.78M r/s | 0.26µs | 0.21µs | 0.25µs |
| 10,000 | 1.40M r/s | 0.72µs | 0.17µs | 0.21µs |
| 20,000 | 671K r/s | 1.49µs | 0.17µs | 0.25µs |
| 50,000 | 269K r/s | 3.72µs | 0.17µs | 0.25µs |
| 100,000 | 161K r/s | 6.19µs | 0.21µs | 0.25µs |
| 250,000 | 57.8K r/s | 17.3µs | 0.21µs | 0.25µs |
| 500,000 | 27.8K r/s | 36.0µs | 0.33µs | 0.38µs |
| 1,000,000 | 13.3K r/s | 75.1µs | 0.50µs | 0.54µs |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 786K r/s | 1.27 µs | 1.40 µs | 1.64 µs |
| 1,000 | 3.92M r/s | 255 ns | 235 ns | 292 ns |
| 10,000 | 836K r/s | 1.20 µs | 596 ns | 739 ns |
| 20,000 | 346K r/s | 2.89 µs | 785 ns | 966 ns |
| 50,000 | 160K r/s | 6.26 µs | 865 ns | 1.00 µs |
| 100,000 | 80K r/s | 12.5 µs | 944 ns | 1.10 µs |
| 250,000 | 25K r/s | 39.3 µs | 1.68 µs | 1.89 µs |
| 500,000 | 16K r/s | 61.7 µs | 1.25 µs | 1.39 µs |
| 1,000,000 | 7.6K r/s | 131 µs | 1.71 µs | 1.88 µs |
Mixed Workload (Throughput)
80% matching + 20% non-matching, cache enabled, 1,000 warmup.
ARM64 (Apple M4)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 1.93M r/s | 518 ns | 1.00 µs | 1.17 µs |
| 1,000 | 5.21M r/s | 192 ns | 250 ns | 292 ns |
| 10,000 | 4.18M r/s | 239 ns | 375 ns | 417 ns |
| 20,000 | 3.20M r/s | 313 ns | 542 ns | 625 ns |
| 50,000 | 1.88M r/s | 531 ns | 1.04 µs | 1.29 µs |
| 100,000 | 1.09M r/s | 919 ns | 1.96 µs | 2.46 µs |
| 250,000 | 500K r/s | 2.00 µs | 4.50 µs | 5.67 µs |
| 500,000 | 259K r/s | 3.86 µs | 8.79 µs | 11.0 µs |
| 1,000,000 | 131K r/s | 7.61 µs | 17.6 µs | 22.2 µs |
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 1.58M r/s | 631 ns | 1.31 µs | 1.43 µs |
| 1,000 | 5.98M r/s | 167 ns | 218 ns | 246 ns |
| 10,000 | 3.48M r/s | 287 ns | 427 ns | 520 ns |
| 20,000 | 2.85M r/s | 351 ns | 537 ns | 626 ns |
| 50,000 | 1.55M r/s | 646 ns | 1.13 µs | 1.36 µs |
| 100,000 | 718K r/s | 1.39 µs | 2.62 µs | 3.16 µs |
| 250,000 | 524K r/s | 1.91 µs | 3.87 µs | 4.83 µs |
| 500,000 | 348K r/s | 2.87 µs | 6.33 µs | 7.88 µs |
| 1,000,000 | 192K r/s | 5.20 µs | 11.2 µs | 14.0 µs |
LDAP ACI Performance
LDAP Access Control Instructions use a DnScopeIndex trie for O(depth)
candidate selection, with dual LRU caches (check_access + authorize) in
CachedAciPolicy. Cached evaluation is essentially O(1) — latency stays
flat at 47–75 ns regardless of rule count. Uncached evaluation uses
objectclass pre-filtering, userdn pre-filtering, and attribute partitioning
to eliminate ~75% of candidates before per-ACI checks, scaling from 598 ns
at 100 rules to 1.5 ms at 1M rules.
The synthetic workload matches real FreeIPA ACI distributions: 60% permission-based (GroupDn + ObjectClass filter + targetattr), 15% authenticated read (ObjectClass filter), 10% admin group, 5% anonymous read, 5% self-service, 5% narrow UserDn scope. Non-matching requests use valid DNs with mismatched objectclasses, exercising the full filter/bind/attr rejection pipeline rather than trivially failing at the DN trie.
Cached (LRU-warm)
10,000 matching requests after 1,000 warmup requests. The LRU cache dominates.
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 16.5M r/s | 60 ns | 66 ns | 76 ns |
| 1,000 | 14.3M r/s | 70 ns | 76 ns | 86 ns |
| 10,000 | 14.5M r/s | 69 ns | 75 ns | 86 ns |
| 20,000 | 14.4M r/s | 69 ns | 77 ns | 85 ns |
| 50,000 | 15.1M r/s | 66 ns | 74 ns | 81 ns |
| 100,000 | 15.0M r/s | 66 ns | 75 ns | 83 ns |
| 250,000 | 15.2M r/s | 66 ns | 71 ns | 81 ns |
| 500,000 | 16.0M r/s | 62 ns | 70 ns | 75 ns |
| 1,000,000 | 13.4M r/s | 75 ns | 83 ns | 93 ns |
check_access (LRU-warm)
check_access() returns bool instead of AuthorizationResult, avoiding
the PermissionSet allocation overhead.
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 21.1M r/s | 47 ns | 53 ns | 58 ns |
| 1,000 | 18.0M r/s | 55 ns | 61 ns | 65 ns |
| 10,000 | 18.5M r/s | 54 ns | 61 ns | 64 ns |
| 20,000 | 18.3M r/s | 55 ns | 61 ns | 65 ns |
| 50,000 | 19.7M r/s | 51 ns | 58 ns | 63 ns |
| 100,000 | 19.1M r/s | 52 ns | 61 ns | 67 ns |
| 250,000 | 19.5M r/s | 51 ns | 58 ns | 63 ns |
| 500,000 | 17.3M r/s | 58 ns | 66 ns | 69 ns |
| 1,000,000 | 16.5M r/s | 61 ns | 68 ns | 72 ns |
Uncached
10,000 unique non-matching requests, 0 warmup. Every request exercises the full evaluation path (DN trie traversal, objectclass/userdn pre-filtering, scope matching, bind rule checks). Non-matching requests use valid DNs with mismatched objectclasses (“device”), so they pass the DN trie but are rejected by filter/bind/attr checks — measuring realistic rejection cost.
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 1.7M r/s | 598 ns | 734 ns | 833 ns |
| 1,000 | 667K r/s | 1.50 µs | 1.68 µs | 1.78 µs |
| 10,000 | 205K r/s | 4.89 µs | 5.53 µs | 9.37 µs |
| 20,000 | 105K r/s | 9.53 µs | 10.91 µs | 15.46 µs |
| 50,000 | 40K r/s | 25.2 µs | 28.4 µs | 32.6 µs |
| 100,000 | 15K r/s | 65.2 µs | 89.4 µs | 104 µs |
| 250,000 | 5K r/s | 221 µs | 280 µs | 299 µs |
| 500,000 | 2K r/s | 587 µs | 714 µs | 832 µs |
| 1,000,000 | 1K r/s | 1.50 ms | 1.86 ms | 1.98 ms |
Mixed Workload (Throughput)
80% matching + 20% non-matching, cache enabled, 1,000 warmup.
x86_64 (Intel i7-12800H)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 15.1M r/s | 66 ns | 73 ns | 85 ns |
| 1,000 | 14.1M r/s | 71 ns | 81 ns | 92 ns |
| 10,000 | 12.7M r/s | 79 ns | 90 ns | 99 ns |
| 20,000 | 11.3M r/s | 89 ns | 102 ns | 114 ns |
| 50,000 | 11.5M r/s | 87 ns | 102 ns | 168 ns |
| 100,000 | 12.7M r/s | 78 ns | 89 ns | 104 ns |
| 250,000 | 12.3M r/s | 81 ns | 90 ns | 154 ns |
| 500,000 | 12.1M r/s | 83 ns | 96 ns | 174 ns |
| 1,000,000 | 13.6M r/s | 74 ns | 86 ns | 157 ns |
Windows SD Performance
Access check latency for Windows Security Descriptors using the check_access
algorithm (DACL walk with deny-before-allow, SID matching, owner implicit
rights). The round-robin adapter evaluates one SD per request, so latency
reflects the per-SD access check cost.
Unlike HBAC/ABAC (which search across a rule set), a Windows access check walks a single SD’s DACL — typically 3–8 ACEs. Latency is therefore nearly constant regardless of how many SDs are loaded; the rule count affects only memory and load time.
ARM64 (Apple M4)
Check Access Latency
| Rules | Mean (ns) | P95 (ns) | P99 (ns) | Throughput (req/s) |
|---|---|---|---|---|
| 100 | 44 | 83 | 84 | 22.6M |
| 1,000 | 38 | 42 | 42 | 26.4M |
| 10,000 | 23 | 42 | 84 | 43.1M |
| 100,000 | 21 | 42 | 125 | 48.7M |
Throughput (mixed matching/non-matching)
| Rules | Mean (ns) | P95 (ns) | Throughput (req/s) |
|---|---|---|---|
| 100 | 59 | 84 | 17.1M |
| 1,000 | 39 | 42 | 25.4M |
| 10,000 | 23 | 42 | 42.9M |
| 100,000 | 21 | 42 | 47.9M |
Uncached Latency (diverse non-matching)
| Rules | Mean (ns) | P95 (ns) | Throughput (req/s) |
|---|---|---|---|
| 100 | 49 | 83 | 20.5M |
| 1,000 | 36 | 42 | 27.5M |
| 10,000 | 22 | 42 | 45.3M |
| 100,000 | 21 | 42 | 47.5M |
Policy Build Time and Memory
| Rules | Build (ms) | Memory (KB) | Per rule (B) |
|---|---|---|---|
| 100 | 0.04 | 13.3 | 136 |
| 1,000 | 0.20 | 132.8 | 136 |
| 10,000 | 1.09 | 1328.1 | 136 |
| 100,000 | 9.40 | 13281.3 | 136 |
Cross-engine comparison (check_access, 10K rules)
| Engine | Mean (ns) | P95 (ns) | Throughput (req/s) |
|---|---|---|---|
| win-sd | 23 | 42 | 43.1M |
| posix-acl | 29 | 42 | 34.4M |
Windows SD access checks are slightly faster than POSIX ACL checks despite the more complex permission model (32-bit masks vs 3-bit rwx, explicit deny ACEs, SID matching vs string comparison). Both are in the same performance class at sub-50ns latency.
Policy Build Time
One-time cost when a policy is loaded or reloaded. Includes Bloom filter construction, decision tree compilation, deny-rule index building, and composite index creation for HBAC, or compiled evaluator, bitmap deny index, and composite index for ABAC, or DN trie index construction and LRU cache initialization for LDAP ACI, or simple Vec loading for Windows SD and POSIX ACL.
HBAC Build Time
ARM64 (Apple M4)
| Rules | Build Time | Per 1K Rules |
|---|---|---|
| 100 | 0.01 ms | 0.12 ms |
| 1,000 | 0.09 ms | 0.09 ms |
| 10,000 | 0.71 ms | 0.07 ms |
| 20,000 | 1.52 ms | 0.08 ms |
| 50,000 | 3.81 ms | 0.08 ms |
| 100,000 | 8.02 ms | 0.08 ms |
| 250,000 | 20.8 ms | 0.08 ms |
| 500,000 | 40.9 ms | 0.08 ms |
| 1,000,000 | 87.6 ms | 0.09 ms |
x86_64 (Intel i7-12800H)
| Rules | Build Time | Per 1K Rules |
|---|---|---|
| 100 | 0.04 ms | 0.38 ms |
| 1,000 | 0.13 ms | 0.13 ms |
| 10,000 | 1.57 ms | 0.16 ms |
| 20,000 | 5.57 ms | 0.28 ms |
| 50,000 | 19.4 ms | 0.39 ms |
| 100,000 | 41.7 ms | 0.42 ms |
| 250,000 | 106 ms | 0.43 ms |
| 500,000 | 202 ms | 0.40 ms |
| 1,000,000 | 424 ms | 0.42 ms |
ABAC Build Time
ARM64 (Apple M4)
| Rules | Build Time | Per 1K Rules |
|---|---|---|
| 100 | 0.15 ms | 1.50 ms |
| 1,000 | 0.83 ms | 0.83 ms |
| 10,000 | 6.19 ms | 0.62 ms |
| 20,000 | 13.4 ms | 0.67 ms |
| 50,000 | 33.4 ms | 0.67 ms |
| 100,000 | 60.7 ms | 0.61 ms |
| 250,000 | 167 ms | 0.67 ms |
| 500,000 | 354 ms | 0.71 ms |
| 1,000,000 | 798 ms | 0.80 ms |
x86_64 (Intel i7-12800H)
| Rules | Build Time | Per 1K Rules |
|---|---|---|
| 100 | 0.11 ms | 1.10 ms |
| 1,000 | 0.47 ms | 0.47 ms |
| 10,000 | 8.79 ms | 0.88 ms |
| 20,000 | 19.8 ms | 0.99 ms |
| 50,000 | 56.6 ms | 1.13 ms |
| 100,000 | 122 ms | 1.22 ms |
| 250,000 | 317 ms | 1.27 ms |
| 500,000 | 611 ms | 1.22 ms |
| 1,000,000 | 1,175 ms | 1.18 ms |
LDAP ACI Build Time
Build time includes DN trie construction, objectclass/userdn pre-filtering index building, and attribute partitioning.
x86_64 (Intel i7-12800H)
| Rules | Build Time | Per 1K Rules |
|---|---|---|
| 100 | 0.26 ms | 2.58 ms |
| 1,000 | 1.76 ms | 1.76 ms |
| 10,000 | 20.1 ms | 2.01 ms |
| 20,000 | 38.3 ms | 1.92 ms |
| 50,000 | 104 ms | 2.07 ms |
| 100,000 | 199 ms | 1.99 ms |
| 250,000 | 530 ms | 2.12 ms |
| 500,000 | 1.04 s | 2.09 ms |
| 1,000,000 | 2.1 s | 2.06 ms |
Memory
HBAC Memory Usage
ARM64 (Apple M4)
| Rules | Total | Per-Rule |
|---|---|---|
| 100 | 34 KB | 0.35 KB |
| 1,000 | 334 KB | 0.34 KB |
| 10,000 | 3.3 MB | 0.34 KB |
| 20,000 | 6.6 MB | 0.34 KB |
| 50,000 | 16.5 MB | 0.34 KB |
| 100,000 | 33 MB | 0.34 KB |
| 250,000 | 82.5 MB | 0.34 KB |
| 500,000 | 165 MB | 0.34 KB |
| 1,000,000 | 330 MB | 0.34 KB |
x86_64 (Intel i7-12800H)
| Rules | Total | Per-Rule |
|---|---|---|
| 100 | 34 KB | 0.34 KB |
| 1,000 | 334 KB | 0.34 KB |
| 10,000 | 3.2 MB | 0.33 KB |
| 20,000 | 6.4 MB | 0.33 KB |
| 50,000 | 16.1 MB | 0.33 KB |
| 100,000 | 32.3 MB | 0.33 KB |
| 250,000 | 80.7 MB | 0.33 KB |
| 500,000 | 161.5 MB | 0.33 KB |
| 1,000,000 | 323.1 MB | 0.33 KB |
ABAC Memory Usage
ARM64 (Apple M4)
| Rules | Total | Per-Rule |
|---|---|---|
| 100 | 7.8 KB | 0.080 KB |
| 1,000 | 78 KB | 0.080 KB |
| 10,000 | 781 KB | 0.080 KB |
| 20,000 | 1.6 MB | 0.080 KB |
| 50,000 | 3.9 MB | 0.080 KB |
| 100,000 | 7.8 MB | 0.080 KB |
| 250,000 | 19.5 MB | 0.080 KB |
| 500,000 | 39 MB | 0.080 KB |
| 1,000,000 | 78 MB | 0.080 KB |
x86_64 (Intel i7-12800H)
| Rules | Total | Per-Rule |
|---|---|---|
| 100 | 7.8 KB | 0.078 KB |
| 1,000 | 78 KB | 0.078 KB |
| 10,000 | 781 KB | 0.078 KB |
| 20,000 | 1.5 MB | 0.078 KB |
| 50,000 | 3.8 MB | 0.078 KB |
| 100,000 | 7.6 MB | 0.078 KB |
| 250,000 | 19 MB | 0.078 KB |
| 500,000 | 38 MB | 0.078 KB |
| 1,000,000 | 76 MB | 0.078 KB |
ABAC uses 4.2× less memory than HBAC due to the compiled evaluator avoiding per-rule storage overhead.
Per-rule cost is the average for the sssd-prod distribution. Category=all
rules are smaller (no entity strings); specific rules with many entities are
larger.
Index Overhead
- LRU cache: configurable (default 1,024 entries) × ~64 bytes = ~64 KB
- Bloom filters: ~1 KB per 1,000 rules
- Decision trees: ~0.05 KB per deny rule
- Deny-rule index: ~1 KB per deny rule (bitmap indexes over user/host/service dimensions)
- Total: <1% of rule storage at 10K+ rules
Performance Crossover
HBAC vs ABAC vs LDAP ACI — Mixed Workload (x86_64)
| Rules | HBAC | ABAC | LDAP ACI | Fastest |
|---|---|---|---|---|
| 100 | 15.6M r/s | 1.58M r/s | 15.1M r/s | HBAC |
| 1,000 | 14.8M r/s | 5.98M r/s | 14.1M r/s | HBAC |
| 10,000 | 11.3M r/s | 3.48M r/s | 12.7M r/s | LDAP ACI |
| 20,000 | 11.7M r/s | 2.85M r/s | 11.3M r/s | HBAC |
| 50,000 | 11.0M r/s | 1.55M r/s | 11.5M r/s | LDAP ACI |
| 100,000 | 9.58M r/s | 718K r/s | 12.7M r/s | LDAP ACI |
| 250,000 | 9.31M r/s | 524K r/s | 12.3M r/s | LDAP ACI |
| 500,000 | 9.87M r/s | 348K r/s | 12.1M r/s | LDAP ACI |
| 1,000,000 | 9.17M r/s | 192K r/s | 13.6M r/s | LDAP ACI |
LDAP ACI is the fastest engine at 10K+ rules in the mixed workload, with
throughput of 11–15M r/s thanks to its dual LRU cache and DnScopeIndex
trie. HBAC is slightly faster at small scales (100–1K rules) due to lower
per-request overhead, while ABAC lags further due to rule traversal even on
cache hits. The mixed workload uses realistic non-matching requests that
exercise objectclass/userdn pre-filtering and attribute rejection, so the
20% non-matching portion has slightly higher latency than trivial rejections.
Cached Workloads (typical SSSD deployments)
ARM64 (Apple M4):
- At 100 rules: ABAC is 2.7× faster (7.41M vs 2.78M req/s)
- At 100K rules: ABAC is 3.3× faster (830K vs 252K req/s)
x86_64 (Intel i7-12800H):
- At 100 rules: HBAC is 7.2× faster than ABAC (16.0M vs 2.22M req/s)
- At 1K rules: HBAC fastest (14.7M r/s) > LDAP ACI (14.3M) > ABAC (5.69M)
- At 100K rules: LDAP ACI (15.0M) > HBAC (11.5M) > ABAC (949K r/s)
- At 1M rules: LDAP ACI (13.4M) > HBAC (11.3M) > ABAC (143K r/s)
Uncached Workloads
ARM64 (Apple M4):
- At 10K rules: HBAC is 6.6× faster uncached (1.18M vs 179K req/s)
- At 100K rules: HBAC is 8.6× faster uncached (108K vs 12.6K req/s)
x86_64 (Intel i7-12800H):
- At 1K rules: HBAC (6.50M) > ABAC (3.92M) > LDAP ACI (667K r/s)
- At 10K rules: HBAC (2.50M) > ABAC (836K) > LDAP ACI (205K r/s)
- At 100K rules: HBAC (249K) > ABAC (80K) > LDAP ACI (15K r/s)
- At 1M rules: HBAC (23K) > ABAC (7.6K) > LDAP ACI (1K r/s)
HBAC and ABAC are faster than LDAP ACI for uncached workloads. LDAP ACI uncached numbers reflect a realistic workload where non-matching requests use valid DNs that pass the DN trie but are rejected by objectclass pre-filtering, userdn pre-filtering, and attribute checks — measuring the true cost of filter/bind rejection rather than trivial DN misses. HBAC and ABAC use Bloom filters and decision trees for faster uncached rejection.
Recommendation: For cached workloads, LDAP ACI is the fastest engine. For uncached workloads, HBAC is dramatically faster. Use ABAC when you need N-dimensional attribute-based rules or 4.2× less memory. Choose the engine that matches your access control model.
Optimization Layers
ABAC uses a 6-layer optimization pipeline:
- Constant-result fast path – single outcome (~15 ns)
- Bitmap deny index – u64 bitmask intersection when universal allow exists
- LRU cache – memoization (sub-microsecond, for non-indexed fallback)
- AHash – 2-3× faster than SipHash for non-cryptographic use
- Compiled evaluator – pre-extracted attributes + array indexing
- Composite index – candidate selection fallback (O(log n))
- Deny-only indexing – skip allow rules when universal allow exists
Key optimization: The bitmap deny index (Layer 1) uses pre-computed BitPos structs and per-dimension inverted indexes with u64 bitmask AND operations. When a universal allow rule exists, evaluation bypasses the cache entirely and resolves via bitmap intersection in O(deny_rules/64) per dimension. This produces excellent throughput at 1K–10K rules (3–5M r/s) where the bitmaps fit in L1 cache, with graceful degradation at larger scales.
Layer 4 (Compiled evaluator) pre-extracts request attributes once (3 HashMap lookups) into a stack-allocated array, then uses array indexing for all rule checks. This serves as the fallback when no universal allow rule exists.
Temporal Rule Support
Both HBAC and ABAC support time-based rules via TemporalHbacRule and
TemporalAbacRule. Temporal rules are evaluated separately and incur minimal
overhead when no temporal rules are active (~5-10ns for the empty-check).
When temporal rules are present:
- Active temporal rules (within time window) are evaluated alongside regular rules
- Inactive rules (outside time window) are skipped at evaluation time
- No additional caching overhead
- Build time increases by ~1-2ms per 1,000 temporal rules
See the Temporal Rules guide for usage examples.
JIT Compilation
Optional feature flag (--features jit). Compiles per-rule evaluation to
native code.
| Workload | Effect |
|---|---|
| Cached | No measurable difference (±3-6% noise). The LRU cache already provides sub-microsecond lookups. |
| Uncached | ~8-12% throughput improvement at all scales. Every request exercises the full evaluation path where native code pays off. |
| Build | No consistent overhead (±8% noise). |
JIT is worth enabling when uncached or low-hit-rate workloads dominate. For typical SSSD deployments with high cache hit rates, it provides no benefit.
Optimization Stack
Evaluations pass through these layers in order. Each layer is skipped if the previous one already produced a result.
Layer 0 — Constant-Result Fast Path
When the decision tree collapses to a single outcome (e.g., a universal deny rule exists, or a universal allow with no deny rules), the result is stored and returned immediately. Bypasses all other layers. ~15 ns per evaluation.
Layer 1 — Bitmap Deny Index
When a universal allow rule exists, the deny index short-circuits evaluation using bitmap intersection. Pre-computed BitPos structs map each deny rule to a (word, mask) pair. Per-dimension inverted indexes map attribute values to their rule bitmasks. Evaluation ANDs dimension bitmasks together; any surviving bit means a deny match. Cost: O(deny_rules/64) per dimension. Bypasses the LRU cache entirely — no RequestKey hashing overhead.
Layer 2 — LRU Memoization Cache
Configurable-size cache (default 1,024 entries) keyed by a u64 hash of (user, host, service, groups). Order-independent group hashing via wrapping addition. Zero heap allocations per lookup. Sub-100ns hit latency. Used when the bitmap deny index is not applicable.
Layer 3 — Bloom Filter Pre-screening
Fast negative check before full evaluation. 1% false positive rate. Effective
for non-matching requests when no category=all rules exist. In SSSD
deployments where category=all is common, the Bloom filter rarely rejects.
Layer 4 — Decision Tree
Rules are compiled into a decision tree at load time. When a universal allow exists alongside specific deny rules, the tree stores only the deny rules — reducing evaluation from O(n) to O(d) where d is the deny rule count.
Layer 5 — Composite Indexing (Fallback)
Indexed user+host lookup for candidate selection. Used when the decision tree
is not available. O(1) for category=all, O(log n) for specific entities.
Layer 6 — JIT Compilation (optional)
Requires the jit feature flag. Compiles per-rule evaluation to native code.
~10% throughput gain on uncached workloads; no gain on cached workloads.
Deployment Guidance
Typical SSSD (100–1,000 rules)
Most FreeIPA environments fall here.
HBAC (x86_64): 62–66 ns cached latency (evaluate), 52–60 ns (check_access), 15.1–16.0M req/s throughput, 34–334 KB memory, <0.13 ms build time.
ABAC (x86_64): 176–450 ns cached latency, 2.22–5.69M req/s throughput, 7.8–78 KB memory, <0.47 ms build time.
LDAP ACI (x86_64): 60–70 ns cached latency, 47–55 ns (check_access), 14.3–16.5M req/s, <1.76 ms build time.
Recommendation: LDAP ACI and HBAC deliver near-identical cached latency at this scale. Use ABAC when you need N-dimensional flexibility or 4.2× less memory. Choose the engine that matches your access control model.
Large Enterprise (1K–10K rules)
HBAC (x86_64): 66–72 ns cached latency, 13.9–15.1M req/s, 334 KB–3.2 MB memory, 0.13–1.57 ms build time.
ABAC (x86_64): 176–252 ns cached latency, 3.97–5.69M req/s, 78–781 KB memory, 0.47–8.79 ms build time.
LDAP ACI (x86_64): 69–70 ns cached latency, 54–55 ns (check_access), 14.3–14.5M req/s, 1.76–20.1 ms build time.
Recommendation: HBAC and LDAP ACI are close at this scale (14.3M vs 14.8M r/s at 1K rules). Both use LRU caching for flat latency. Use ABAC for N-dimensional flexibility or lower memory (4.2× less).
Very Large (10K–100K rules)
HBAC (x86_64): 72–87 ns cached latency, 11.5–13.9M req/s, 3.2–32.3 MB memory, 1.57–41.7 ms build time.
ABAC (x86_64): 252 ns–1.05 µs cached latency, 949K–3.97M req/s, 781 KB–7.6 MB memory, 8.79–122 ms build time.
LDAP ACI (x86_64): 66–69 ns cached latency, 51–52 ns (check_access), 14.4–15.1M req/s, 20.1–199 ms build time.
LDAP ACI is 1.3× faster than HBAC at 100K rules (15.0M vs 11.5M r/s). ABAC uses 4.2× less memory than HBAC.
Recommendation: LDAP ACI and HBAC are both excellent at this scale. Choose ABAC if memory is a constraint (7.6 MB vs 32.3 MB at 100K rules) or you need N-dimensional rules.
Extreme Scale (100K–1M rules)
HBAC (x86_64): 87–88 ns cached latency, 11.3–11.5M req/s, 32.3–323 MB memory, 41.7–424 ms build time.
ABAC (x86_64): 1.05–7.01 µs cached latency, 143K–949K req/s, 7.6–76 MB memory, 122 ms–1.18 s build time.
LDAP ACI (x86_64): 62–75 ns cached latency, 51–61 ns (check_access), 13.4–16.0M req/s, 199 ms–2.1 s build time.
At 1M rules, LDAP ACI is 1.2× faster than HBAC (13.4M vs 11.3M r/s) and 94× faster than ABAC (13.4M vs 143K r/s).
Recommendation: LDAP ACI delivers the best cached throughput at extreme scale. HBAC is close behind. Choose ABAC if memory is the primary constraint (76 MB vs 323 MB at 1M rules) or you need N-dimensional rules.
Cache Tuning
The default 1,024-entry LRU is optimal for most workloads.
- Higher hit rate: 2,048 or 4,096 entries (+64-192 KB memory)
- Memory constrained: 512 entries (~85% hit rate)
- Cold-start sensitive: pre-warm with common requests after policy load
- Diverse patterns: increase cache size if hit rate drops below 80%
Benchmark Methodology
Scenarios
| Scenario | Requests | Warmup | Distribution |
|---|---|---|---|
single-latency | 10,000 | 1,000 | All matching (pool: 200) |
check-access-latency | 10,000 | 1,000 | All matching (pool: 200, bool) |
uncached-latency | 10,000 | 0 | All non-matching (pool: 5,000) |
throughput | 10,000 | 1,000 | 80/20 mixed (pool: 500) |
build-time | 1 | 0 | Matching |
All evaluation scenarios pre-generate a request pool before measurement.
Requests cycle through the pool during warmup and measurement, ensuring
that only evaluation time is measured — not request object construction.
check-access-latency uses check_access() (returns bool) instead of
evaluate() (returns full result with matched rule lists).
Fixtures
Synthetic fixtures use the sssd-prod distribution, which models real SSSD
deployments: 15% user category=all, 30% host category=all, 40% service
category=all, 5% deny rules, 2% disabled rules. Universal deny rules (all
three dimensions category=all with type=deny) are excluded — real deployments
never create these, and they would collapse the decision tree to a constant.
LDAP ACI fixtures use the ldap-aci distribution, which models real 389-ds
deployments with varied DN scopes, bind rules (identity + environment), target
filters, attribute sets, and grant/deny ACIs.
Measurement
- Seeded RNG:
StdRng::seed_from_u64(42)for reproducibility - Pre-generated pools: requests are created before timing begins
- Single-threaded: sequential request processing
- Throughput: derived from the sum of per-request
evaluate()latencies - Timing: only the
evaluate()/check_access()call is measured
Running Benchmarks
Individual fixture generation and benchmarking:
# Generate fixtures
cargo run --release -p perf-testing -- \
generate -b hbac -c 10000 -o hbac_10000_baseline --fixtures-dir fixtures
# Run all scenarios for HBAC
cargo run --release -p perf-testing bench -b hbac -f hbac_10000_baseline --cache
# Run all scenarios for ABAC
cargo run --release -p perf-testing bench -b abac -f abac_10000_baseline --cache
# Run all scenarios for LDAP ACI
cargo run --release -p perf-testing bench -b ldap_aci -f ldap_aci_10000_baseline --cache
# Run specific scenarios
cargo run --release -p perf-testing -- \
list # List available fixtures and scenarios
Full benchmark suite across all scales:
# HBAC benchmarks
scripts/bench.sh run --no-jit --rules 100,1000,10000,20000,50000,100000,250000,500000,1000000
# ABAC benchmarks
scripts/bench.sh run --no-jit --bac-type abac --rules 100,1000,10000,20000,50000,100000,250000,500000,1000000
# LDAP ACI benchmarks
scripts/bench.sh run --no-jit --bac-type ldap_aci --rules 100,1000,10000,20000,50000,100000,250000,500000,1000000
Criterion Benchmarks
For statistically rigorous measurements with confidence intervals:
cd crates/perf-testing
cargo bench --bench hbac_benchmarks
open target/criterion/report/index.html
Python Binding Benchmarks
For the Python binding performance results below:
source .venv/bin/activate
python python/collect_results.py --warmup 3 --measure 5
Python Binding Performance
Measured on x86_64 — Intel Core i7-12800H (Linux 6.15.8, Python 3.15). Same fixtures and request-generation logic as the Rust benchmarks. Policy is built once per fixture and shared across all eval scenarios.
Two build/eval strategies are benchmarked:
- evaluate+builder: constructs rules via PyO3 builder pattern, evaluates
with
evaluate()(returns fullDecision/HbacEvaluationResult) - check_access+json (HBAC only): loads rules via
load_rules_json(), evaluates withcheck_access()(returnsbool, avoidsVec<String>allocation per call)
For ABAC, load_rules_json() provides faster build times (no Python object
construction) with identical evaluation speed — both paths produce the same
internal rules.
Python — HBAC (evaluate+builder)
Cached (LRU-warm, matching requests)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 6.43M r/s | 155 ns | 162 ns | 167 ns |
| 1,000 | 6.39M r/s | 157 ns | 161 ns | 165 ns |
| 10,000 | 6.26M r/s | 160 ns | 167 ns | 168 ns |
| 20,000 | 6.53M r/s | 153 ns | 161 ns | 165 ns |
| 50,000 | 6.46M r/s | 155 ns | 159 ns | 160 ns |
| 100,000 | 6.53M r/s | 153 ns | 156 ns | 163 ns |
| 250,000 | 6.38M r/s | 157 ns | 166 ns | 170 ns |
| 500,000 | 6.38M r/s | 157 ns | 161 ns | 164 ns |
| 1,000,000 | 6.21M r/s | 161 ns | 168 ns | 169 ns |
Uncached (~80% cache misses, non-matching)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 5.85M r/s | 171 ns | 183 ns | 186 ns |
| 1,000 | 4.39M r/s | 228 ns | 239 ns | 240 ns |
| 10,000 | 4.69M r/s | 213 ns | 227 ns | 233 ns |
| 20,000 | 4.18M r/s | 239 ns | 243 ns | 251 ns |
| 50,000 | 4.15M r/s | 241 ns | 246 ns | 249 ns |
| 100,000 | 3.65M r/s | 274 ns | 284 ns | 289 ns |
| 250,000 | 2.88M r/s | 347 ns | 366 ns | 371 ns |
| 500,000 | 2.02M r/s | 496 ns | 507 ns | 519 ns |
| 1,000,000 | 1.37M r/s | 731 ns | 749 ns | 754 ns |
Mixed Workload (80% matching / 20% non-matching)
| Rules | Throughput | Mean |
|---|---|---|
| 100 | 6.47M r/s | 154 ns |
| 1,000 | 6.18M r/s | 162 ns |
| 10,000 | 6.46M r/s | 155 ns |
| 20,000 | 6.35M r/s | 157 ns |
| 50,000 | 6.32M r/s | 158 ns |
| 100,000 | 6.39M r/s | 156 ns |
| 250,000 | 6.32M r/s | 158 ns |
| 500,000 | 6.23M r/s | 161 ns |
| 1,000,000 | 6.01M r/s | 166 ns |
Policy Build Time
| Rules | Rule objects (py) | load_rules() (rs) | Total |
|---|---|---|---|
| 100 | 0.7 ms | 0.0 ms | 0.7 ms |
| 1,000 | 2.2 ms | 0.1 ms | 2.3 ms |
| 10,000 | 20.1 ms | 1.6 ms | 21.7 ms |
| 20,000 | 36.8 ms | 3.0 ms | 39.8 ms |
| 50,000 | 99.3 ms | 8.1 ms | 107.4 ms |
| 100,000 | 186.8 ms | 15.0 ms | 201.8 ms |
| 250,000 | 475.3 ms | 36.3 ms | 511.5 ms |
| 500,000 | 981.2 ms | 70.0 ms | 1,051 ms |
| 1,000,000 | 1,938 ms | 142.4 ms | 2,080 ms |
Python — HBAC (check_access+json)
The optimized path: load_rules_json() skips Python rule-object allocation
entirely, and check_access() returns a bool instead of allocating
HbacEvaluationResult (3×Vec<String>).
Cached (LRU-warm, matching requests)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 7.93M r/s | 126 ns | 131 ns | 134 ns |
| 1,000 | 8.25M r/s | 121 ns | 126 ns | 127 ns |
| 10,000 | 8.25M r/s | 121 ns | 126 ns | 128 ns |
| 20,000 | 8.15M r/s | 123 ns | 126 ns | 128 ns |
| 50,000 | 8.16M r/s | 123 ns | 126 ns | 127 ns |
| 100,000 | 8.14M r/s | 123 ns | 127 ns | 131 ns |
| 250,000 | 7.71M r/s | 130 ns | 138 ns | 159 ns |
| 500,000 | 7.68M r/s | 130 ns | 136 ns | 140 ns |
| 1,000,000 | 8.15M r/s | 123 ns | 127 ns | 130 ns |
Uncached (~80% cache misses, non-matching)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 6.46M r/s | 155 ns | 165 ns | 175 ns |
| 1,000 | 5.41M r/s | 185 ns | 203 ns | 207 ns |
| 10,000 | 5.53M r/s | 181 ns | 189 ns | 195 ns |
| 20,000 | 5.25M r/s | 191 ns | 196 ns | 205 ns |
| 50,000 | 4.87M r/s | 205 ns | 211 ns | 212 ns |
| 100,000 | 4.10M r/s | 244 ns | 258 ns | 261 ns |
| 250,000 | 3.07M r/s | 326 ns | 349 ns | 360 ns |
| 500,000 | 2.21M r/s | 452 ns | 471 ns | 480 ns |
| 1,000,000 | 1.35M r/s | 739 ns | 820 ns | 859 ns |
Mixed Workload (80% matching / 20% non-matching)
| Rules | Throughput | Mean |
|---|---|---|
| 100 | 7.54M r/s | 133 ns |
| 1,000 | 7.75M r/s | 129 ns |
| 10,000 | 8.09M r/s | 124 ns |
| 20,000 | 8.10M r/s | 123 ns |
| 50,000 | 7.93M r/s | 126 ns |
| 100,000 | 7.77M r/s | 129 ns |
| 250,000 | 7.46M r/s | 134 ns |
| 500,000 | 7.82M r/s | 128 ns |
| 1,000,000 | 7.62M r/s | 131 ns |
Policy Build Time (JSON)
| Rules | load_rules_json() (rs) | Speedup vs builder |
|---|---|---|
| 100 | 0.6 ms | 1.2× |
| 1,000 | 1.2 ms | 1.9× |
| 10,000 | 6.9 ms | 3.1× |
| 20,000 | 12.8 ms | 3.1× |
| 50,000 | 32.0 ms | 3.4× |
| 100,000 | 63.2 ms | 3.2× |
| 250,000 | 160.6 ms | 3.2× |
| 500,000 | 313.9 ms | 3.3× |
| 1,000,000 | 617.4 ms | 3.4× |
Python — ABAC (evaluate+builder)
Cached (LRU-warm, matching requests)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 1.66M r/s | 603 ns | 621 ns | 626 ns |
| 1,000 | 3.12M r/s | 320 ns | 330 ns | 334 ns |
| 10,000 | 2.44M r/s | 410 ns | 422 ns | 426 ns |
| 20,000 | 2.22M r/s | 451 ns | 467 ns | 478 ns |
| 50,000 | 1.54M r/s | 649 ns | 683 ns | 723 ns |
| 100,000 | 1.02M r/s | 982 ns | 1.04 µs | 1.14 µs |
| 250,000 | 524K r/s | 1.91 µs | 2.05 µs | 2.07 µs |
| 500,000 | 278K r/s | 3.60 µs | 3.69 µs | 3.78 µs |
| 1,000,000 | 159K r/s | 6.29 µs | 7.13 µs | 7.18 µs |
ABAC evaluation scales inversely with rule count — no FFI floor like the prior (incorrect) ARM64 measurements suggested. At 1K rules the compiled evaluator delivers 3.1M r/s; at 1M rules the larger index scan drops to 159K.
Uncached (~80% cache misses, non-matching)
| Rules | Throughput | Mean | P95 | P99 |
|---|---|---|---|---|
| 100 | 649K r/s | 1.54 µs | 1.66 µs | 1.69 µs |
| 1,000 | 3.41M r/s | 293 ns | 310 ns | 316 ns |
| 10,000 | 3.35M r/s | 299 ns | 316 ns | 322 ns |
| 20,000 | 3.46M r/s | 289 ns | 298 ns | 304 ns |
| 50,000 | 3.32M r/s | 301 ns | 310 ns | 316 ns |
| 100,000 | 3.13M r/s | 319 ns | 328 ns | 336 ns |
| 250,000 | 2.56M r/s | 390 ns | 404 ns | 420 ns |
| 500,000 | 1.94M r/s | 515 ns | 534 ns | 562 ns |
| 1,000,000 | 1.55M r/s | 645 ns | 671 ns | 691 ns |
Uncached is faster than cached at 1K+ rules because the Bloom filter rejects non-matching requests before rule iteration.
Mixed Workload (80% matching / 20% non-matching)
| Rules | Throughput | Mean |
|---|---|---|
| 100 | 1.12M r/s | 896 ns |
| 1,000 | 3.17M r/s | 315 ns |
| 10,000 | 2.57M r/s | 389 ns |
| 20,000 | 2.28M r/s | 439 ns |
| 50,000 | 1.66M r/s | 603 ns |
| 100,000 | 1.20M r/s | 835 ns |
| 250,000 | 633K r/s | 1.58 µs |
| 500,000 | 337K r/s | 2.97 µs |
| 1,000,000 | 198K r/s | 5.06 µs |
Policy Build Time
| Rules | Rule objects (py) | load_rules() (rs) | Total |
|---|---|---|---|
| 100 | 0.7 ms | 0.1 ms | 0.7 ms |
| 1,000 | 3.3 ms | 0.7 ms | 3.9 ms |
| 10,000 | 30.5 ms | 7.5 ms | 38.0 ms |
| 20,000 | 59.8 ms | 15.0 ms | 74.8 ms |
| 50,000 | 148.1 ms | 34.0 ms | 182.1 ms |
| 100,000 | 304.1 ms | 68.1 ms | 372.2 ms |
| 250,000 | 788.5 ms | 183.2 ms | 971.7 ms |
| 500,000 | 1,564 ms | 370.3 ms | 1,935 ms |
| 1,000,000 | 3,123 ms | 769.5 ms | 3,892 ms |
Using load_rules_json() instead of the builder pattern is 2.6–3.1× faster
for build time (same eval performance):
| Rules | Builder Total | JSON Total | Speedup |
|---|---|---|---|
| 1,000 | 3.9 ms | 1.5 ms | 2.6× |
| 10,000 | 38.0 ms | 13.3 ms | 2.9× |
| 100,000 | 372.2 ms | 121.3 ms | 3.1× |
| 1,000,000 | 3,892 ms | 1,354 ms | 2.9× |
Python vs Rust — Summary (x86_64)
| Metric | Rust (x86_64) | Python (x86_64) | Ratio |
|---|---|---|---|
| HBAC evaluate (cached, 1K) | 66 ns | 157 ns | Rust 2.4× faster |
| HBAC check_access (cached, 1K) | 60 ns | 121 ns | Rust 2.0× faster |
| HBAC evaluate (cached, 100K) | 87 ns | 153 ns | Rust 1.8× faster |
| HBAC check_access (cached, 100K) | 76 ns | 123 ns | Rust 1.6× faster |
| HBAC throughput (1K) | 14.8M r/s | 6.18M r/s | Rust 2.4× faster |
| HBAC build (10K, builder) | 1.57 ms | 21.7 ms | ~14× slower |
| HBAC build (10K, json) | 1.57 ms | 6.9 ms | ~4.4× slower |
| ABAC eval (cached, 1K) | 176 ns | 320 ns | Rust 1.8× faster |
| ABAC eval (cached, 100K) | 1.05 µs | 982 ns | ~equal |
| ABAC throughput (10K) | 3.48M r/s | 2.57M r/s | Rust 1.4× faster |
| ABAC build (10K, builder) | 8.79 ms | 38.0 ms | ~4.3× slower |
| ABAC build (10K, json) | 8.79 ms | 13.3 ms | ~1.5× slower |
Key observations:
-
HBAC evaluation: With pre-generated request pools (fixing the old benchmark methodology that counted request allocation), native Rust is 1.6–2.4× faster than Python. The
check_access()path (returningboolinstead ofHbacEvaluationResult) is the fastest option in both Rust and Python. Python’s#[pyclass(frozen)]optimization still delivers impressive throughput (8.25M r/s at 1K rules). -
ABAC evaluation: At small scales (1K), Rust is ~1.8× faster. At 100K rules, Python is on par — the compiled evaluator dominates and the PyO3 call overhead is negligible relative to rule evaluation time.
-
Build time: The
load_rules_json()path eliminates Python rule-object construction entirely, achieving build times within 1.5–4.4× of native Rust. The builder path is 4–14× slower due to per-rule FFI round-trips. -
Use case: Python bindings deliver near-native evaluation performance and are suitable for production data-plane use. Use
load_rules_json()andcheck_access()(HBAC) for maximum throughput.
API Reference
This page lists the public types and their key methods across all crates. For full documentation with examples, generate rustdoc:
cargo doc --workspace --no-deps --open
Or for a specific crate:
cargo doc --package acls-rs --open
cargo doc --package hbac-rs --open
cargo doc --package abac-rs --open
cargo doc --package perf-testing --open
acls-rs
Algebraic access control: permissions, subjects, roles, attributes, and temporal rules.
acls_rs::permission
Core permission types.
| Type | Description |
|---|---|
AtomicPermission | Single namespace:action permission. new(ns, action), FromStr, Display. |
PermissionSet | Set of atomic permissions. from([...]), combine() (union), meet() (intersection), join() (join), difference(). Operators: |, &, -. |
GrantDenialPair | Grants + denials with denial precedence. new(grants, denials), empty(), has_permission(), effective_permissions(). |
PermissionDelta | Batch permission change. builder().grant_str(ns, act).remove_str(ns, act).build(), apply_to(set). |
TemporalPermission | Time-windowed permission. new(perm, from, until), is_valid_at(ts), is_currently_valid(). |
TemporalPermissionSet | Collection of temporal permissions. new(), add(), effective_at(ts), currently_effective(). |
DenialSet | Set of denied permissions. |
Timestamp | Type alias for u64 (milliseconds since epoch). |
current_timestamp_millis() | Returns the current time as a Timestamp. |
See Permissions and Subjects, Temporal Permissions.
acls_rs::subject
| Type | Description |
|---|---|
Subject | User or service account. new(id), builder().id(id).grant(perm).role(r).build().unwrap(), has_permission(), grant(), deny(), effective_permissions_at(ts). Fields: id, roles, temporal_permissions. |
BuilderError | Error from Subject::builder().build(). |
acls_rs::resource
| Type | Description |
|---|---|
Resource | Protected object. new(name).requires(perm).resource_type(t), can_access(&subject), missing_permissions(&subject). |
acls_rs::policy
| Type | Description |
|---|---|
RbacPolicy | Role-based policy. new(), add_role(), resolve_permissions(&roles), effective_permissions(&roles). |
Role | Named permission bundle. new(name, GrantDenialPair), with_parent(name). Field: parent_roles. |
RbacError | Error enum. Variant: CyclicDependency. |
AbacPolicy | Attribute-based policy. new() (MeetResolver default), with_resolver(resolver), add_rule(), evaluate(&context). |
AttributeRule | Attribute-based rule. new().grant(perm, attrs).deny(perm, attrs). |
AttributeContext | Type alias for HashMap<String, String>. |
AttributePermission | Result of ABAC evaluation. has_permission(), effective_permissions(). |
MeetResolver | Conflict resolver: most restrictive wins (default). |
JoinResolver | Conflict resolver: most permissive wins. |
PriorityResolver | Conflict resolver: priority-ordered. |
ConflictResolver | Trait for custom resolvers. |
See RBAC Policies, ABAC Policies.
acls_rs::algebra
Algebraic traits implemented by permission types.
| Trait | Description |
|---|---|
Semigroup | Associative binary operation. |
Monoid | Semigroup with identity element. |
MeetSemilattice | Greatest lower bound (intersection). |
JoinSemilattice | Least upper bound (union). |
Lattice | Both meet and join semilattice. |
MonoidAction | Monoidal action on another type. |
acls_rs::calculation
Permission computation utilities.
| Type | Description |
|---|---|
HasPermissions | Trait for types that carry permissions. |
PermissionEffect | Result of a permission check with rationale. |
PermissionEffectBuilder | Builder for PermissionEffect. |
PermissionPreview | Preview what permissions would result from a change. |
acls_rs::prelude
Re-exports all commonly used types from the modules above. Use
use acls_rs::prelude::*; in application code.
hbac-rs
Host-Based Access Control for FreeIPA environments. Built on acls-rs.
hbac_rs::rule
| Type | Description |
|---|---|
HbacRule | Three-dimensional access rule. new(name), new_deny(name), enable(), disable(), set_user_category_all(), set_host_category_all(), set_service_category_all(), add_user(s), add_user_group(s), add_host(s), add_host_group(s), add_service(s), add_service_group(s), estimated_size(). All add_* methods accept impl Into<String> and return Result<(), CategoryError>. Fields: name: Arc<str>, enabled, users, targethosts, services, rule_type. |
HbacRuleType | Enum: Allow, Deny. |
hbac_rs::category
| Type | Description |
|---|---|
HbacCategory | Enum: All or Specific { names: Box<[Arc<str>]>, groups: Box<[Arc<str>]> }. new_specific(), all(), from_names_and_groups(names, groups), add_name(s), add_group(s), is_all(), estimated_heap_size(). |
CategoryError | Error when adding entities to a Category::All. Variant: CannotModifyAll. |
hbac_rs::request
| Type | Description |
|---|---|
HbacRequest | Access request. new(subject, host, service), builder(). Fields: user, targethost, targethost_groups, service, service_groups. |
HbacRequestBuilder | Builder: user(), targethost(), targethost_group(), service(), service_group(), build() -> Result<HbacRequest, BuilderError>. |
BuilderError | Error for missing required fields. Variant: MissingField(&str). |
hbac_rs::policy
| Type | Description |
|---|---|
HbacPolicy | Thread-safe policy (Mutex-backed). Type alias for HbacPolicyCore<Mutex<RulePipeline>>. |
HbacPolicyLocal | Single-threaded policy (RefCell-backed). Type alias for HbacPolicyCore<RefCell<RulePipeline>>. |
HbacPolicyCore<L> | Generic policy. new(), add_rule(), add_temporal_rule(), remove_rule(), enable_rule(), disable_rule(), load_rules(), rules(), stats(), evaluate(), evaluate_detailed(), evaluate_at(), evaluate_detailed_at(), check_access(), evaluate_and_apply(), evaluate_and_apply_at(). With jit feature: enable_jit(), jit_compile_hot(), jit_stats(). |
HbacEvaluationResult | Result of evaluation. is_allowed(), is_denied(). Fields: allowed, matched_rules, not_matched_rules. |
PipelineLock | Trait for thread-safety abstraction. |
See Policy Evaluation.
hbac_rs::resource
| Type | Description |
|---|---|
HbacResource | (host, service) as an acls-rs Resource. new(host, service), builder(), can_access(&subject), missing_permissions(&subject), required_permission(), grant_access(&mut subject), deny_access(&mut subject). |
HbacResourceBuilder | Builder: host(), service(), host_group(), service_group(), build(). |
hbac_rs::temporal
| Type | Description |
|---|---|
TemporalHbacRule | Time-windowed HBAC rule. new(rule, from, until), valid_for_duration(rule, ms), valid_from(rule, ts), valid_until(rule, ts), is_valid_at(ts), is_currently_valid(), current_rule(), rule_at(ts). Fields: rule, valid_from, valid_until. |
See Temporal HBAC Rules.
hbac_rs::compiled
| Type | Description |
|---|---|
HbacCompiledEvaluator | Pre-compiled HBAC evaluator with AHashSet-based lookups. build(rules), evaluate(request) -> bool. Separates deny and allow rules at build time. Detects universal allow for fast-path evaluation. |
hbac_rs::composition
| Type | Description |
|---|---|
ComposedPolicy | HBAC+RBAC composition. new(hbac, rbac, mode), evaluate(), check_access(), hbac_policy(), hbac_policy_mut(), rbac_policy(), rbac_policy_mut(). |
CompositionMode | Enum: And, Or, HbacFirst, RbacFirst. |
See Policy Composition.
hbac_rs::cache
Caching pipeline for production deployments.
| Type | Description |
|---|---|
RulePipeline | Source-filter-cache-evaluate pipeline. builder(), load(), refresh(), incremental_update(), evaluate(), evaluate_with_tracking(), rule_count(), stats(), last_update_timestamp(). |
RulePipelineBuilder | Builder: with_source(), with_filter(), with_cache(), build(). |
RuleSource | Trait: fetch_all(), fetch_updated_since(), is_available(). |
RuleSourceError | Error enum: ConnectionFailed, AuthenticationFailed, ParseError, Unavailable, Other. |
MemorySource | In-memory source. new(rules), add_rule(). |
CompositeSource | Multi-source with fallback. new(), with_source(), add_source(). |
RuleFilter | Trait: should_cache(), filter(), filter_cloned(). |
HostFilter | Host-specific filter. for_host(name), with_group(), with_groups(). |
ApplicabilityFilter | Scope filter. global_only(), all_enabled(). |
AcceptAllFilter | No-op filter. |
AndFilter | Logical AND of filters. new(), with(). |
OrFilter | Logical OR of filters. new(), with(). |
RuleCache | Trait: store(), get(), get_all(), get_many(), contains(), len(), is_empty(), clear(), stats(). |
IndexedCache | In-memory indexed cache. new(), find_by_user(), find_by_host(), find_by_user_and_host(). |
DiskCache | Persistent cache (requires serde). new(path), flush(). |
CacheStats | Cache metrics. hit_rate(). Fields: rule_count, memory_bytes, hit_count, miss_count. |
RuleIndex | Trait: index(), find_by_user(), find_by_host(), find_by_service(), clear(). |
UserIndex | Index by user. new(). |
HostIndex | Index by host. new(). |
CompositeIndex | Multi-dimensional index. new(), find_by_user_and_host(). |
BloomFilterCache | Probabilistic filter. build(rules), might_match(...). |
DecisionNode | Compiled decision tree. build(rules), evaluate(request). |
See Caching System, Caching Architecture.
hbac_rs::prelude
Re-exports commonly used types: HbacRule, HbacRuleType, HbacPolicy,
HbacPolicyLocal, HbacRequest, HbacRequestBuilder, HbacResource,
HbacResourceBuilder, HbacCategory, HbacEvaluationResult,
ComposedPolicy, CompositionMode, TemporalHbacRule, plus acls-rs types
(Subject, Resource, AtomicPermission, Timestamp,
current_timestamp_millis).
Cache types are not in the prelude. Import them from hbac_rs::cache::*.
abac-rs
Attribute-Based Access Control with multi-type attributes and pluggable matchers. Built on acls-rs.
abac_rs::rule
| Type | Description |
|---|---|
AbacRule | Attribute-based access rule. new(name), new_deny(name), enable(), disable(), add_condition(key, value), estimated_size(). Fields: name: Arc<str>, enabled, conditions, rule_type. |
AbacRuleType | Enum: Allow, Deny. |
abac_rs::attribute
| Type | Description |
|---|---|
AttributeValue | Multi-type attribute value. Enum variants: String(String), Integer(i64), Float(f64), IpAddr(IpAddr), IpCidr(IpCidr), Custom(Box<dyn Any + Send + Sync>). |
AttributeMatcher | Trait for custom matching logic. matches(&self, value: &AttributeValue) -> bool. |
abac_rs::request
| Type | Description |
|---|---|
AbacRequest | Evaluation request with attributes. new(), set_attribute(key, value), get_attribute(key). Field: attributes: HashMap<String, AttributeValue>. |
abac_rs::policy
| Type | Description |
|---|---|
AbacPolicy | Thread-safe policy (Mutex-backed). Type alias for AbacPolicyCore<Mutex<RulePipeline>>. |
AbacPolicyLocal | Single-threaded policy (RefCell-backed). Type alias for AbacPolicyCore<RefCell<RulePipeline>>. |
AbacPolicyCore<L> | Generic policy. new(), add_rule(), add_temporal_rule(), remove_rule(), enable_rule(), disable_rule(), load_rules(), rules(), stats(), evaluate(), evaluate_detailed(), evaluate_at(), evaluate_detailed_at(), register_matcher(key, matcher). |
AbacEvaluationResult | Result of evaluation. is_allowed(), is_denied(). Fields: allowed, matched_rules, not_matched_rules. |
See Policy Evaluation.
abac_rs::compiled
| Type | Description |
|---|---|
AbacCompiledEvaluator | Pre-compiled evaluator with extracted attributes. build(rules), evaluate(request). |
abac_rs::temporal
| Type | Description |
|---|---|
TemporalAbacRule | Time-windowed ABAC rule. new(rule, valid_from, valid_until), is_valid_at(ts). |
abac_rs::cache
| Type | Description |
|---|---|
RulePipeline | Evaluation pipeline with deny index and compiled evaluator. evaluate(request), rebuild_indexes(). |
DenyIndex | Bitmap-based deny rule index (3.2x less memory than HBAC). build(rules), check_deny(request). |
BloomFilterCache | Probabilistic filter for attributes. build(rules), might_match(...). |
See ABAC User Guide.
abac_rs::prelude
Re-exports commonly used types: AbacRule, AbacRuleType, AbacPolicy,
AbacPolicyLocal, AbacRequest, AbacEvaluationResult, AttributeValue,
AttributeMatcher, TemporalAbacRule, plus acls-rs types
(Subject, Timestamp, current_timestamp_millis).
abac-wasm
WebAssembly bindings for the ABAC engine. Provides a JSON-based API
for managing and evaluating ABAC policies in browser and Node.js
environments. All errors are returned as structured objects with
{type, message} fields.
AbacPolicyWasm
| Method | Description |
|---|---|
new() | Create a new ABAC policy. |
addRuleFromJson(json) | Add a rule from a JSON string. |
evaluateJson(requestJson) | Evaluate a request; returns {"allowed": bool}. |
loadRulesFromJson(jsonArray) | Load multiple rules from a JSON array. |
clearRules() | Remove all rules from the policy. |
getCacheStats() | Return cache statistics as JSON. |
getRuleCount() | Return the number of loaded rules. |
getAllRulesJson() | Return all rules as a JSON array. |
registerMatcher(dimension, matchFn) | Register a custom JavaScript matcher function for a dimension. The callback receives (ruleValue, requestValue, requestGroups) and returns a boolean. |
AbacRuleBuilderWasm
Fluent API for building ABAC rules without manual JSON construction.
| Method | Description |
|---|---|
new(name) | Create a builder with the given rule name. |
addDimension(dimension, valuesJson) | Add a dimension with typed attribute values. |
addDimensionAll(dimension) | Set a dimension to match all values (wildcard). |
setEnabled(enabled) | Enable or disable the rule (disabled by default). |
setRuleType(type) | Set "Allow" or "Deny". |
buildJson() | Return the rule as a JSON string for addRuleFromJson(). |
Error types
All methods that return errors produce a JavaScript object:
| Field | Values |
|---|---|
type | "PolicyError", "JsonError", or "ValidationError" |
message | Human-readable description |
hbac-wasm
WebAssembly bindings for the HBAC engine. Provides a JSON-based API for FreeIPA-style host-based access control in browser and Node.js environments.
HbacPolicyWasm
| Method | Description |
|---|---|
new() | Create a new HBAC policy. |
addRuleFromJson(json) | Add a rule from a JSON string. |
checkAccessJson(requestJson) | Evaluate a request; returns boolean. |
evaluateDetailedJson(requestJson) | Evaluate with detail; returns {allowed, matched_rules, not_matched_rules}. |
loadRulesFromJson(jsonArray) | Load multiple rules from a JSON array. |
clearRules() | Remove all rules from the policy. |
getRuleCount() | Return the number of loaded rules. |
HbacRuleBuilderWasm
Fluent API for building HBAC rules.
| Method | Description |
|---|---|
new(name) | Create a builder with the given rule name. |
userCategoryAll() | Match all users. |
hostCategoryAll() | Match all hosts. |
serviceCategoryAll() | Match all services. |
addUsers(json) | Add specific users from JSON array. |
addUserGroups(json) | Add user groups from JSON array. |
addHosts(json) | Add specific hosts from JSON array. |
addHostGroups(json) | Add host groups from JSON array. |
addServices(json) | Add specific services from JSON array. |
addServiceGroups(json) | Add service groups from JSON array. |
setEnabled(enabled) | Enable or disable the rule. |
setDeny() | Set as a deny rule (allow by default). |
buildJson() | Return the rule as a JSON string for addRuleFromJson(). |
HbacRequestWasm
| Method | Description |
|---|---|
new(user, targethost, service) | Create a new request. |
addUserGroups(json) | Add user groups from JSON array. |
addTargethostGroups(json) | Add target host groups from JSON array. |
addServiceGroups(json) | Add service groups from JSON array. |
toJson() | Serialize to JSON string. |
fromJson(json) | (static) Deserialize from JSON string. |
Error types
Same structured error format as abac-wasm: {type, message} objects
with "PolicyError", "JsonError", or "ValidationError" types.
perf-testing
Performance testing framework and bac-perf CLI.
Key modules
| Module | Description |
|---|---|
perf_testing::synthesis | Rule generation with distribution presets and patterns. |
perf_testing::fixtures | Fixture loading, saving, and validation (gzip-compressed JSON). |
perf_testing::runner | Benchmark scenario execution and metrics collection. |
perf_testing::bac | BAC abstraction layer (BacPolicy trait, HbacPolicyAdapter). |
perf_testing::cli | bac-perf command implementations. |
See Performance Testing Framework, CLI Reference.
Architecture Overview
BAC Rules is organized as a Rust workspace with five crates.
Crate Structure
graph LR
CORE[bac-python-core<br/>Python shared types] --> ACLS[acls-rs<br/>Foundation]
CORE --> HBAC[hbac-rs<br/>HBAC implementation]
CORE --> ABAC[abac-rs<br/>ABAC implementation]
ACLS --> HBAC
ACLS --> ABAC
ACLS --> PERF[perf-testing<br/>Performance tooling]
HBAC --> PERF
ABAC --> PERF
bac-python-core (Python Shared Types)
Shared infrastructure for Python bindings across all crates. Enables cross-module type sharing via Python capsules (PyCapsule API).
Key components:
capsulemodule – Safe PyCapsule creation and import helperscreate_capsule<T>()– Export C-ABI compatible type via capsuleimport_capsule<T>()– Import capsule from another Python module
rbacmodule –RbacPolicyAPIfor sharingRbacPolicyacross modulessubjectmodule –SubjectAPIfor sharingSubjectacross modules- Enables
abac_rs.ComposedPolicyto acceptacls_rs.RbacPolicyandhbac_rs.Subjectvia FFI
acls-rs (Foundation)
Algebraically-correct access control primitives. Minimal runtime dependencies (log, thiserror).
Key types:
Subject– user with permissions, roles, and temporal permissionsAtomicPermission– singlenamespace:actionpermissionPermissionSet– collection with algebraic operations (combine,meet,join,difference)GrantDenialPair– separate grants and denials with denial precedenceRole– named permission bundle with optional parent hierarchyRbacPolicy– role resolution with cycle detectionAbacPolicy– attribute-based evaluation with pluggable conflict resolversTemporalPermission– time-windowed permission
Shared abstractions:
sync::SyncStrategy<T>– Generic interior mutability trait overMutex<T>andRefCell<T>policy::PolicyError– Shared error type for DoS protection violationspolicy::RuleLimitedPolicy– Trait for enforcing maximum rule counts
hbac-rs (HBAC Implementation)
FreeIPA HBAC built on acls-rs. Three-dimensional access control (user x host x service) with a six-layer optimization pipeline.
Key types:
HbacRule– three-dimensional rule with allow/deny semantics (name: Arc<str>, categories useBox<[Arc<str>]>)HbacCategory–AllorSpecific { names: Box<[Arc<str>]>, groups: Box<[Arc<str>]> }HbacCompiledEvaluator– pre-compiled evaluator with AHashSet lookupsHbacPolicyCore<L>– evaluation engine parameterized by lock typeHbacPolicy/HbacPolicyLocal– thread-safe and single-threaded aliasesRulePipeline– cached evaluation with Bloom filter, decision tree, bitmap-based deny-rule index, and compiled evaluatorComposedPolicy– HBAC+RBAC composition with four modesTemporalHbacRule– time-windowed HBAC rule
abac-rs (ABAC Implementation)
Generic attribute-based access control built on acls-rs. N-dimensional access control with multi-type attributes and pluggable matchers.
Key types:
AbacRule– attribute-based rule with allow/deny semanticsAttributeValue– multi-type value (String, Integer, Float, IpAddr, IpCidr, custom)AttributeMatcher– pluggable matcher for custom matching logicAbacCompiledEvaluator– pre-compiled evaluator with pre-extracted attributesAbacPolicy/AbacPolicyLocal– thread-safe and single-threaded aliasesAbacRequest– evaluation request with attribute context- Bitmap-based deny index with u64 bitmask intersection (3.2x less memory than HBAC)
perf-testing (Tooling)
Performance testing framework and bac-perf CLI. Depends on acls-rs,
hbac-rs, and abac-rs.
Key components:
- Rule synthesis with configurable distribution presets
- Fixture management (compressed JSON)
- Benchmark scenarios (latency, throughput, build time)
- Result comparison for regression detection
Directory Structure
graph TD
A[bac-rules/] --> B[crates/]
A --> C[docs/]
A --> D[fixtures/]
B --> B0[bac-python-core/]
B --> B1[acls-rs/]
B --> B2[hbac-rs/]
B --> B3[abac-rs/]
B --> B4[perf-testing/]
Design Principles
- Layered architecture: acls-rs provides primitives, hbac-rs and abac-rs build domain-specific evaluation, perf-testing validates performance.
- Separation of concerns: pure evaluation logic vs. caching vs. tooling.
- Composability: HBAC, RBAC, and ABAC policies can be combined.
- Lazy construction: expensive indexes are deferred until first evaluation.
- Interior mutability: cache updates happen through an immutable policy API.
HBAC Optimization Architecture
HbacPolicy uses a six-layer optimization stack. Each layer is tried in
order; a result from any layer short-circuits the rest.
Evaluation Flow
flowchart TD
REQ[evaluate request] --> DIRTY{indexes<br/>dirty?}
DIRTY -->|yes| REBUILD[rebuild indexes]
REBUILD --> L0
DIRTY -->|no| L0
L0{Layer 0<br/>constant result?} -->|yes| RET0[return stored result]
L0 -->|no| L1{Layer 1<br/>LRU cache hit?}
L1 -->|hit| RET1[return cached result]
L1 -->|miss| L2{Layer 2<br/>Bloom filter}
L2 -->|definite no-match| RET2[return deny]
L2 -->|maybe match| L6
L6{Layer 6<br/>JIT compiled?} -->|hit| RET6[return JIT result]
L6 -->|no JIT| L4
L4{Layer 4<br/>deny index<br/>available?} -->|yes| DENY[bitmask intersection<br/>check candidates]
DENY --> RET4[return result]
L4 -->|no| L3[Layer 3<br/>decision tree]
L3 --> RET3[return result]
RET2 --> CACHE[store in LRU]
RET6 --> CACHE
RET4 --> CACHE
RET3 --> CACHE
style L6 stroke-dasharray: 5 5
Layer 0: Constant-Result Fast Path
When the decision tree collapses to a constant (e.g., a universal deny rule exists, or a universal allow with no deny rules), the result is stored at build time and returned immediately. Bypasses all other layers.
Layer 1: LRU Memoization Cache
#![allow(unused)]
fn main() {
eval_cache: LruCache<u64, bool> // 1,024 entries, u64 hash key
}
Keyed by a u64 hash of (user, host, service, groups). Group hashing uses
order-independent wrapping addition. Zero heap allocations per lookup.
Layer 2: Bloom Filter Pre-screening
#![allow(unused)]
fn main() {
pub struct BloomFilterCache {
user_filter: BloomFilter,
host_filter: BloomFilter,
service_filter: BloomFilter,
has_user_all: bool,
has_host_all: bool,
has_service_all: bool,
}
}
Fast negative check with a 1% false positive rate. Tracks category=all
rules separately to avoid false negatives.
Layer 3: Decision Tree Compilation
#![allow(unused)]
fn main() {
pub enum DecisionNode {
UserCheck { user_id, if_match, if_no_match },
HostCheck { host, if_match, if_no_match },
ServiceCheck { service, if_match, if_no_match },
UserGroupCheck { group, if_match, if_no_match },
Result { allow: bool },
Sequential { rule_indices: Vec<usize>, has_deny },
SequentialWithDefault { rule_indices: Vec<usize>, default_allow, has_deny },
}
}
Rules are compiled into a tree at load time. The Sequential and
SequentialWithDefault variants store Vec<usize> indices into the
canonical rule slice rather than cloned rules. When a universal allow exists
alongside specific deny rules, the tree stores only the deny rule indices
(SequentialWithDefault { default_allow: true }), reducing evaluation from
O(n) to O(d).
Layer 4: Deny-Rule Index
Indexes deny rules by user and host dimensions. On evaluation, intersects user candidates with host candidates using bitmask operations (u64 words), then checks service matching only on the intersection. Narrows the candidate set from tens of deny rules to 2-3.
flowchart LR
U[User candidates<br/>bitmask] --> INT[Bitmask<br/>intersection]
H[Host candidates<br/>bitmask] --> INT
INT --> SVC{Service<br/>match?}
SVC -->|match| DENY[Deny]
SVC -->|no match| ALLOW[Default allow]
Built only when the decision tree produces
SequentialWithDefault { default_allow: true }.
Layer 5: Composite Indexing (Fallback)
O(1) user+host lookup via CompositeIndex. Used when the decision tree is
not available.
Layer 6: JIT Compilation (optional)
Requires the jit feature flag. Compiles per-rule matching logic to native
code via Cranelift.
flowchart TD
EVAL[evaluate rule] --> HOT{evaluated<br/>>100 times?}
HOT -->|no| INTERP[interpreter]
HOT -->|yes| COMPILED{JIT cache<br/>has compiled?}
COMPILED -->|yes| NATIVE[run native code]
COMPILED -->|no| COMPILE[compile via Cranelift]
COMPILE --> NATIVE
Compilation strategies by rule pattern:
| Pattern | Strategy | Speedup |
|---|---|---|
category=all | Constant return | Negligible (already fast) |
| Single user | Integer comparison (<=8 bytes) or memcmp | Measurable |
| Group match | Unrolled comparison loop | Measurable |
| Complex | Falls back to interpreter | None |
See Performance Results for measured JIT impact.
Lazy Index Building
sequenceDiagram
participant App
participant Policy as HbacPolicy
participant Pipeline as RulePipeline
App->>Policy: load_rules(rules)
Policy->>Pipeline: store rules
Pipeline->>Pipeline: indexes_dirty = true
App->>Policy: evaluate(request)
Policy->>Pipeline: evaluate(request)
Pipeline->>Pipeline: indexes_dirty? yes
Pipeline->>Pipeline: rebuild_indexes()
Note over Pipeline: Build Bloom filter<br/>Build decision tree<br/>Build deny index<br/>Detect constant result
Pipeline->>Pipeline: indexes_dirty = false
Pipeline-->>Policy: HbacEvaluationResult
App->>Policy: evaluate(request)
Policy->>Pipeline: evaluate(request)
Pipeline->>Pipeline: indexes_dirty? no
Pipeline-->>Policy: HbacEvaluationResult (fast path)
This eliminates O(n^2) overhead when loading rules in batch: load_rules()
marks indexes dirty and a single rebuild happens on the first evaluate()
call.
Interior Mutability Pattern
All policy types (HbacPolicyCore<L>, AbacPolicyCore<L>,
ComposedPolicyCore<A, P>) use the SyncStrategy<T> trait for generic
interior mutability.
classDiagram
class SyncStrategy~T~ {
<<trait>>
+new(inner: T) Self
+with(f: impl FnOnce(&mut T) -> R) R
}
class Mutex~T~ {
+lock() MutexGuard~T~
}
class RefCell~T~ {
+borrow_mut() RefMut~T~
}
SyncStrategy <|.. Mutex : Thread-safe
SyncStrategy <|.. RefCell : Single-threaded
class HbacPolicyCore~L~ {
-rules: Vec~HbacRule~
-pipeline: L
-pipeline_dirty: AtomicBool
-temporal_rules: Vec~TemporalHbacRule~
+evaluate(&self, request) HbacEvaluationResult
+add_rule(&mut self, rule)
}
class HbacPolicy {
L = Mutex~RulePipeline~
Send + Sync
}
class HbacPolicyLocal {
L = RefCell~RulePipeline~
!Sync
}
HbacPolicyCore <|-- HbacPolicy : L = Mutex
HbacPolicyCore <|-- HbacPolicyLocal : L = RefCell
evaluate(&self) mutates the internal pipeline (LRU cache updates, lazy index
rebuilds) through the SyncStrategy trait. This maintains a natural API where
evaluation does not require &mut self, while cache mutations happen
internally.
HbacPolicy(Mutex): safe to share viaArc<HbacPolicy>across threads.HbacPolicyLocal(RefCell): zero mutex overhead for single-threaded use (SSSD, benchmarks).- Shared implementation in
acls_rs::sync::SyncStrategyeliminates code duplication across HBAC, ABAC, and composed policies.
Two-Tier Evaluation API
#![allow(unused)]
fn main() {
// Fast path: production use (no match tracking)
pub fn evaluate(&self, request) -> HbacEvaluationResult
// result.matched_rules is empty -- saves allocation overhead
// Detailed: testing/debugging (with match tracking)
pub fn evaluate_detailed(&self, request) -> HbacEvaluationResult
// result.matched_rules is populated
}
Production systems rarely need to know which rules matched. The detailed
variant populates matched_rules and not_matched_rules for diagnostic
tools.
See Also
- Performance Results – benchmark data
- Caching Architecture – pipeline internals
- API Reference – complete type listings
JIT Compilation
The jit feature flag enables Just-In-Time compilation of HBAC rules via
Cranelift. It adds a hybrid JIT/interpreter runtime as an additional
optimization layer in the evaluation pipeline.
Enabling
[dependencies]
hbac-rs = { version = "0.2", features = ["jit"] }
#![allow(unused)]
fn main() {
let mut policy = HbacPolicy::new();
policy.load_rules(rules);
policy.enable_jit();
for request in requests {
policy.evaluate(&request);
}
}
enable_jit() returns false if Cranelift is not available for the
current architecture (only x86_64 is supported).
How It Works
The JIT runtime profiles rule evaluations and compiles hot rules to native code after a threshold of 100 evaluations. Rules that are not hot or cannot be compiled fall back to the standard decision tree interpreter.
flowchart TD
EVAL[evaluate request] --> JIT{JIT-compiled<br/>deny rules?}
JIT -->|match| DENY[return deny]
JIT -->|no match| INTERP[decision tree<br/>interpreter]
INTERP --> RESULT[return result]
PROFILE[profiler records<br/>evaluation count] -.-> HOT{count >= 100?}
HOT -->|yes| COMPILE[compile via Cranelift]
COMPILE --> CACHE[store in jit_cache]
The runtime checks JIT-compiled deny rules first for early exit, then delegates to the interpreter for the full evaluation.
Rule Classification
The compiler classifies each rule into a pattern that determines the compilation strategy:
| Pattern | Criteria | Compilation |
|---|---|---|
CategoryAll | All three dimensions are category=all | Native Cranelift codegen (constant return) |
SingleUser | One user name, any host/service | Optimized interpreter (direct string comparison) |
GroupMatch | User groups specified, any host/service | Optimized interpreter (set membership check) |
Complex | Multiple users, or other patterns | Standard interpreter (not compiled) |
Only CategoryAll rules produce actual native machine code. SingleUser
and GroupMatch use pattern-specific data cached at classification time for
faster interpreter evaluation, but do not generate native code.
Internals
Components
JitCompiler– Cranelift-based compiler. Owns theJITModule, declares and finalizes functions.HotRuleProfiler– LRU-based evaluation counter (256 entries). Tracks per-rule evaluation counts and identifies rules above the JIT threshold.JitRuntime– Orchestrates profiling, compilation, and evaluation. Maintains ajit_cachemapping rule names to compiled evaluators.CompiledRule– Implements theRuleEvaluatortrait. Holds either a native function pointer (CategoryAll) or pattern-specific data for the optimized interpreter fallback.
RuleEvaluator Trait
#![allow(unused)]
fn main() {
pub trait RuleEvaluator: Send + Sync {
fn evaluate(&self, request: &HbacRequest) -> bool;
fn debug_info(&self) -> String;
}
}
All compiled rules implement this trait. CompiledRule dispatches to the
native function pointer when available, otherwise uses the cached pattern
data.
Safety
CompiledRule contains a raw function pointer to JIT-compiled code. The
pointer is safe because:
- It is obtained from
JITModule::get_finalized_function()after finalization - The backing
JITModuleis owned byJitCompiler, which is owned by the sameJitRuntimethat owns theCompiledRulein itsjit_cache - The compiled code is read-only after finalization (
Send + Sync)
Statistics
#![allow(unused)]
fn main() {
let stats = policy.jit_stats();
println!("total: {}", stats.total_rules);
println!("compiled: {}", stats.jit_compiled);
println!("hot: {}", stats.hot_rules);
}
jit_compile_hot() manually triggers compilation of all rules that have
crossed the profiling threshold:
#![allow(unused)]
fn main() {
policy.jit_compile_hot();
}
Performance Impact
See Performance Results for measured JIT impact at each scale. Summary:
- Cached workloads: no measurable difference (LRU cache dominates)
- Uncached workloads: ~8-12% throughput improvement
- Build time: no consistent overhead
JIT is worth enabling when uncached or low-hit-rate workloads dominate. For typical SSSD deployments with high cache hit rates, it provides no benefit.
Binary Size
The jit feature adds ~500 KB from Cranelift dependencies.
See Also
- Architecture Overview – JIT layer in the evaluation flow
- Performance Results – benchmark data
WebAssembly Builds
This guide explains how to build and test the WebAssembly packages for abac-wasm and hbac-wasm.
Prerequisites
Rust with rustup
The WASM targets require rustup for toolchain management. If you have Homebrew-installed Rust, you’ll need to switch to rustup:
# Remove Homebrew Rust (if installed)
brew uninstall rust
# Install rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add WASM target
rustup target add wasm32-unknown-unknown
wasm-pack
Install wasm-pack for building WebAssembly packages:
cargo install wasm-pack
Node.js
Node.js is required for running WASM tests:
# macOS
brew install node
# Or use your preferred package manager
Building
Quick Build
Use the provided script to build both WASM crates:
./scripts/wasm-build.sh
This will build:
crates/abac-wasm/pkg/- Web targetcrates/abac-wasm/pkg-node/- Node.js targetcrates/hbac-wasm/pkg/- Web targetcrates/hbac-wasm/pkg-node/- Node.js target
Manual Build
Build individual packages:
# abac-wasm for web
cd crates/abac-wasm
wasm-pack build --target web --release
# abac-wasm for Node.js
wasm-pack build --target nodejs --release --out-dir pkg-node
# hbac-wasm for web
cd ../hbac-wasm
wasm-pack build --target web --release
# hbac-wasm for Node.js
wasm-pack build --target nodejs --release --out-dir pkg-node
Cargo Aliases
You can also use the cargo aliases defined in .cargo/config.toml:
cargo wasm-build-abac
cargo wasm-build-hbac
Testing
Quick Test
Run all WASM tests:
./scripts/wasm-test.sh
Manual Testing
# Test in Node.js
cd crates/abac-wasm
wasm-pack test --node
# Test in browser (requires Firefox)
wasm-pack test --headless --firefox
Dependency Compatibility
WASM-Compatible Dependencies
The following dependencies are verified to work with WASM:
- acls-rs (zero dependencies)
- lru (with
default-features = falseon wasm32) - ipnetwork
- thiserror
- log
- ahash (with
no-rngfeature on wasm32) - serde, serde_json
- getrandom (with
wasm_jsfeature) - js-sys, web-sys
- console_error_panic_hook
Excluded Features
Cranelift JIT: The jit feature of hbac-rs is NOT WASM-compatible and is explicitly excluded from hbac-wasm builds.
Troubleshooting
Homebrew Rust
If you’re using Homebrew-installed Rust (not rustup), you cannot add WASM targets. The error will be:
error[E0463]: can't find crate for `core`
= note: the `wasm32-unknown-unknown` target may not be installed
= help: consider downloading the target with `rustup target add wasm32-unknown-unknown`
Solution: Switch to rustup-based Rust installation as described in Prerequisites.
Bloom Filter on WASM
The bloom feature (backed by fastbloom) is disabled for WASM builds.
abac-wasm depends on abac-rs with default-features = false, which excludes
the bloom and smallvec dependencies. Bloom filter pre-screening is only
available in native builds.
WASM API Features
Structured Errors
Both abac-wasm and hbac-wasm return structured error objects instead of plain strings. Every error thrown by the WASM bindings is a JavaScript object with two fields:
try {
policy.addRuleFromJson(invalidJson);
} catch (e) {
console.log(e.type); // "JsonError", "PolicyError", or "ValidationError"
console.log(e.message); // human-readable description
}
Custom Matchers (abac-wasm)
AbacPolicyWasm.registerMatcher(dimension, matchFn) installs a JavaScript
callback for a named dimension. The ABAC engine calls this function
during evaluation instead of its built-in matching logic:
// Threshold matcher: true when request value >= rule threshold
policy.registerMatcher("temperature_high", (ruleValue, requestValue, requestGroups) => {
if (ruleValue === "all") return true;
if (!Array.isArray(ruleValue)) return false;
const actual = requestValue.type === "Float" ? requestValue.value : null;
if (actual == null) return false;
return ruleValue.some(t => {
const threshold = t.type === "Float" ? t.value : null;
return threshold != null && actual >= threshold;
});
});
Arguments are serde-serialized: ruleValue is either "all" or an array of
{type, value} objects; requestValue and requestGroups follow the same
{type, value} shape.
Wildcard Dimensions
Rule dimensions accept the literal string "all" as a wildcard. The
custom deserializer rejects any other string value — only "all"
(case-insensitive) is valid:
{
"name": "match-all-locations",
"dimensions": {
"sensor_type": [{"type": "String", "value": "temperature"}],
"location": "all"
},
"rule_type": "Allow",
"enabled": true
}
The AbacRuleBuilderWasm.addDimensionAll(dimension) method provides a
type-safe way to set a wildcard dimension from the builder API.
Deterministic JSON Output
getAllRulesJson() and rule serialization use BTreeMap for dimension
ordering, so the JSON output is deterministic across calls. This makes
snapshot testing and diff-based comparisons reliable.
Bundle Sizes
Target bundle sizes (gzipped):
- abac-wasm: ~150-200 KB
- hbac-wasm: ~120-150 KB
Check actual sizes after building:
# After running wasm-build.sh
du -h crates/abac-wasm/pkg/*.wasm
du -h crates/hbac-wasm/pkg/*.wasm
CI/CD
The CI workflow (.github/workflows/ci.yml) runs two WASM jobs:
wasm-build — builds both crates for web and Node.js targets:
- Installs
wasm-pack@0.13.1(pinned version) - Verifies hbac-wasm has no cranelift dependency via
cargo tree - Builds abac-wasm and hbac-wasm for
--target weband--target nodejs - Caches the cargo registry and git checkouts between runs
wasm-test — runs WASM tests after a successful build:
- Installs build tooling (
gcc,make,pkgconf-pkg-config) - Installs
wasm-pack@0.13.1 - Runs
wasm-pack test --nodefor both crates - Caches the cargo registry and git checkouts
The cranelift check in wasm-build captures cargo tree output and
validates both the command exit code and the absence of cranelift in the
dependency tree. This prevents the JIT feature from accidentally leaking
into hbac-wasm builds.
Contributing
Building
cargo build --workspace
With JIT compilation (x86_64 only):
cargo build --workspace --features jit
Local CI
Run the full CI pipeline locally before submitting a pull request:
./scripts/local-ci.sh all
Individual jobs:
./scripts/local-ci.sh --list # show available jobs
./scripts/local-ci.sh fmt clippy # run specific jobs
./scripts/local-ci.sh build test doc # build, test, and generate docs
Available jobs: build, fmt, clippy, doc, test, bench-quick,
examples.
Code Style
cargo fmtenforces formatting (checked by CI)cargo clippy --workspace --all-targetsmust pass with no warningscargo doc --workspace --no-depsmust build without warnings
Testing
cargo test --workspace
See Testing for the full testing strategy.
Pull Requests
- One logical change per commit
- Conventional commit format:
feat:,fix:,docs:,refactor:,test:,perf:,chore: - Subject line under 72 characters, imperative mood
- Include
Signed-off-byline
Documentation
The book is built with mdbook:
mdbook build docs/
mdbook serve docs/ # local preview at http://localhost:3000
API documentation:
cargo doc --workspace --no-deps --open
Release Process
See Release Process for the two-phase prepare/publish workflow.
Testing
Running Tests
# Full workspace
cargo test --workspace
# Specific crate
cargo test --package acls-rs
cargo test --package hbac-rs
cargo test --package abac-rs
cargo test --package perf-testing
# With output visible
cargo test -- --nocapture
# Single test by name
cargo test --package hbac-rs deny_rule
Test Organization
acls-rs
Unit tests for algebraic permission operations, subject construction, role
hierarchy resolution, ABAC evaluation, and temporal permission validity.
Tests live in #[cfg(test)] modules alongside the implementation.
hbac-rs
Unit tests for rule construction, category semantics, request matching, policy evaluation (allow/deny precedence, dimension matching), and the optimization pipeline (Bloom filter, decision tree, deny-rule index).
Integration tests under tests/ cover end-to-end evaluation scenarios:
multi-rule policies, temporal rules, composed policies, and cache pipeline
behavior.
With the jit feature enabled, additional tests cover rule classification,
Cranelift compilation, and JIT runtime evaluation:
cargo test --package hbac-rs --features jit
abac-rs
Unit tests for rule construction, attribute matching, policy evaluation (allow/deny precedence, multi-type attributes), compiled evaluator, and bitmap-based deny index.
Integration tests under tests/ cover end-to-end scenarios: multi-type
attributes (String, Integer, Float, IpAddr, IpCidr), custom matchers,
deny rules, and cache behavior.
perf-testing
Tests for fixture generation (distribution presets, reproducibility with seeded RNG), fixture I/O (compression, schema validation), and benchmark scenario execution.
Performance Benchmarks
Benchmarks are separate from the test suite. Use the bac-perf CLI:
# Generate a fixture and run all scenarios
cargo run --release --package perf-testing -- \
generate --count 1000 --output test_1k --seed 42
cargo run --release --package perf-testing -- \
bench --fixture test_1k --scenario all --cache
For statistically rigorous measurements with Criterion:
cd crates/perf-testing
cargo bench --bench hbac_benchmarks
See Performance Testing Framework for the full workflow.
Local CI
Run the complete CI pipeline before submitting changes:
./scripts/local-ci.sh all
This runs build, fmt, clippy, doc, test, bench-quick, and
examples in sequence. Any failure stops the pipeline.
Writing Tests
- Place unit tests in
#[cfg(test)]modules next to the code they test - Place integration tests in
tests/directories within each crate - Use
assert!/assert_eq!with descriptive messages for failures - Test both the happy path and error conditions (e.g.,
CategoryErrorwhen adding to aCategory::All) - For HBAC evaluation tests, use
evaluate_detailed()to inspect which rules matched
Release Process
The bac-rules workspace uses a two-phase release system managed by
contrib/release/release.py. Releases are split into prepare (changelog
generation, version bump, tag) and publish (upload to crates.io) so that
the changelog commit can be reviewed via a pull request before anything is
published.
Prerequisites
- Python 3.10+
cargo loginwith a valid crates.io token (for publishing)- GPG key configured (for signed tags; use
--no-signif unavailable)
Typical release flow
Phase 1 — prepare
# Preview changelog and version changes (no files written):
./contrib/release/release.py
# Preview a version bump:
./contrib/release/release.py 0.2.0
# Execute: commit changelogs, bump version, and tag:
./contrib/release/release.py --do-run 0.2.0
# Push and open a PR:
git push origin <branch> --tags
The script runs cargo check --workspace and scripts/local-ci.sh before
committing. Pass --skip-ci if CI is already known green.
Phase 2 — publish
After the release PR is merged:
git checkout main && git pull
# Preview (dry-run publish check):
./contrib/release/release.py --publish-only
# Publish all crates to crates.io:
./contrib/release/release.py --do-run --publish-only
Crates are published in dependency order (acls-rs before hbac-rs and
abac-rs) with a 30-second delay between calls for crates.io index propagation.
Crate publish order
The file contrib/release/publish-order defines the publication sequence.
Each line is a workspace-relative crate path. The order must respect
dependency constraints — each crate must appear after all workspace crates
it depends on.
Changelogs
Each publishable crate has its own CHANGELOG.md following the
Keep a Changelog format.
The release script auto-generates entries from git commit history using
keyword scoring. Commits below a relevance threshold (style fixes, CI
tweaks) are excluded automatically.
Version strategy
All crates in the workspace share a single version defined in
[workspace.package] in the root Cargo.toml. Member crates inherit it
via version.workspace = true. The [workspace.dependencies] table
carries version specs (e.g. version = "0.1") that are also updated on
every version bump.
See contrib/release/README.md for the full option reference, commit
scoring rules, and first-release checklist.