Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

CratePurpose
acls-rsPermission primitives, RBAC with role inheritance, temporal permissions
hbac-rsFreeIPA Host-Based Access Control — three-dimensional matching (user × host × service)
abac-rsAttribute-Based Access Control with arbitrary typed dimensions
ldap-acisLDAP Access Control Instructions — parse, evaluate, generate, and transform ACIs for 389 DS and OpenLDAP
posix-aclsPOSIX.1e ACL modeling: typed permission model, ACL builder, access check algorithm, user/group model, acls-rs bridge
win-sdWindows 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

CratePurpose
acls-python, hbac-python, abac-python, posix-acls-python, win-sd-pythonPython bindings via PyO3 with cross-module type sharing
abac-wasm, hbac-wasmWebAssembly packages for browsers and Node.js

Tools

CratePurpose
perf-testingBenchmark CLI with synthetic rule generation, fixture management, and scaling analysis
ldif-parserLDIF (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

  1. Correctness — permission composition follows well-defined algebraic rules so that grant/deny interactions behave predictably
  2. Performance — evaluation stays fast at scale through caching, indexing, and optional JIT compilation; see Performance Results for benchmark data
  3. Composability — combine HBAC, RBAC, and ABAC policies in a single evaluation
  4. 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

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:

  1. 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.

  2. RBAC policies – group permissions into named roles (developer, admin, ops) with inheritance hierarchies. Assign roles to users instead of managing permissions individually.

  3. ABAC policies – make access decisions based on runtime attributes (department, network location, clearance level). Useful when static roles cannot capture the policy you need.

  4. 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.

  • 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.

See hbac-rs: HBAC Evaluation.

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:

DimensionDescriptionExamples
UserWho is requesting accessA named user, a user group, or all users
HostWhich host is being accessedA named host, a host group, or all hosts
ServiceWhich service is requestedsshd, 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 a Mutex. Use this when the policy is shared across threads (e.g., behind an Arc in a web server).
  • HbacPolicyLocal – single-threaded, backed by a RefCell. 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 typeHBAC usage
SubjectRepresents the requesting user (ID + role memberships as groups)
AtomicPermissionMaps to (host, service) access grants
GrantDenialPairHandles allow/deny rule precedence
TemporalPermissionSupports time-limited HBAC rules
RbacPolicyComposes with HBAC via ComposedPolicy
  • 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:

  1. Filter: Only enabled rules participate. Disabled rules are skipped entirely.
  2. 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.
  3. Deny precedence: If any matching rule is a deny rule, the request is denied regardless of how many allow rules also match.
  4. 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:

  1. Is the rule enabled? Check rule.enabled.
  2. 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?
  3. Does the host dimension match? Is the request’s target host or any of its groups in the rule’s host set?
  4. Does the service dimension match? Same check for services.
  5. Is a deny rule matching? A matching deny rule overrides all allow rules.
  6. 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]

  1. RuleSource fetches rules from an external backend (LDAP, file, API).
  2. RuleFilter keeps only rules applicable to this host.
  3. IndexedCache stores rules with multi-dimensional indexes for fast lookup.
  4. 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:

FilterPurpose
HostFilterKeep rules whose host dimension matches this host or its groups
ApplicabilityFilterKeep only global rules (global_only) or all enabled rules (all_enabled)
AcceptAllFilterKeep everything (no filtering)
AndFilterRequire all child filters to accept
OrFilterAccept 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:

FieldDescription
rule_countNumber of rules in the cache
memory_bytesEstimated memory consumption
hit_countNumber of cache hits
miss_countNumber 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:

  1. SSSD connects to FreeIPA LDAP and fetches HBAC rules.
  2. Rules are filtered to those applicable to the local host and stored in an IndexedCache.
  3. When a user attempts to log in, PAM calls into the evaluation engine.
  4. 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:

ConstructorDescription
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

ModeBehavior
AndBoth HBAC and RBAC must allow. Use for defense in depth.
OrEither policy allowing is sufficient. Use for convenience-first environments.
HbacFirstHBAC is evaluated first. If HBAC denies, RBAC is not checked.
RbacFirstRBAC 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

ScenarioRecommended mode
High-security environmentAnd – defense in depth
User convenience is priorityOr – either policy suffices
Host access is the bottleneckHbacFirst – skip RBAC when host is unreachable
Most denials are permission-basedRbacFirst – skip HBAC when role lacks permissions
Need ABAC tooManual 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:

  1. Check all deny rules – if any match, return Decision::Deny.
  2. Check all allow rules – if any match, return Decision::Allow.
  3. Default to Decision::Deny if 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):

RulesThroughputMean LatencyMemory
1001.50M r/s668 ns7.8 KB
1,0004.76M r/s210 ns78 KB
10,0002.92M r/s343 ns781 KB
100,0001.19M r/s843 ns7.6 MB
1,000,000156K r/s6.43 us76 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.

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

  1. Layer 0: Constant result (~15 ns) – universal allow/deny detection
  2. Layer 1: Bitmap deny index – u64 bitmask intersection when universal allow exists
  3. Layer 2: LRU cache (sub-μs) – memoization of recent evaluations
  4. Layer 3: AHash – 2-3x faster than SipHash for non-cryptographic use
  5. Layer 4: Compiled evaluator – pre-extracted attributes + array indexing
  6. Layer 5: Composite index (O(log n)) – candidate selection fallback
  7. 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:

  1. Deny rules are checked first (deny-override semantics)
  2. If no deny matches, universal allow guarantees Allow decision
  3. 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

  1. Check all deny rules – if any match, return Deny
  2. Check all allow rules – if any match, return Allow
  3. Default to Deny if 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:

  • String matches String
  • Integer matches Integer
  • IpAddr matches IpAddr (or IpCidr with 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 false from supports_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

ModeBehavior
AndBoth ABAC and RBAC must allow. Use for defense in depth.
OrEither policy allowing is sufficient. Use for convenience-first environments.
AbacFirstABAC is evaluated first. If ABAC has matching rules, use that decision. Otherwise fall back to RBAC.
RbacFirstRBAC 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 (uses Mutex for caches), can be shared across threads
  • ComposedPolicyLocal - Single-threaded (uses RefCell for caches), faster but not Send + Sync

Both use the generic ComposedPolicyCore<A, P> type where:

  • A: CacheLock - Lock type for ABAC evaluation cache
  • P: 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:

  • resource attribute → permission namespace
  • action attribute → 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:

  1. ABAC evaluation cache - LRU cache of request → decision mappings
  2. 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

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:

  1. Typed POSIX ACL model – the rwx permission 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.

  2. 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 validated PosixAcl values. 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());
}
  • Permissions – the rwx permission triple, special bits, and the lattice algebra that makes composition well-defined.
  • Building ACLs – tags, entries, the PosixAcl type, fluent construction with AclBuilder, 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.

TraitOperationMeaning
Semigroupcombine (OR)Merge two permission sets
Monoididentity (NONE)Empty permission set
JoinSemilatticejoin (OR)Least upper bound
MeetSemilatticemeet (AND)Greatest lower bound
BoundedJoinSemilatticebottom (NONE)Minimal element
BoundedMeetSemilatticetop (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());
}
Taggetfacl syntaxRequired?
UserObjuser::permsAlways
User(name)user:name:permsMakes ACL extended
GroupObjgroup::permsAlways
Group(name)group:name:permsMakes ACL extended
Maskmask::permsRequired when extended
Otherother::permsAlways

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):

VariantMeaning
MissingEntryA required tag (user::, group::, other::) is absent
DuplicateEntryThe same named entry appears more than once
MissingMaskNamed entries exist but no mask:: entry is present
SpuriousMaskA 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.

See Parsing and Generation.

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:

VariantCause
EmptyInputThe input string is empty or all whitespace
InvalidTagSyntaxA tag name is not user, group, mask, or other
InvalidPermStringA permission field is not a valid rwx string
MissingRequiredEntryA mandatory tag (user::, group::, other::) is absent
DuplicateEntryThe same named entry appears more than once
MissingMaskNamed entries present but no mask::
SpuriousMaskA 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:

  1. Owner – if the user is the file owner, use the user:: permissions. No masking is applied.
  2. Named user – if a user:name: entry matches, return its permissions masked with the mask entry.
  3. Group – collect all matching group:name: entries (by checking the user’s group list) plus the group:: 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.
  4. 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:

FieldTypeMeaning
grantedboolWhether the requested permissions are fully held
matched_tagAclTagWhich entry determined the result
effective_permsPosixPermThe 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:

VariantCause
MissingOwnerbuilder().build() called without .owner()
MissingOwningGroupbuilder().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 the setfacl commands 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 bitAtomicPermission
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:

