axum-acl 0.2.0

Flexible ACL middleware for axum 0.8 with 5-tuple rule matching (endpoint, role, id, ip, time)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
# axum-acl

Flexible Access Control List (ACL) middleware for [axum](https://docs.rs/axum) 0.8.

## Features

- **TOML Configuration** - Define rules in config files (compile-time or startup)
- **Table-based rules** - No hardcoded rules; all access control is configured at startup
- **Five-tuple matching** - Rules match on Endpoint + Role + ID + IP + Time
- **Extended actions** - Allow, Deny, Error (custom codes), Reroute, Log
- **Flexible extractors** - Extract roles (u32 bitmask) and IDs from headers, extensions, or custom sources
- **Path parameters** - Match `{id}` in paths against user ID for ownership-based access
- **Pattern matching** - Exact, prefix, and glob patterns for endpoints
- **Time windows** - Restrict access to specific hours or days
- **IP filtering** - Single IPs, CIDR ranges, or lists
- **Priority ordering** - Control rule evaluation order via priority field

## Installation

Add to your `Cargo.toml`:

```toml
[dependencies]
axum-acl = "0.1"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
```

## Quick Start

```rust
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:

```bash
# 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
```

## TOML Configuration

Define rules in TOML format - either embedded at compile-time or loaded from a file at startup.

### Compile-time Embedded Config

```rust
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();
}
```

### Startup File Loading

```rust
use axum_acl::AclTable;

fn main() {
    let table = AclTable::from_toml_file("config/acl.toml").unwrap();
}
```

### TOML Format

```toml
[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 Types

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

## Rule Structure (5-Tuple)

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.

### Matching Logic

```
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
```

## Endpoint Patterns

```rust
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
```

## Role Extraction

Roles are extracted as a `u32` bitmask, allowing up to 32 roles per user.

### Default: Header as Bitmask

```rust
// X-Roles header parsed as decimal or hex
// X-Roles: 5      -> 0b101 (roles 0 and 2)
// X-Roles: 0x1F   -> 0b11111 (roles 0-4)
```

### Custom Header

```rust
use axum_acl::{AclLayer, AclTable, HeaderRoleExtractor};

let layer = AclLayer::new(acl_table)
    .with_extractor(HeaderRoleExtractor::new("X-User-Roles"));
```

### With Default Roles for Anonymous Users

```rust
use axum_acl::HeaderRoleExtractor;

const ROLE_GUEST: u32 = 0b100;

let extractor = HeaderRoleExtractor::new("X-Roles")
    .with_default_roles(ROLE_GUEST);
```

### Custom Role Translation

Translate your role scheme (strings, enums, etc.) to u32 bitmask:

```rust
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);
```

## ID Extraction

User IDs are extracted as strings for matching against `{id}` path parameters.

### Header-based ID

```rust
use axum_acl::HeaderIdExtractor;

let layer = AclLayer::new(acl_table)
    .with_id_extractor(HeaderIdExtractor::new("X-User-Id"));
```

### Custom ID Extraction

```rust
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
    }
}
```

### Path Parameter Matching

Match `{id}` in paths against the user's ID for ownership-based access:

```rust
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
```

## Time Windows

```rust
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)
)
```

## IP Matching

```rust
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
```

## Behind a Reverse Proxy

When behind nginx, traefik, or similar:

```rust
let layer = AclLayer::new(acl_table)
    .with_forwarded_ip_header("X-Forwarded-For");
```

## Custom Denied Response

```rust
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()
    }
}
```

## Dynamic Rules from Database

```rust
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()
}
```

## Endpoint Parser Tool

Discover endpoints and their ACL rules from your codebase:

```bash
# 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/
```

### CLI Arguments

```
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
```

### Output Format

```
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
```

## API Reference

### Core Types

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

### Middleware

| Type | Description |
|------|-------------|
| `AclLayer` | Tower layer for adding ACL to router |
| `AclMiddleware` | The middleware service |
| `AclConfig` | Middleware configuration |

### Role Extraction

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

### ID Extraction

| Type | Description |
|------|-------------|
| `IdExtractor` | Trait for extracting user ID (String) |
| `HeaderIdExtractor` | Extract from HTTP header |
| `ExtensionIdExtractor` | Extract from request extension |
| `FixedIdExtractor` | Always returns same ID |

### Error Handling

| Type | Description |
|------|-------------|
| `AccessDenied` | Access denied error |
| `AccessDeniedHandler` | Trait for custom responses |
| `DefaultDeniedHandler` | Plain text 403 response |
| `JsonDeniedHandler` | JSON 403 response |

## Examples

Run the examples:

```bash
# 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
```

## License

MIT OR Apache-2.0