| Crates.io | axum-acl |
| lib.rs | axum-acl |
| version | 0.2.0 |
| created_at | 2025-12-04 11:40:30.117814+00 |
| updated_at | 2025-12-04 14:43:08.063405+00 |
| description | Flexible ACL middleware for axum 0.8 with 5-tuple rule matching (endpoint, role, id, ip, time) |
| homepage | https://github.com/greenpdx/axum-acl |
| repository | https://github.com/greenpdx/axum-acl |
| max_upload_size | |
| id | 1966372 |
| size | 234,935 |
Flexible Access Control List (ACL) middleware for axum 0.8.
{id} in paths against user ID for ownership-based accessAdd to your Cargo.toml:
[dependencies]
axum-acl = "0.1"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
use axum::{Router, routing::get};
use axum_acl::{AclLayer, AclTable, AclRuleFilter, AclAction};
use std::net::SocketAddr;
// Define role bits
const ROLE_ADMIN: u32 = 0b001;
const ROLE_USER: u32 = 0b010;
#[tokio::main]
async fn main() {
// Define ACL rules
let acl_table = AclTable::builder()
.default_action(AclAction::Deny)
// Admins can access everything
.add_any(AclRuleFilter::new()
.role_mask(ROLE_ADMIN)
.action(AclAction::Allow))
// Users can access /api/**
.add_prefix("/api/", AclRuleFilter::new()
.role_mask(ROLE_USER)
.action(AclAction::Allow))
// Public endpoints (any role)
.add_prefix("/public/", AclRuleFilter::new()
.role_mask(u32::MAX)
.action(AclAction::Allow))
.build();
let app = Router::new()
.route("/public/info", get(|| async { "Public" }))
.route("/api/users", get(|| async { "API" }))
.route("/admin/dashboard", get(|| async { "Admin" }))
.layer(AclLayer::new(acl_table));
// Important: Use into_make_service_with_connect_info for IP extraction
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>()
).await.unwrap();
}
Test it:
# Public endpoint (allowed)
curl http://localhost:3000/public/info
# API as user (role_mask=2, allowed)
curl -H "X-Roles: 2" http://localhost:3000/api/users
# Admin as user (denied)
curl -H "X-Roles: 2" http://localhost:3000/admin/dashboard
# Admin as admin (role_mask=1, allowed)
curl -H "X-Roles: 1" http://localhost:3000/admin/dashboard
Define rules in TOML format - either embedded at compile-time or loaded from a file at startup.
use axum_acl::AclTable;
// Embed configuration at compile time
const CONFIG: &str = include_str!("../acl.toml");
fn main() {
let table = AclTable::from_toml(CONFIG).unwrap();
}
use axum_acl::AclTable;
fn main() {
let table = AclTable::from_toml_file("config/acl.toml").unwrap();
}
[settings]
default_action = "deny"
# Rules are sorted by priority (lower = higher priority)
[[rules]]
endpoint = "*"
role_mask = 1 # Admin role bit
action = "allow"
priority = 10
description = "Admins have full access"
[[rules]]
endpoint = "/api/**"
role_mask = 2 # User role bit
time = { start = 9, end = 17, days = [0,1,2,3,4] } # Mon-Fri 9-5 UTC
action = "allow"
priority = 100
[[rules]]
endpoint = "/admin/**"
role_mask = "*" # Any role
action = { type = "error", code = 403, message = "Admin access required" }
priority = 20
[[rules]]
endpoint = "/boat/{id}/**"
role_mask = 2
id = "{id}" # Match path {id} against user ID
action = "allow"
priority = 50
[[rules]]
endpoint = "/internal/**"
role_mask = "*"
ip = "127.0.0.1"
action = "allow"
priority = 50
[[rules]]
endpoint = "/public/**"
role_mask = "*"
action = "allow"
priority = 200
| Action | TOML Syntax | Description |
|---|---|---|
| Allow | "allow" |
Allow the request |
| Deny | "deny" or "block" |
Return 403 Forbidden |
| Error | { type = "error", code = 418, message = "..." } |
Custom HTTP error |
| Reroute | { type = "reroute", target = "/path" } |
Redirect to another path |
| Log | { type = "log", level = "warn", message = "..." } |
Log and allow |
Each rule matches on five dimensions:
| Field | Description | Default |
|---|---|---|
endpoint |
Path pattern to match | Any |
role_mask |
u32 bitmask or * for any |
Required |
id |
User ID match: exact, *, or {id} for path param |
* (any) |
ip |
Client IP(s) to match | Any IP |
time |
Time window when rule is active | Any time |
action |
Allow, Deny, Error, Reroute | Allow |
Rules are evaluated in order. The first matching rule wins.
endpoint: HashMap lookup (O(1) for exact) or pattern match
role: (rule.role_mask & request.roles) != 0
id: rule.id == "*" OR rule.id == request.id OR rule.id == "{id}" (path param)
ip: CIDR match (ip & mask == network)
time: start <= now <= end AND day in days
use axum_acl::EndpointPattern;
// Match any path
EndpointPattern::any()
// Exact match
EndpointPattern::exact("/api/users") // Only /api/users
// Prefix match
EndpointPattern::prefix("/api/") // /api/*, /api/users, etc.
// Glob patterns
EndpointPattern::glob("/api/*/users") // /api/v1/users, /api/v2/users
EndpointPattern::glob("/api/**/export") // /api/export, /api/v1/data/export
// Path parameters (matched against user ID)
EndpointPattern::glob("/boat/{id}/details") // {id} matches user's ID
// Parse from string
EndpointPattern::parse("/api/") // Prefix (ends with /)
EndpointPattern::parse("/api/users") // Exact
EndpointPattern::parse("/api/**") // Glob
EndpointPattern::parse("*") // Any
Roles are extracted as a u32 bitmask, allowing up to 32 roles per user.
// X-Roles header parsed as decimal or hex
// X-Roles: 5 -> 0b101 (roles 0 and 2)
// X-Roles: 0x1F -> 0b11111 (roles 0-4)
use axum_acl::{AclLayer, AclTable, HeaderRoleExtractor};
let layer = AclLayer::new(acl_table)
.with_extractor(HeaderRoleExtractor::new("X-User-Roles"));
use axum_acl::HeaderRoleExtractor;
const ROLE_GUEST: u32 = 0b100;
let extractor = HeaderRoleExtractor::new("X-Roles")
.with_default_roles(ROLE_GUEST);
Translate your role scheme (strings, enums, etc.) to u32 bitmask:
use axum_acl::{RoleExtractor, RoleExtractionResult};
use http::Request;
// Your role definitions
const ROLE_ADMIN: u32 = 1 << 0;
const ROLE_USER: u32 = 1 << 1;
const ROLE_GUEST: u32 = 1 << 2;
struct JwtRoleExtractor;
impl<B> RoleExtractor<B> for JwtRoleExtractor {
fn extract_roles(&self, request: &Request<B>) -> RoleExtractionResult {
// Decode JWT, lookup database, etc.
if let Some(auth) = request.headers().get("Authorization") {
// Parse and translate to bitmask
let roles = ROLE_USER | ROLE_GUEST;
return RoleExtractionResult::Roles(roles);
}
RoleExtractionResult::Anonymous
}
}
let layer = AclLayer::new(acl_table)
.with_extractor(JwtRoleExtractor);
User IDs are extracted as strings for matching against {id} path parameters.
use axum_acl::HeaderIdExtractor;
let layer = AclLayer::new(acl_table)
.with_id_extractor(HeaderIdExtractor::new("X-User-Id"));
use axum_acl::{IdExtractor, IdExtractionResult};
use http::Request;
struct JwtIdExtractor;
impl<B> IdExtractor<B> for JwtIdExtractor {
fn extract_id(&self, request: &Request<B>) -> IdExtractionResult {
// Extract user ID from JWT, session, etc.
if let Some(auth) = request.headers().get("Authorization") {
return IdExtractionResult::Id("user-123".to_string());
}
IdExtractionResult::Anonymous
}
}
Match {id} in paths against the user's ID for ownership-based access:
use axum_acl::{AclTable, AclRuleFilter, AclAction, EndpointPattern};
const ROLE_USER: u32 = 0b010;
let table = AclTable::builder()
.default_action(AclAction::Deny)
// Users can only access their own boat data
.add_pattern(
EndpointPattern::glob("/api/boat/{id}/**"),
AclRuleFilter::new()
.role_mask(ROLE_USER)
.id("{id}") // Path {id} must match user's ID
.action(AclAction::Allow)
)
.build();
// User with id="boat-123":
// /api/boat/boat-123/details -> ALLOWED
// /api/boat/boat-456/details -> DENIED
use axum_acl::TimeWindow;
// Any time (default)
TimeWindow::any()
// Specific hours (UTC)
TimeWindow::hours(9, 17) // 9 AM - 5 PM UTC
// Specific hours on specific days
TimeWindow::hours_on_days(
9, 17, // 9 AM - 5 PM
vec![0, 1, 2, 3, 4] // Mon-Fri (0=Monday)
)
use axum_acl::IpMatcher;
// Any IP (default)
IpMatcher::any()
// Single IP
IpMatcher::single("192.168.1.1".parse().unwrap())
// CIDR range
IpMatcher::cidr("10.0.0.0/8".parse().unwrap())
// Parse from string
IpMatcher::parse("*").unwrap() // Any
IpMatcher::parse("192.168.1.1").unwrap() // Single
IpMatcher::parse("192.168.0.0/16").unwrap() // CIDR
When behind nginx, traefik, or similar:
let layer = AclLayer::new(acl_table)
.with_forwarded_ip_header("X-Forwarded-For");
use axum_acl::{AccessDeniedHandler, AccessDenied, JsonDeniedHandler};
use axum::response::{Response, IntoResponse};
use http::StatusCode;
// Use built-in JSON handler
let layer = AclLayer::new(acl_table)
.with_denied_handler(JsonDeniedHandler::new());
// Or custom handler
struct MyHandler;
impl AccessDeniedHandler for MyHandler {
fn handle(&self, denied: &AccessDenied) -> Response {
(
StatusCode::FORBIDDEN,
format!("Access denied: roles={}", denied.roles)
).into_response()
}
}
use axum_acl::{AclRuleProvider, RuleEntry, AclRuleFilter, AclTable, AclAction, EndpointPattern};
struct DbRuleProvider { /* db pool */ }
impl AclRuleProvider for DbRuleProvider {
type Error = std::io::Error;
fn load_rules(&self) -> Result<Vec<RuleEntry>, Self::Error> {
// Query your database
Ok(vec![
RuleEntry::any(AclRuleFilter::new()
.role_mask(0b001)
.action(AclAction::Allow))
])
}
}
// Usage at startup
fn build_table(provider: &DbRuleProvider) -> AclTable {
let rules = provider.load_rules().unwrap();
let mut builder = AclTable::builder().default_action(AclAction::Deny);
for entry in rules {
builder = builder.add_pattern(entry.pattern, entry.filter);
}
builder.build()
}
Discover endpoints and their ACL rules from your codebase:
# Build the parser
cargo build --bin endpoint_parser
# Parse endpoints (table format)
cargo run --bin endpoint_parser -- examples/
# Output as CSV
cargo run --bin endpoint_parser -- --csv examples/ > endpoints.csv
# Output as TOML config
cargo run --bin endpoint_parser -- --toml examples/ > acl.toml
# Use AST-based parsing (more accurate, requires feature)
cargo run --bin endpoint_parser --features ast-parser -- --ast examples/
Usage: endpoint_parser [OPTIONS] <directory>
Options:
--text Use text-based parsing (default, fast)
--ast Use AST-based parsing (requires --features ast-parser)
--table Output as formatted table (default)
--csv Output as CSV
--toml Output as TOML config file
--help Show help message
ENDPOINT METHOD ROLE, ID, IP, TIME | ACTION HANDLER LOCATION
------------------------------------------------------------------------------------------------------------------------
/admin/dashboard GET ROLE_ADMIN, *, *, * | allow admin_dashboard basic.rs:109
/api/users GET ROLE_USER, *, *, * | allow api_users basic.rs:106
/public/info GET *, *, *, * | allow public_info basic.rs:103
| Type | Description |
|---|---|
AclTable |
Container for ACL rules (HashMap + patterns) |
AclRuleFilter |
Filter for 5-tuple matching (role, id, ip, time, action) |
AclAction |
Allow, Deny, Error, Reroute, Log |
EndpointPattern |
Path matching: Exact, Prefix, Glob, Any |
RequestContext |
Request metadata: roles (u32), ip, id |
TimeWindow |
Time-based restriction |
IpMatcher |
IP address matching |
| Type | Description |
|---|---|
AclLayer |
Tower layer for adding ACL to router |
AclMiddleware |
The middleware service |
AclConfig |
Middleware configuration |
| Type | Description |
|---|---|
RoleExtractor |
Trait for extracting roles (u32 bitmask) |
HeaderRoleExtractor |
Extract from HTTP header |
ExtensionRoleExtractor |
Extract from request extension |
FixedRoleExtractor |
Always returns same roles |
ChainedRoleExtractor |
Try multiple extractors |
| Type | Description |
|---|---|
IdExtractor |
Trait for extracting user ID (String) |
HeaderIdExtractor |
Extract from HTTP header |
ExtensionIdExtractor |
Extract from request extension |
FixedIdExtractor |
Always returns same ID |
| Type | Description |
|---|---|
AccessDenied |
Access denied error |
AccessDeniedHandler |
Trait for custom responses |
DefaultDeniedHandler |
Plain text 403 response |
JsonDeniedHandler |
JSON 403 response |
Run the examples:
# Basic usage with builder API
cargo run --example basic
# Custom role extraction from request extensions
cargo run --example custom_extractor
# TOML configuration (compile-time and startup)
cargo run --example toml_config
MIT OR Apache-2.0