MountOwnerGroupModePurpose
/srv/data/engineeringsysadmineng-staff0750Source code, builds
/srv/data/legallegal-adminlegal-staff0750Contracts, IP filings
/srv/data/hrhr-adminhr-staff0750Personnel files
/srv/data/reportssysadminstaff0754Business reports

Four contractor teams, each with a different access window:

TeamPOSIX GroupsWindowMounts
Alphaalpha-lead, alpha-devQ1 2026 (Jan–Mar)engineering
Betabeta-teamH1 2026 (Jan–Jun)reports
Gammagamma-teamAudit (Feb 1–Mar 14)legal, hr
Deltadelta-team, delta-lead, delta-engMigration (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:

Dateengineeringlegalhrreports
2026-01-15alpha-lead:rwx, alpha-dev:r-xbeta-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-30beta-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?

DateWindowVerdict
2026-01-15before auditdenied
2026-02-10audit activegranted (r--)
2026-03-10audit activegranted (r--)
2026-03-15audit closeddenied

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

  1. Grants live at the group level. The ACL contains group:name:perm entries, never user:name:perm. Role differentiation (lead vs developer) is expressed as separate POSIX groups, each with its own grant.

  2. Temporal windows are declarative. Each grant carries a TemporalPermissionSet with explicit valid_from / valid_until. No cron jobs, no cleanup scripts — the evaluation function queries the model at a given time and produces the correct ACL.

  3. Per-user access is derived, not stored. PosixAcl::check_access runs 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.

  4. The same model drives setfacl commands. The getfacl text output maps 1:1 to setfacl -m g:group:perm invocations. The periodic review cycle is: evaluate grants at current time → generate ACLs → apply with setfacl.

  5. 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/execute namespace convention that connects POSIX permissions to acls-rs temporal types.
  • Temporal Permissions — how TemporalPermission and TemporalPermissionSet work 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:

  1. Foundation typesSid (Security Identifier with string parsing and well-known constants), AccessMask (32-bit permission bitmask with lattice algebra), Guid (128-bit UUID for Object ACEs).

  2. ACE modelAceType (21 MS-DTYP ACE type variants), AceFlags (inheritance and audit flags), Ace (entry with optional GUIDs for Object ACEs, optional application_data for Callback ACEs, integrity level accessors, and structural validation).

  3. Security descriptorWinAcl (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).

  4. 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.

  5. 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.

  6. 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.

  • 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 PermissionMapping for file/AD/registry semantics, _with variants, 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:

BitsCategoryExamples
0–15Object-specificFILE_READ_DATA, FILE_WRITE_DATA, KEY_QUERY_VALUE
16–20Standard rightsDELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE
24–25SpecialACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED
28–31GenericGENERIC_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

ConstantBits
FILE_GENERIC_READFILE_READ_DATA | FILE_READ_EA | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE
FILE_GENERIC_WRITEFILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE
FILE_GENERIC_EXECUTEFILE_EXECUTE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE
FILE_ALL_ACCESSAll 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:

CategoryTypesSDDLNumeric
BasicAccessAllowed, AccessDeniedA, D0x00, 0x01
AuditSystemAudit, SystemAlarmAU, AL0x02, 0x03
ObjectAccessAllowedObject, AccessDeniedObject, SystemAuditObject, SystemAlarmObjectOA, OD, OU, OL0x05–0x08
CallbackAccessAllowedCallback, AccessDeniedCallback, + Object variantsXA, XD, ZA, ZD, XU, XL, ZU, ZL0x09–0x10
SpecialSystemMandatoryLabel, SystemResourceAttribute, SystemScopedPolicyIdML, RA, SP0x11–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());
}
FlagValueMeaning
OBJECT_INHERIT0x01Non-container children inherit
CONTAINER_INHERIT0x02Container children inherit
NO_PROPAGATE_INHERIT0x04Stop propagation to grandchildren
INHERIT_ONLY0x08Does not apply to this object
INHERITED_ACE0x10Was inherited from parent
SUCCESSFUL_ACCESS0x40Audit on success (SACL)
FAILED_ACCESS0x80Audit 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:

AliasSIDName
WDS-1-1-0Everyone
BAS-1-5-32-544BUILTIN\Administrators
BUS-1-5-32-545BUILTIN\Users
SYS-1-5-18LOCAL SYSTEM
ANS-1-5-7ANONYMOUS LOGON
AUS-1-5-11Authenticated Users
COS-1-3-0Creator Owner
LWS-1-16-4096Low Integrity
MES-1-16-8192Medium Integrity
HIS-1-16-12288High Integrity
SIS-1-16-16384System Integrity

BUILTIN and other well-known SIDs

AliasSIDName
PSS-1-5-10Principal Self
EDS-1-5-9Enterprise DCs
OWS-1-3-4Owner Rights
BOS-1-5-32-551Backup Operators
SOS-1-5-32-549Server Operators
POS-1-5-32-550Print Operators
AOS-1-5-32-548Account Operators
PUS-1-5-32-547Power Users
RES-1-5-32-552Replicator
RUS-1-5-32-554Pre-W2K Compatible
ISS-1-5-32-568IIS_IUSRS
CYS-1-5-32-569Crypto Operators
ERS-1-5-32-573Event Log Readers
HAS-1-5-32-578Hyper-V Admins
AAS-1-5-32-579Access Control Assistance
ACS-1-15-2-1All 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"));
}
AliasRIDName
DA512Domain Admins
DU513Domain Users
DG514Domain Guests
DC515Domain Computers
DD516Domain Controllers
SA518Schema Admins
EA519Enterprise Admins
PA520Policy Admins
RO521Read-Only DCs

Rights aliases

Access mask values use two-letter codes:

CodeValueMeaning
GA0x10000000GENERIC_ALL
GR0x80000000GENERIC_READ
GW0x40000000GENERIC_WRITE
GX0x20000000GENERIC_EXECUTE
FA0x001F01FFFILE_ALL_ACCESS
FR0x00120089FILE_GENERIC_READ
FW0x00120116FILE_GENERIC_WRITE
FX0x001200A0FILE_GENERIC_EXECUTE
KA0x000F003FKEY_ALL_ACCESS
KR0x00020019KEY_READ
KW0x00020006KEY_WRITE
CC0x00000001CREATE_CHILD (AD)
DC0x00000002DELETE_CHILD (AD)
RP0x00000010READ_PROP (AD)
WP0x00000020WRITE_PROP (AD)
CR0x00000100CONTROL_ACCESS (AD)
RC0x00020000READ_CONTROL
SD0x00010000DELETE
WD0x00040000WRITE_DAC
WO0x00080000WRITE_OWNER
NW0x01NO_WRITE_UP (integrity)
NR0x02NO_READ_UP (integrity)
NX0x04NO_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:

  1. No DACL present → grant all
  2. Empty DACL → deny all (except owner implicit READ_CONTROL | WRITE_DAC)
  3. Walk ACEs in order, skip INHERIT_ONLY
  4. Deny ACEs block; allow ACEs accumulate
  5. 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 SystemMandatoryLabel ACE, denies write/read/execute when token integrity is below object integrity.
  • Privilege handling: SeSecurityPrivilege grants ACCESS_SYSTEM_SECURITY; SeTakeOwnershipPrivilege grants 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 satisfied
  • granted_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:

ElementExamples
Attribute references@User.name, @Resource.name, @Device.name, @Local.name
Comparison==, !=, <, <=, >, >=
Set operatorsContains, Any_of
Logical&&, ||, !(...)
ExistenceExists @User.attr
MembershipMember_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
Literalsintegers, "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:

CategoryVariants
Leaf nodesLiteral(ClaimValue), UserAttr, ResourceAttr, DeviceAttr, LocalAttr
ComparisonEquals, NotEquals, LessThan, LessEqual, GreaterThan, GreaterEqual
SetContains, AnyOf
LogicalAnd, Or, Not
ExistenceExists
MembershipMemberOf, DeviceMemberOf, NotMemberOf, NotDeviceMemberOf, MemberOfAny, DeviceMemberOfAny, NotMemberOfAny, NotDeviceMemberOfAny

The ClaimValue enum carries typed data:

VariantRust typeDescription
Int64i6464-bit signed integer
Uint64u6464-bit unsigned integer
StringStringUnicode string
SidSidSecurity identifier
BooleanboolBoolean value
OctetStringVec<u8>Raw byte string
CompositeVec<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, Unknown means “not granted.” For deny ACEs, Unknown means “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:

MethodPurpose
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:

VariantMeaning
InvalidSignatureData doesn’t start with artx
UnexpectedEndTruncated data
UnknownToken(u8)Unrecognized token byte
StackUnderflowMalformed 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_INHERIT are inherited. ACEs with only OBJECT_INHERIT pass through as INHERIT_ONLY.
  • Non-containers: ACEs with OBJECT_INHERIT are inherited, with all inheritance flags cleared.
  • NO_PROPAGATE_INHERIT stops further propagation.
  • INHERIT_ONLY on 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:

BitFileActive DirectoryRegistry
0x0001file_read_datacreate_childquery_value
0x0010file_write_earead_propnotify
0x0020file_executewrite_propcreate_link
0x0100file_write_attributescontrol_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")

BitAction
0x00010000delete
0x00020000read_control
0x00040000write_dac
0x00080000write_owner
0x00100000synchronize
0x0001file_read_data
0x0002file_write_data
0x0004file_append_data
0x0008file_read_ea
0x0010file_write_ea
0x0020file_execute
0x0040file_delete_child
0x0080file_read_attributes
0x0100file_write_attributes

AD mapping (ad_mapping(), namespace "ad")

BitActionMS-DTYP name
0x0001create_childADS_RIGHT_DS_CREATE_CHILD
0x0002delete_childADS_RIGHT_DS_DELETE_CHILD
0x0004list_childrenADS_RIGHT_ACTRL_DS_LIST
0x0008self_writeADS_RIGHT_DS_SELF
0x0010read_propADS_RIGHT_DS_READ_PROP
0x0020write_propADS_RIGHT_DS_WRITE_PROP
0x0040delete_treeADS_RIGHT_DS_DELETE_TREE
0x0080list_objectADS_RIGHT_DS_LIST_OBJECT
0x0100control_accessADS_RIGHT_DS_CONTROL_ACCESS

Plus the 5 standard rights shared with file mapping.

Registry mapping (registry_mapping(), namespace "reg")

BitAction
0x0001query_value
0x0002set_value
0x0004create_sub_key
0x0008enumerate_sub_keys
0x0010notify
0x0020create_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:

AspectNTPOSIX
Permission granularity32-bit mask (14+ distinct rights)3-bit rwx
Deny supportExplicit deny ACEsNone
InheritancePer-ACE flags (CI, OI, NP, IO)Default ACL only
AuditSACL with audit ACEsNone
IntegrityMandatory integrity labelsNone
Principal identifierSID (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 → read
  • FILE_WRITE_DATA | FILE_APPEND_DATA → write
  • FILE_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

ApproachFidelityStorageUse case
DirectLossyNoneQuick mapping, POSIX-only consumers
Out-of-bandFull SDxattr/sidecarSamba, dual-protocol file servers
PermissionSetPer-ACEIn-memoryApplication-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

CrateRole
ldif-parserParse LDIF schema files into LdapEntry structures
win-sdParse SDDL, evaluate access checks, hierarchical object type list
ldap-acisBuild ACIs, evaluate LDAP authorization, generate 389DS ACI text
acls-rsPermissionSet 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 codeMask bitMeaning
RP0x0010Read property
WP0x0020Write property
CC0x0001Create child
DC0x0002Delete child
LC0x0004List children
LO0x0080List object
CR0x0100Control 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 conceptLDAP ACI concept
SID alias (DA, SY, AU)Bind rule (groupdn, userdn, userdn = "ldap:///all")
Allow/Deny ACE typeallow/deny
READ_PROP, LIST_CHILDRENread, search
WRITE_PROPwrite (modify)
CREATE_CHILDadd
DELETE_CHILD, DELETEdelete

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::GroupDn via memberof on 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 namespace
  • action() → 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 grant
  • attributes (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 deny
  • attributes (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; returns True if 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 in self that are not in other.

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 for duration_ms milliseconds.
  • TemporalPermission.valid_from(perm, from_ts: int) — active from from_ts onwards.
  • TemporalPermission.valid_until(perm, deadline: int) — active until deadline.

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 for perm; returns True if 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 to ps and 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 identifier
  • grants (list[AtomicPermission]): Permissions this role grants
  • denials (list[AtomicPermission]): Permissions this role explicitly denies

Methods:

  • with_parent(parent_name: str) -> Role: Declare a parent role for inheritance (returns self).

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

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 user
  • user_group(group: str) -> HbacRuleBuilder: Add a user group
  • user_category_all() -> HbacRuleBuilder: Match all users

Host Dimension

All methods return HbacRuleBuilder for fluent chaining:

  • host(host: str) -> HbacRuleBuilder: Add a specific host
  • host_group(group: str) -> HbacRuleBuilder: Add a host group
  • host_category_all() -> HbacRuleBuilder: Match all hosts

Service Dimension

All methods return HbacRuleBuilder for fluent chaining:

  • service(service: str) -> HbacRuleBuilder: Add a specific service
  • service_group(group: str) -> HbacRuleBuilder: Add a service group
  • service_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 access
  • targethost: The target host being accessed
  • service: 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 request
  • timestamp (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 request
  • timestamp (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 membership
  • service_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 underlying HbacRule
  • valid_from: Millisecond timestamp at which the rule becomes active (inclusive). None means active from the beginning of time.
  • valid_until: Millisecond timestamp at which the rule expires (exclusive). None means 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 for duration_ms milliseconds 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:

ModeBehaviour
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

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:

  • resource attribute → permission namespace
  • action attribute → 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

  1. Register before adding rules: Matchers must be registered before adding rules that use them
  2. Performance: Custom matchers are slower than exact matching and disable Bloom filter optimization
  3. Error handling: Matcher exceptions are caught and treated as non-matches (deny by default)
  4. Thread safety: Matchers must be thread-safe if using AbacPolicyThreadSafe
  5. 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 self for 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

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), not PosixPerm objects. Use PosixPerm.RWX.bits to get the integer value. This avoids wrapping overhead on hot paths.
  • Special bits use booleans: special_bits(suid, sgid, sticky) takes three bool arguments instead of a SpecialBits object.
  • Builder pattern: all builder methods return self for fluent chaining, same as Rust.
  • Errors are ValueError: all error conditions (parse failures, invalid ACL structure, policy errors, out-of-range permission bits) raise ValueError with 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 bit
  • write: set the write bit
  • execute: 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 set
  • write (bool): whether the write bit is set
  • execute (bool): whether the execute bit is set
  • bits (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): True if not NONE
  • p1 == p2: equality comparison
  • hash(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 bit
  • sgid: set the set-group-ID bit
  • sticky: set the sticky bit

Class attributes: SpecialBits.NONE

Properties (read-only):

  • suid (bool)
  • sgid (bool)
  • sticky (bool)

Operators:

  • bool(s): True if 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 held
  • matched_tag (str): which ACL entry determined the result
  • effective_perms (PosixPerm): actual permissions after masking

Operators:

  • bool(result): returns granted (so if 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 name
  • owning_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 entries
  • default_entries (list[AclEntry]): default (inherited) ACL entries
  • mask (PosixPerm): the effective-rights mask
  • is_extended (bool): True if 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 name
  • groups: the user’s group memberships
  • required_bits: permission bits to check (use PosixPerm.R.bits etc.)

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 name
  • owning_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) -> AclBuilder
  • owning_group_perms(bits: int) -> AclBuilder
  • other_perms(bits: int) -> AclBuilder
  • special_bits(suid: bool, sgid: bool, sticky: bool) -> AclBuilder
  • add_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) -> AclBuilder
  • default_owning_group_perms(bits: int) -> AclBuilder
  • default_other_perms(bits: int) -> AclBuilder
  • default_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 name
  • groups: 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 groups
  • bool(model): True if 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) -> AclPolicyBuilder
  • owning_group(group: str) -> AclPolicyBuilder
  • owner_perms(bits: int) -> AclPolicyBuilder
  • owning_group_perms(bits: int) -> AclPolicyBuilder
  • other_perms(bits: int) -> AclPolicyBuilder
  • add_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) -> AclPolicyBuilder
  • default_owning_group_perms(bits: int) -> AclPolicyBuilder
  • default_other_perms(bits: int) -> AclPolicyBuilder
  • default_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 owner and owning_group are 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

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:

ElementExamples
Attribute references@User.name, @Resource.name, @Device.name, @Local.name
Comparison==, !=, <, <=, >, >=
Set operatorsContains, Any_of
Logical&&, ||, !(...)
ExistenceExists @User.attr
MembershipMember_of{SID(BA)}, Not_Member_of{...}, Member_of_Any{...}, Device_Member_of{...} and 4 more variants
Literalsintegers, "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:

KeyValue typePurpose
"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"
MappingNamespaceBit 0x0010 meansBit 0x0020 means
file()"win"file_write_eafile_execute
ad()"ad"read_propwrite_prop
registry()"reg"notifycreate_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 (HbacPolicyLocal backend, not HbacPolicy)
  • 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:

  1. Can the system handle your request volume at acceptable latency?
  2. Does enabling caching, JIT, or host filtering actually improve your workload?
  3. 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-perf command.

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:

Presetuser=allhost=allsvc=alldeny %Groups
sssd-prod15%30%40%5%dense (avg 10)
dev40%50%60%1%sparse (avg 3)
high-security5%10%15%20%very specific

Choose the preset that most closely matches your production rule distribution.

Benchmark scenarios

ScenarioWhat it measures
single-latencyRequest latency with warm cache (10K matching requests)
uncached-latencyRaw evaluation without cache benefit (10K unique requests)
throughputSustained load with 80/20 match/non-match mix
build-timePolicy load and index construction time
allRun all of the above

Output formats

  • terminal (default) – human-readable tables.
  • json – machine-readable, suitable for bac-perf compare.
  • csv – spreadsheet-compatible.
  • markdown – documentation-ready tables.

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:

AspectHBACABAC
DimensionsFixed: user, host, serviceMapped: user, resource, action
Entity poolsIncluded in metadataNot included (generic)
Benchmark compatibilityHBAC onlyBoth HBAC and ABAC

ABAC fixtures use the same distributions but map them to ABAC’s generic dimension model:

  • user dimension (same as HBAC)
  • resource dimension (equivalent to HBAC’s targethost)
  • action dimension (equivalent to HBAC’s service)

This mapping preserves statistical properties while allowing ABAC benchmarks.

Entity pools

Generated rules draw from pools of synthetic entities:

EntityDefault count
Users500
User groups50
Hosts200
Host groups20
Services30
Service groups5

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

FormatUse case
terminalInteractive review (default)
jsonInput to bac-perf compare, CI pipelines
csvSpreadsheet analysis
markdownDocumentation 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 DimensionABAC DimensionExample
useruseralice, group:admins
targethostresourceweb01.example.com, group:prod_servers
serviceactionsshd, 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:

AspectHBACABAC
DimensionsFixed (user, host, service)Generic (N dimensions)
Index structureDecision tree + bitmap deny indexBitmap deny index + compiled eval
Attribute accessDirect struct fieldsPre-extracted array
Rule storageArc<str> + Box<[Arc<str>]>Generic AttributeType sets
Deny indexAHashMap<Arc<str>, Vec<BitPos>>AHashMap<AttributeType, Vec<BitPos>>
Memory @ 100K24.0 MB (0.25 KB/rule)7.6 MB (0.078 KB/rule)
Memory @ 1M240.7 MB76 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

  1. Same seed – Always use --seed 42 for reproducible fixtures.
  2. Same scenarios – Run the same benchmark scenarios for both implementations.
  3. Multiple runs – Repeat benchmarks 3-5 times and average results to reduce variance.
  4. Release mode – Always benchmark in --release mode.
  5. Cache consistency – Use --cache for both or neither (not one).

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]
OptionDescriptionDefault
--bac-type <TYPE>BAC type: hbac or abachbac
--count <N>Number of rules to generaterequired
--output <NAME>Output fixture namerequired
--distribution <PRESET>Distribution preset: sssd-prod, dev, high-securitysssd-prod
--seed <N>Random seed for reproducibility42
--fixtures-dir <PATH>Fixtures directoryfixtures
# 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]
OptionDescriptionDefault
--bac-type <TYPE>BAC type: hbac or abachbac
--fixture <NAME>Fixture to benchmarkrequired
--scenario <NAME>Benchmark scenarioall
--cacheEnable cachingdisabled
--jitEnable JIT compilation (HBAC only, requires --features jit build)disabled
--format <FORMAT>Output format: terminal, json, csv, markdownterminal
--output <PATH>Save results to filestdout
--fixtures-dir <PATH>Fixtures directoryfixtures

Scenarios:

ScenarioDescription
single-latencyRequest latency with warm cache (10K matching requests)
uncached-latencyRaw evaluation without cache benefit (10K unique requests)
throughputSustained load with 80/20 match/non-match mix
build-timePolicy load and index construction time
allRun 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]
OptionDescriptionDefault
--verboseShow rule count, size, and generation datesummary only
--fixtures-dir <PATH>Fixtures directoryfixtures
bac-perf list --verbose

compare

Compare two benchmark runs to detect regressions.

bac-perf compare <BASELINE> <CURRENT> [OPTIONS]
ArgumentDescription
<BASELINE>Path to baseline JSON results
<CURRENT>Path to current JSON results
OptionDescriptionDefault
--format <FORMAT>Output format: terminal, markdownterminal
bac-perf compare baseline.json current.json

validate

Check fixture integrity (schema version, JSON structure, rule count consistency).

bac-perf validate <FIXTURE> [OPTIONS]
ArgumentDescription
<FIXTURE>Fixture name to validate
OptionDescriptionDefault
--fixtures-dir <PATH>Fixtures directoryfixtures
bac-perf validate prod_1k

Environment variables

VariableDescription
BAC_PERF_FIXTURES_DIROverride the default fixtures directory
BAC_PERF_OUTPUT_FORMATOverride the default output format
export BAC_PERF_FIXTURES_DIR=/var/lib/bac-perf/fixtures
bac-perf list

Exit codes

CodeMeaning
0Success
1General error (invalid arguments, file not found)
2Validation error (fixture is invalid)
3Benchmark 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:

ScenarioRules in CacheApproximate Memory
No filtering (10K total rules)10,000megabytes
HostFilter (100 applicable)100tens 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

StrategyMemoryLookup SpeedUse Case
No cacheNoneO(n) per requestStateless gateways
No filterHighSub-microsecondSmall deployments (<1K rules)
HostFilterLowSub-microsecondSSSD on enrolled hosts
GlobalOnly filterMinimalSub-microsecondRestricted 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

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)

RulesThroughputMeanP95P99Memory
1007.85M r/s127 ns208 ns292 ns34 KB
1,0005.49M r/s182 ns292 ns375 ns334 KB
10,0004.07M r/s245 ns375 ns417 ns3.3 MB
20,0003.36M r/s298 ns459 ns542 ns6.6 MB
50,0001.68M r/s596 ns1.00 µs1.21 µs16.5 MB
100,000947K r/s1.06 µs1.83 µs2.21 µs33 MB
250,000428K r/s2.34 µs4.17 µs5.00 µs82.5 MB
500,000215K r/s4.64 µs8.33 µs9.96 µs165 MB
1,000,000109K r/s9.14 µs16.5 µs19.8 µs330 MB

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99Memory
10016.0M r/s62 ns71 ns77 ns34 KB
1,00015.1M r/s66 ns75 ns82 ns334 KB
10,00013.9M r/s72 ns81 ns88 ns3.2 MB
20,00013.9M r/s72 ns81 ns87 ns6.4 MB
50,00014.1M r/s71 ns81 ns86 ns16.1 MB
100,00011.5M r/s87 ns99 ns106 ns32.3 MB
250,00011.7M r/s86 ns99 ns106 ns80.7 MB
500,0007.74M r/s129 ns151 ns166 ns161.5 MB
1,000,00011.3M r/s88 ns102 ns109 ns323.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)

RulesThroughputMeanP95P99
10019.2M r/s52 ns60 ns66 ns
1,00016.8M r/s60 ns70 ns78 ns
10,00015.9M r/s63 ns73 ns78 ns
20,00016.6M r/s60 ns70 ns75 ns
50,00013.8M r/s73 ns86 ns91 ns
100,00013.2M r/s76 ns88 ns95 ns
250,00013.6M r/s74 ns84 ns90 ns
500,00013.0M r/s77 ns90 ns98 ns
1,000,00012.7M r/s79 ns92 ns101 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)

RulesThroughputMeanP95P99
10011.0M r/s91 ns166 ns167 ns
1,0008.15M r/s123 ns167 ns208 ns
10,0006.40M r/s156 ns125 ns167 ns
20,0003.88M r/s258 ns166 ns167 ns
50,0002.03M r/s492 ns125 ns167 ns
100,0001.06M r/s941 ns167 ns209 ns
250,000449K r/s2.23 µs250 ns292 ns
500,000223K r/s4.48 µs375 ns417 ns
1,000,000106K r/s9.42 µs625 ns708 ns

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
10011.2M r/s89 ns118 ns174 ns
1,0006.50M r/s154 ns195 ns237 ns
10,0002.50M r/s400 ns302 ns379 ns
20,0001.27M r/s787 ns374 ns463 ns
50,000373K r/s2.68 µs908 ns1.21 µs
100,000249K r/s4.02 µs684 ns828 ns
250,00090K r/s11.1 µs786 ns946 ns
500,00047K r/s21.3 µs971 ns1.15 µs
1,000,00023K r/s43.7 µs1.25 µs1.44 µs

Mixed Workload (Throughput)

80% matching + 20% non-matching, cache enabled, 1,000 warmup.

ARM64 (Apple M4)

RulesThroughputMeanP95P99
1009.26M r/s108 ns167 ns250 ns
1,0006.37M r/s157 ns250 ns292 ns
10,0005.55M r/s180 ns292 ns334 ns
20,0003.65M r/s274 ns459 ns542 ns
50,0001.91M r/s523 ns1.00 µs1.21 µs
100,0001.05M r/s955 ns1.96 µs2.33 µs
250,000485K r/s2.06 µs4.33 µs5.21 µs
500,000249K r/s4.02 µs8.46 µs10.2 µs
1,000,000129K r/s7.74 µs16.4 µs19.7 µs

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
10015.6M r/s64 ns73 ns78 ns
1,00014.8M r/s68 ns79 ns86 ns
10,00011.3M r/s88 ns101 ns111 ns
20,00011.7M r/s86 ns103 ns112 ns
50,00011.0M r/s91 ns108 ns122 ns
100,0009.58M r/s104 ns125 ns140 ns
250,0009.31M r/s107 ns130 ns189 ns
500,0009.87M r/s101 ns120 ns143 ns
1,000,0009.17M r/s109 ns131 ns176 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)

RulesThroughputMeanP95P99Memory
1001.48M r/s0.67µs1.46µs2.17µs7.8 KB
1,0003.19M r/s0.31µs0.42µs0.50µs78 KB
10,0003.73M r/s0.27µs0.38µs0.46µs781 KB
20,0003.06M r/s0.33µs0.50µs0.62µs1.6 MB
50,0001.70M r/s0.59µs1.04µs1.29µs3.9 MB
100,000969K r/s1.03µs1.92µs2.42µs7.8 MB
250,000435K r/s2.30µs4.38µs5.38µs19.5 MB
500,000223K r/s4.48µs8.67µs10.9µs39 MB
1,000,000113K r/s8.84µs17.2µs21.3µs78 MB

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99Memory
1002.22M r/s450 ns939 ns1.14 µs7.8 KB
1,0005.69M r/s176 ns227 ns247 ns78 KB
10,0003.97M r/s252 ns348 ns398 ns781 KB
20,0002.88M r/s348 ns559 ns710 ns1.5 MB
50,0001.67M r/s598 ns974 ns1.19 µs3.8 MB
100,000949K r/s1.05 µs1.73 µs2.15 µs7.6 MB
250,000546K r/s1.83 µs3.18 µs4.24 µs19.1 MB
500,000289K r/s3.46 µs6.16 µs9.22 µs38.1 MB
1,000,000143K r/s7.01 µs12.9 µs15.4 µs76.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)

RulesThroughputMeanP95P99
100930K r/s1.08µs1.21µs1.33µs
1,0003.78M r/s0.26µs0.21µs0.25µs
10,0001.40M r/s0.72µs0.17µs0.21µs
20,000671K r/s1.49µs0.17µs0.25µs
50,000269K r/s3.72µs0.17µs0.25µs
100,000161K r/s6.19µs0.21µs0.25µs
250,00057.8K r/s17.3µs0.21µs0.25µs
500,00027.8K r/s36.0µs0.33µs0.38µs
1,000,00013.3K r/s75.1µs0.50µs0.54µs

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
100786K r/s1.27 µs1.40 µs1.64 µs
1,0003.92M r/s255 ns235 ns292 ns
10,000836K r/s1.20 µs596 ns739 ns
20,000346K r/s2.89 µs785 ns966 ns
50,000160K r/s6.26 µs865 ns1.00 µs
100,00080K r/s12.5 µs944 ns1.10 µs
250,00025K r/s39.3 µs1.68 µs1.89 µs
500,00016K r/s61.7 µs1.25 µs1.39 µs
1,000,0007.6K r/s131 µs1.71 µs1.88 µs

Mixed Workload (Throughput)

80% matching + 20% non-matching, cache enabled, 1,000 warmup.

ARM64 (Apple M4)

RulesThroughputMeanP95P99
1001.93M r/s518 ns1.00 µs1.17 µs
1,0005.21M r/s192 ns250 ns292 ns
10,0004.18M r/s239 ns375 ns417 ns
20,0003.20M r/s313 ns542 ns625 ns
50,0001.88M r/s531 ns1.04 µs1.29 µs
100,0001.09M r/s919 ns1.96 µs2.46 µs
250,000500K r/s2.00 µs4.50 µs5.67 µs
500,000259K r/s3.86 µs8.79 µs11.0 µs
1,000,000131K r/s7.61 µs17.6 µs22.2 µs

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
1001.58M r/s631 ns1.31 µs1.43 µs
1,0005.98M r/s167 ns218 ns246 ns
10,0003.48M r/s287 ns427 ns520 ns
20,0002.85M r/s351 ns537 ns626 ns
50,0001.55M r/s646 ns1.13 µs1.36 µs
100,000718K r/s1.39 µs2.62 µs3.16 µs
250,000524K r/s1.91 µs3.87 µs4.83 µs
500,000348K r/s2.87 µs6.33 µs7.88 µs
1,000,000192K r/s5.20 µs11.2 µs14.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)

RulesThroughputMeanP95P99
10016.5M r/s60 ns66 ns76 ns
1,00014.3M r/s70 ns76 ns86 ns
10,00014.5M r/s69 ns75 ns86 ns
20,00014.4M r/s69 ns77 ns85 ns
50,00015.1M r/s66 ns74 ns81 ns
100,00015.0M r/s66 ns75 ns83 ns
250,00015.2M r/s66 ns71 ns81 ns
500,00016.0M r/s62 ns70 ns75 ns
1,000,00013.4M r/s75 ns83 ns93 ns

check_access (LRU-warm)

check_access() returns bool instead of AuthorizationResult, avoiding the PermissionSet allocation overhead.

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
10021.1M r/s47 ns53 ns58 ns
1,00018.0M r/s55 ns61 ns65 ns
10,00018.5M r/s54 ns61 ns64 ns
20,00018.3M r/s55 ns61 ns65 ns
50,00019.7M r/s51 ns58 ns63 ns
100,00019.1M r/s52 ns61 ns67 ns
250,00019.5M r/s51 ns58 ns63 ns
500,00017.3M r/s58 ns66 ns69 ns
1,000,00016.5M r/s61 ns68 ns72 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)

RulesThroughputMeanP95P99
1001.7M r/s598 ns734 ns833 ns
1,000667K r/s1.50 µs1.68 µs1.78 µs
10,000205K r/s4.89 µs5.53 µs9.37 µs
20,000105K r/s9.53 µs10.91 µs15.46 µs
50,00040K r/s25.2 µs28.4 µs32.6 µs
100,00015K r/s65.2 µs89.4 µs104 µs
250,0005K r/s221 µs280 µs299 µs
500,0002K r/s587 µs714 µs832 µs
1,000,0001K r/s1.50 ms1.86 ms1.98 ms

Mixed Workload (Throughput)

80% matching + 20% non-matching, cache enabled, 1,000 warmup.

x86_64 (Intel i7-12800H)

RulesThroughputMeanP95P99
10015.1M r/s66 ns73 ns85 ns
1,00014.1M r/s71 ns81 ns92 ns
10,00012.7M r/s79 ns90 ns99 ns
20,00011.3M r/s89 ns102 ns114 ns
50,00011.5M r/s87 ns102 ns168 ns
100,00012.7M r/s78 ns89 ns104 ns
250,00012.3M r/s81 ns90 ns154 ns
500,00012.1M r/s83 ns96 ns174 ns
1,000,00013.6M r/s74 ns86 ns157 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

RulesMean (ns)P95 (ns)P99 (ns)Throughput (req/s)
10044838422.6M
1,00038424226.4M
10,00023428443.1M
100,000214212548.7M

Throughput (mixed matching/non-matching)

RulesMean (ns)P95 (ns)Throughput (req/s)
100598417.1M
1,000394225.4M
10,000234242.9M
100,000214247.9M

Uncached Latency (diverse non-matching)

RulesMean (ns)P95 (ns)Throughput (req/s)
100498320.5M
1,000364227.5M
10,000224245.3M
100,000214247.5M

Policy Build Time and Memory

RulesBuild (ms)Memory (KB)Per rule (B)
1000.0413.3136
1,0000.20132.8136
10,0001.091328.1136
100,0009.4013281.3136

Cross-engine comparison (check_access, 10K rules)

EngineMean (ns)P95 (ns)Throughput (req/s)
win-sd234243.1M
posix-acl294234.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)

RulesBuild TimePer 1K Rules
1000.01 ms0.12 ms
1,0000.09 ms0.09 ms
10,0000.71 ms0.07 ms
20,0001.52 ms0.08 ms
50,0003.81 ms0.08 ms
100,0008.02 ms0.08 ms
250,00020.8 ms0.08 ms
500,00040.9 ms0.08 ms
1,000,00087.6 ms0.09 ms

x86_64 (Intel i7-12800H)

RulesBuild TimePer 1K Rules
1000.04 ms0.38 ms
1,0000.13 ms0.13 ms
10,0001.57 ms0.16 ms
20,0005.57 ms0.28 ms
50,00019.4 ms0.39 ms
100,00041.7 ms0.42 ms
250,000106 ms0.43 ms
500,000202 ms0.40 ms
1,000,000424 ms0.42 ms

ABAC Build Time

ARM64 (Apple M4)

RulesBuild TimePer 1K Rules
1000.15 ms1.50 ms
1,0000.83 ms0.83 ms
10,0006.19 ms0.62 ms
20,00013.4 ms0.67 ms
50,00033.4 ms0.67 ms
100,00060.7 ms0.61 ms
250,000167 ms0.67 ms
500,000354 ms0.71 ms
1,000,000798 ms0.80 ms

x86_64 (Intel i7-12800H)

RulesBuild TimePer 1K Rules
1000.11 ms1.10 ms
1,0000.47 ms0.47 ms
10,0008.79 ms0.88 ms
20,00019.8 ms0.99 ms
50,00056.6 ms1.13 ms
100,000122 ms1.22 ms
250,000317 ms1.27 ms
500,000611 ms1.22 ms
1,000,0001,175 ms1.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)

RulesBuild TimePer 1K Rules
1000.26 ms2.58 ms
1,0001.76 ms1.76 ms
10,00020.1 ms2.01 ms
20,00038.3 ms1.92 ms
50,000104 ms2.07 ms
100,000199 ms1.99 ms
250,000530 ms2.12 ms
500,0001.04 s2.09 ms
1,000,0002.1 s2.06 ms

Memory

HBAC Memory Usage

ARM64 (Apple M4)

RulesTotalPer-Rule
10034 KB0.35 KB
1,000334 KB0.34 KB
10,0003.3 MB0.34 KB
20,0006.6 MB0.34 KB
50,00016.5 MB0.34 KB
100,00033 MB0.34 KB
250,00082.5 MB0.34 KB
500,000165 MB0.34 KB
1,000,000330 MB0.34 KB

x86_64 (Intel i7-12800H)

RulesTotalPer-Rule
10034 KB0.34 KB
1,000334 KB0.34 KB
10,0003.2 MB0.33 KB
20,0006.4 MB0.33 KB
50,00016.1 MB0.33 KB
100,00032.3 MB0.33 KB
250,00080.7 MB0.33 KB
500,000161.5 MB0.33 KB
1,000,000323.1 MB0.33 KB

ABAC Memory Usage

ARM64 (Apple M4)

RulesTotalPer-Rule
1007.8 KB0.080 KB
1,00078 KB0.080 KB
10,000781 KB0.080 KB
20,0001.6 MB0.080 KB
50,0003.9 MB0.080 KB
100,0007.8 MB0.080 KB
250,00019.5 MB0.080 KB
500,00039 MB0.080 KB
1,000,00078 MB0.080 KB

x86_64 (Intel i7-12800H)

RulesTotalPer-Rule
1007.8 KB0.078 KB
1,00078 KB0.078 KB
10,000781 KB0.078 KB
20,0001.5 MB0.078 KB
50,0003.8 MB0.078 KB
100,0007.6 MB0.078 KB
250,00019 MB0.078 KB
500,00038 MB0.078 KB
1,000,00076 MB0.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)

RulesHBACABACLDAP ACIFastest
10015.6M r/s1.58M r/s15.1M r/sHBAC
1,00014.8M r/s5.98M r/s14.1M r/sHBAC
10,00011.3M r/s3.48M r/s12.7M r/sLDAP ACI
20,00011.7M r/s2.85M r/s11.3M r/sHBAC
50,00011.0M r/s1.55M r/s11.5M r/sLDAP ACI
100,0009.58M r/s718K r/s12.7M r/sLDAP ACI
250,0009.31M r/s524K r/s12.3M r/sLDAP ACI
500,0009.87M r/s348K r/s12.1M r/sLDAP ACI
1,000,0009.17M r/s192K r/s13.6M r/sLDAP 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:

  1. Constant-result fast path – single outcome (~15 ns)
  2. Bitmap deny index – u64 bitmask intersection when universal allow exists
  3. LRU cache – memoization (sub-microsecond, for non-indexed fallback)
  4. AHash – 2-3× faster than SipHash for non-cryptographic use
  5. Compiled evaluator – pre-extracted attributes + array indexing
  6. Composite index – candidate selection fallback (O(log n))
  7. 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.

WorkloadEffect
CachedNo 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.
BuildNo 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

ScenarioRequestsWarmupDistribution
single-latency10,0001,000All matching (pool: 200)
check-access-latency10,0001,000All matching (pool: 200, bool)
uncached-latency10,0000All non-matching (pool: 5,000)
throughput10,0001,00080/20 mixed (pool: 500)
build-time10Matching

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 full Decision/HbacEvaluationResult)
  • check_access+json (HBAC only): loads rules via load_rules_json(), evaluates with check_access() (returns bool, avoids Vec<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)

RulesThroughputMeanP95P99
1006.43M r/s155 ns162 ns167 ns
1,0006.39M r/s157 ns161 ns165 ns
10,0006.26M r/s160 ns167 ns168 ns
20,0006.53M r/s153 ns161 ns165 ns
50,0006.46M r/s155 ns159 ns160 ns
100,0006.53M r/s153 ns156 ns163 ns
250,0006.38M r/s157 ns166 ns170 ns
500,0006.38M r/s157 ns161 ns164 ns
1,000,0006.21M r/s161 ns168 ns169 ns

Uncached (~80% cache misses, non-matching)

RulesThroughputMeanP95P99
1005.85M r/s171 ns183 ns186 ns
1,0004.39M r/s228 ns239 ns240 ns
10,0004.69M r/s213 ns227 ns233 ns
20,0004.18M r/s239 ns243 ns251 ns
50,0004.15M r/s241 ns246 ns249 ns
100,0003.65M r/s274 ns284 ns289 ns
250,0002.88M r/s347 ns366 ns371 ns
500,0002.02M r/s496 ns507 ns519 ns
1,000,0001.37M r/s731 ns749 ns754 ns

Mixed Workload (80% matching / 20% non-matching)

RulesThroughputMean
1006.47M r/s154 ns
1,0006.18M r/s162 ns
10,0006.46M r/s155 ns
20,0006.35M r/s157 ns
50,0006.32M r/s158 ns
100,0006.39M r/s156 ns
250,0006.32M r/s158 ns
500,0006.23M r/s161 ns
1,000,0006.01M r/s166 ns

Policy Build Time

RulesRule objects (py)load_rules() (rs)Total
1000.7 ms0.0 ms0.7 ms
1,0002.2 ms0.1 ms2.3 ms
10,00020.1 ms1.6 ms21.7 ms
20,00036.8 ms3.0 ms39.8 ms
50,00099.3 ms8.1 ms107.4 ms
100,000186.8 ms15.0 ms201.8 ms
250,000475.3 ms36.3 ms511.5 ms
500,000981.2 ms70.0 ms1,051 ms
1,000,0001,938 ms142.4 ms2,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)

RulesThroughputMeanP95P99
1007.93M r/s126 ns131 ns134 ns
1,0008.25M r/s121 ns126 ns127 ns
10,0008.25M r/s121 ns126 ns128 ns
20,0008.15M r/s123 ns126 ns128 ns
50,0008.16M r/s123 ns126 ns127 ns
100,0008.14M r/s123 ns127 ns131 ns
250,0007.71M r/s130 ns138 ns159 ns
500,0007.68M r/s130 ns136 ns140 ns
1,000,0008.15M r/s123 ns127 ns130 ns

Uncached (~80% cache misses, non-matching)

RulesThroughputMeanP95P99
1006.46M r/s155 ns165 ns175 ns
1,0005.41M r/s185 ns203 ns207 ns
10,0005.53M r/s181 ns189 ns195 ns
20,0005.25M r/s191 ns196 ns205 ns
50,0004.87M r/s205 ns211 ns212 ns
100,0004.10M r/s244 ns258 ns261 ns
250,0003.07M r/s326 ns349 ns360 ns
500,0002.21M r/s452 ns471 ns480 ns
1,000,0001.35M r/s739 ns820 ns859 ns

Mixed Workload (80% matching / 20% non-matching)

RulesThroughputMean
1007.54M r/s133 ns
1,0007.75M r/s129 ns
10,0008.09M r/s124 ns
20,0008.10M r/s123 ns
50,0007.93M r/s126 ns
100,0007.77M r/s129 ns
250,0007.46M r/s134 ns
500,0007.82M r/s128 ns
1,000,0007.62M r/s131 ns

Policy Build Time (JSON)

Rulesload_rules_json() (rs)Speedup vs builder
1000.6 ms1.2×
1,0001.2 ms1.9×
10,0006.9 ms3.1×
20,00012.8 ms3.1×
50,00032.0 ms3.4×
100,00063.2 ms3.2×
250,000160.6 ms3.2×
500,000313.9 ms3.3×
1,000,000617.4 ms3.4×

Python — ABAC (evaluate+builder)

Cached (LRU-warm, matching requests)

RulesThroughputMeanP95P99
1001.66M r/s603 ns621 ns626 ns
1,0003.12M r/s320 ns330 ns334 ns
10,0002.44M r/s410 ns422 ns426 ns
20,0002.22M r/s451 ns467 ns478 ns
50,0001.54M r/s649 ns683 ns723 ns
100,0001.02M r/s982 ns1.04 µs1.14 µs
250,000524K r/s1.91 µs2.05 µs2.07 µs
500,000278K r/s3.60 µs3.69 µs3.78 µs
1,000,000159K r/s6.29 µs7.13 µs7.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)

RulesThroughputMeanP95P99
100649K r/s1.54 µs1.66 µs1.69 µs
1,0003.41M r/s293 ns310 ns316 ns
10,0003.35M r/s299 ns316 ns322 ns
20,0003.46M r/s289 ns298 ns304 ns
50,0003.32M r/s301 ns310 ns316 ns
100,0003.13M r/s319 ns328 ns336 ns
250,0002.56M r/s390 ns404 ns420 ns
500,0001.94M r/s515 ns534 ns562 ns
1,000,0001.55M r/s645 ns671 ns691 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)

RulesThroughputMean
1001.12M r/s896 ns
1,0003.17M r/s315 ns
10,0002.57M r/s389 ns
20,0002.28M r/s439 ns
50,0001.66M r/s603 ns
100,0001.20M r/s835 ns
250,000633K r/s1.58 µs
500,000337K r/s2.97 µs
1,000,000198K r/s5.06 µs

Policy Build Time

RulesRule objects (py)load_rules() (rs)Total
1000.7 ms0.1 ms0.7 ms
1,0003.3 ms0.7 ms3.9 ms
10,00030.5 ms7.5 ms38.0 ms
20,00059.8 ms15.0 ms74.8 ms
50,000148.1 ms34.0 ms182.1 ms
100,000304.1 ms68.1 ms372.2 ms
250,000788.5 ms183.2 ms971.7 ms
500,0001,564 ms370.3 ms1,935 ms
1,000,0003,123 ms769.5 ms3,892 ms

Using load_rules_json() instead of the builder pattern is 2.6–3.1× faster for build time (same eval performance):

RulesBuilder TotalJSON TotalSpeedup
1,0003.9 ms1.5 ms2.6×
10,00038.0 ms13.3 ms2.9×
100,000372.2 ms121.3 ms3.1×
1,000,0003,892 ms1,354 ms2.9×

Python vs Rust — Summary (x86_64)

MetricRust (x86_64)Python (x86_64)Ratio
HBAC evaluate (cached, 1K)66 ns157 nsRust 2.4× faster
HBAC check_access (cached, 1K)60 ns121 nsRust 2.0× faster
HBAC evaluate (cached, 100K)87 ns153 nsRust 1.8× faster
HBAC check_access (cached, 100K)76 ns123 nsRust 1.6× faster
HBAC throughput (1K)14.8M r/s6.18M r/sRust 2.4× faster
HBAC build (10K, builder)1.57 ms21.7 ms~14× slower
HBAC build (10K, json)1.57 ms6.9 ms~4.4× slower
ABAC eval (cached, 1K)176 ns320 nsRust 1.8× faster
ABAC eval (cached, 100K)1.05 µs982 ns~equal
ABAC throughput (10K)3.48M r/s2.57M r/sRust 1.4× faster
ABAC build (10K, builder)8.79 ms38.0 ms~4.3× slower
ABAC build (10K, json)8.79 ms13.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 (returning bool instead of HbacEvaluationResult) 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() and check_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.

TypeDescription
AtomicPermissionSingle namespace:action permission. new(ns, action), FromStr, Display.
PermissionSetSet of atomic permissions. from([...]), combine() (union), meet() (intersection), join() (join), difference(). Operators: |, &, -.
GrantDenialPairGrants + denials with denial precedence. new(grants, denials), empty(), has_permission(), effective_permissions().
PermissionDeltaBatch permission change. builder().grant_str(ns, act).remove_str(ns, act).build(), apply_to(set).
TemporalPermissionTime-windowed permission. new(perm, from, until), is_valid_at(ts), is_currently_valid().
TemporalPermissionSetCollection of temporal permissions. new(), add(), effective_at(ts), currently_effective().
DenialSetSet of denied permissions.
TimestampType 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

TypeDescription
SubjectUser 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.
BuilderErrorError from Subject::builder().build().

acls_rs::resource

TypeDescription
ResourceProtected object. new(name).requires(perm).resource_type(t), can_access(&subject), missing_permissions(&subject).

acls_rs::policy

TypeDescription
RbacPolicyRole-based policy. new(), add_role(), resolve_permissions(&roles), effective_permissions(&roles).
RoleNamed permission bundle. new(name, GrantDenialPair), with_parent(name). Field: parent_roles.
RbacErrorError enum. Variant: CyclicDependency.
AbacPolicyAttribute-based policy. new() (MeetResolver default), with_resolver(resolver), add_rule(), evaluate(&context).
AttributeRuleAttribute-based rule. new().grant(perm, attrs).deny(perm, attrs).
AttributeContextType alias for HashMap<String, String>.
AttributePermissionResult of ABAC evaluation. has_permission(), effective_permissions().
MeetResolverConflict resolver: most restrictive wins (default).
JoinResolverConflict resolver: most permissive wins.
PriorityResolverConflict resolver: priority-ordered.
ConflictResolverTrait for custom resolvers.

See RBAC Policies, ABAC Policies.

acls_rs::algebra

Algebraic traits implemented by permission types.

TraitDescription
SemigroupAssociative binary operation.
MonoidSemigroup with identity element.
MeetSemilatticeGreatest lower bound (intersection).
JoinSemilatticeLeast upper bound (union).
LatticeBoth meet and join semilattice.
MonoidActionMonoidal action on another type.

acls_rs::calculation

Permission computation utilities.

TypeDescription
HasPermissionsTrait for types that carry permissions.
PermissionEffectResult of a permission check with rationale.
PermissionEffectBuilderBuilder for PermissionEffect.
PermissionPreviewPreview 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

TypeDescription
HbacRuleThree-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.
HbacRuleTypeEnum: Allow, Deny.

hbac_rs::category

TypeDescription
HbacCategoryEnum: 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().
CategoryErrorError when adding entities to a Category::All. Variant: CannotModifyAll.

hbac_rs::request

TypeDescription
HbacRequestAccess request. new(subject, host, service), builder(). Fields: user, targethost, targethost_groups, service, service_groups.
HbacRequestBuilderBuilder: user(), targethost(), targethost_group(), service(), service_group(), build() -> Result<HbacRequest, BuilderError>.
BuilderErrorError for missing required fields. Variant: MissingField(&str).

hbac_rs::policy

TypeDescription
HbacPolicyThread-safe policy (Mutex-backed). Type alias for HbacPolicyCore<Mutex<RulePipeline>>.
HbacPolicyLocalSingle-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().
HbacEvaluationResultResult of evaluation. is_allowed(), is_denied(). Fields: allowed, matched_rules, not_matched_rules.
PipelineLockTrait for thread-safety abstraction.

See Policy Evaluation.

hbac_rs::resource

TypeDescription
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).
HbacResourceBuilderBuilder: host(), service(), host_group(), service_group(), build().

hbac_rs::temporal

TypeDescription
TemporalHbacRuleTime-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

TypeDescription
HbacCompiledEvaluatorPre-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

TypeDescription
ComposedPolicyHBAC+RBAC composition. new(hbac, rbac, mode), evaluate(), check_access(), hbac_policy(), hbac_policy_mut(), rbac_policy(), rbac_policy_mut().
CompositionModeEnum: And, Or, HbacFirst, RbacFirst.

See Policy Composition.

hbac_rs::cache

Caching pipeline for production deployments.

TypeDescription
RulePipelineSource-filter-cache-evaluate pipeline. builder(), load(), refresh(), incremental_update(), evaluate(), evaluate_with_tracking(), rule_count(), stats(), last_update_timestamp().
RulePipelineBuilderBuilder: with_source(), with_filter(), with_cache(), build().
RuleSourceTrait: fetch_all(), fetch_updated_since(), is_available().
RuleSourceErrorError enum: ConnectionFailed, AuthenticationFailed, ParseError, Unavailable, Other.
MemorySourceIn-memory source. new(rules), add_rule().
CompositeSourceMulti-source with fallback. new(), with_source(), add_source().
RuleFilterTrait: should_cache(), filter(), filter_cloned().
HostFilterHost-specific filter. for_host(name), with_group(), with_groups().
ApplicabilityFilterScope filter. global_only(), all_enabled().
AcceptAllFilterNo-op filter.
AndFilterLogical AND of filters. new(), with().
OrFilterLogical OR of filters. new(), with().
RuleCacheTrait: store(), get(), get_all(), get_many(), contains(), len(), is_empty(), clear(), stats().
IndexedCacheIn-memory indexed cache. new(), find_by_user(), find_by_host(), find_by_user_and_host().
DiskCachePersistent cache (requires serde). new(path), flush().
CacheStatsCache metrics. hit_rate(). Fields: rule_count, memory_bytes, hit_count, miss_count.
RuleIndexTrait: index(), find_by_user(), find_by_host(), find_by_service(), clear().
UserIndexIndex by user. new().
HostIndexIndex by host. new().
CompositeIndexMulti-dimensional index. new(), find_by_user_and_host().
BloomFilterCacheProbabilistic filter. build(rules), might_match(...).
DecisionNodeCompiled 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

TypeDescription
AbacRuleAttribute-based access rule. new(name), new_deny(name), enable(), disable(), add_condition(key, value), estimated_size(). Fields: name: Arc<str>, enabled, conditions, rule_type.
AbacRuleTypeEnum: Allow, Deny.

abac_rs::attribute

TypeDescription
AttributeValueMulti-type attribute value. Enum variants: String(String), Integer(i64), Float(f64), IpAddr(IpAddr), IpCidr(IpCidr), Custom(Box<dyn Any + Send + Sync>).
AttributeMatcherTrait for custom matching logic. matches(&self, value: &AttributeValue) -> bool.

abac_rs::request

TypeDescription
AbacRequestEvaluation request with attributes. new(), set_attribute(key, value), get_attribute(key). Field: attributes: HashMap<String, AttributeValue>.

abac_rs::policy

TypeDescription
AbacPolicyThread-safe policy (Mutex-backed). Type alias for AbacPolicyCore<Mutex<RulePipeline>>.
AbacPolicyLocalSingle-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).
AbacEvaluationResultResult of evaluation. is_allowed(), is_denied(). Fields: allowed, matched_rules, not_matched_rules.

See Policy Evaluation.

abac_rs::compiled

TypeDescription
AbacCompiledEvaluatorPre-compiled evaluator with extracted attributes. build(rules), evaluate(request).

abac_rs::temporal

TypeDescription
TemporalAbacRuleTime-windowed ABAC rule. new(rule, valid_from, valid_until), is_valid_at(ts).

abac_rs::cache

TypeDescription
RulePipelineEvaluation pipeline with deny index and compiled evaluator. evaluate(request), rebuild_indexes().
DenyIndexBitmap-based deny rule index (3.2x less memory than HBAC). build(rules), check_deny(request).
BloomFilterCacheProbabilistic 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

MethodDescription
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.

MethodDescription
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:

FieldValues
type"PolicyError", "JsonError", or "ValidationError"
messageHuman-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

MethodDescription
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.

MethodDescription
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

MethodDescription
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

ModuleDescription
perf_testing::synthesisRule generation with distribution presets and patterns.
perf_testing::fixturesFixture loading, saving, and validation (gzip-compressed JSON).
perf_testing::runnerBenchmark scenario execution and metrics collection.
perf_testing::bacBAC abstraction layer (BacPolicy trait, HbacPolicyAdapter).
perf_testing::clibac-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:

  • capsule module – Safe PyCapsule creation and import helpers
    • create_capsule<T>() – Export C-ABI compatible type via capsule
    • import_capsule<T>() – Import capsule from another Python module
  • rbac module – RbacPolicyAPI for sharing RbacPolicy across modules
  • subject module – SubjectAPI for sharing Subject across modules
  • Enables abac_rs.ComposedPolicy to accept acls_rs.RbacPolicy and hbac_rs.Subject via FFI

acls-rs (Foundation)

Algebraically-correct access control primitives. Minimal runtime dependencies (log, thiserror).

Key types:

  • Subject – user with permissions, roles, and temporal permissions
  • AtomicPermission – single namespace:action permission
  • PermissionSet – collection with algebraic operations (combine, meet, join, difference)
  • GrantDenialPair – separate grants and denials with denial precedence
  • Role – named permission bundle with optional parent hierarchy
  • RbacPolicy – role resolution with cycle detection
  • AbacPolicy – attribute-based evaluation with pluggable conflict resolvers
  • TemporalPermission – time-windowed permission

Shared abstractions:

  • sync::SyncStrategy<T> – Generic interior mutability trait over Mutex<T> and RefCell<T>
  • policy::PolicyError – Shared error type for DoS protection violations
  • policy::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 use Box<[Arc<str>]>)
  • HbacCategoryAll or Specific { names: Box<[Arc<str>]>, groups: Box<[Arc<str>]> }
  • HbacCompiledEvaluator – pre-compiled evaluator with AHashSet lookups
  • HbacPolicyCore<L> – evaluation engine parameterized by lock type
  • HbacPolicy / HbacPolicyLocal – thread-safe and single-threaded aliases
  • RulePipeline – cached evaluation with Bloom filter, decision tree, bitmap-based deny-rule index, and compiled evaluator
  • ComposedPolicy – HBAC+RBAC composition with four modes
  • TemporalHbacRule – 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 semantics
  • AttributeValue – multi-type value (String, Integer, Float, IpAddr, IpCidr, custom)
  • AttributeMatcher – pluggable matcher for custom matching logic
  • AbacCompiledEvaluator – pre-compiled evaluator with pre-extracted attributes
  • AbacPolicy / AbacPolicyLocal – thread-safe and single-threaded aliases
  • AbacRequest – 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

  1. Layered architecture: acls-rs provides primitives, hbac-rs and abac-rs build domain-specific evaluation, perf-testing validates performance.
  2. Separation of concerns: pure evaluation logic vs. caching vs. tooling.
  3. Composability: HBAC, RBAC, and ABAC policies can be combined.
  4. Lazy construction: expensive indexes are deferred until first evaluation.
  5. 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:

PatternStrategySpeedup
category=allConstant returnNegligible (already fast)
Single userInteger comparison (<=8 bytes) or memcmpMeasurable
Group matchUnrolled comparison loopMeasurable
ComplexFalls back to interpreterNone

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 via Arc<HbacPolicy> across threads.
  • HbacPolicyLocal (RefCell): zero mutex overhead for single-threaded use (SSSD, benchmarks).
  • Shared implementation in acls_rs::sync::SyncStrategy eliminates 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

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:

PatternCriteriaCompilation
CategoryAllAll three dimensions are category=allNative Cranelift codegen (constant return)
SingleUserOne user name, any host/serviceOptimized interpreter (direct string comparison)
GroupMatchUser groups specified, any host/serviceOptimized interpreter (set membership check)
ComplexMultiple users, or other patternsStandard 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 the JITModule, 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 a jit_cache mapping rule names to compiled evaluators.
  • CompiledRule – Implements the RuleEvaluator trait. 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 JITModule is owned by JitCompiler, which is owned by the same JitRuntime that owns the CompiledRule in its jit_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

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 target
  • crates/abac-wasm/pkg-node/ - Node.js target
  • crates/hbac-wasm/pkg/ - Web target
  • crates/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 = false on wasm32)
  • ipnetwork
  • thiserror
  • log
  • ahash (with no-rng feature on wasm32)
  • serde, serde_json
  • getrandom (with wasm_js feature)
  • 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:

  1. Installs wasm-pack@0.13.1 (pinned version)
  2. Verifies hbac-wasm has no cranelift dependency via cargo tree
  3. Builds abac-wasm and hbac-wasm for --target web and --target nodejs
  4. Caches the cargo registry and git checkouts between runs

wasm-test — runs WASM tests after a successful build:

  1. Installs build tooling (gcc, make, pkgconf-pkg-config)
  2. Installs wasm-pack@0.13.1
  3. Runs wasm-pack test --node for both crates
  4. 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 fmt enforces formatting (checked by CI)
  • cargo clippy --workspace --all-targets must pass with no warnings
  • cargo doc --workspace --no-deps must 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-by line

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., CategoryError when adding to a Category::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 login with a valid crates.io token (for publishing)
  • GPG key configured (for signed tags; use --no-sign if 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.