laye 0.1.0

A framework-agnostic role and permission based access control library
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
# laye — Implementation Plan

## Context

`laye` is a standalone Rust library crate for role and permission based access control (RBAC). The reference design comes from an actix-web starter template that implements a layered access control system: a global auth middleware that loads roles and permissions from the database, a flexible `AccessControl` enum, and a composable `AccessControlCondition` builder for AND/OR logic at the route level.

`laye` extracts and generalises this pattern into a framework-agnostic library. The core (traits + policy system) has zero framework dependencies. Optional framework integrations are gated behind feature flags so users only pull in what they need.

---

## Stage 1 — Crate Setup ✓ DONE

**Goal:** Convert the binary stub into a library.

**What was done:**
- Deleted `src/main.rs`
- Deleted `src/main.rs`
- Created `src/lib.rs` with module declarations for `result`, `principal`, `policy` (originally `error`; renamed to `result` in Stage 3)
- Created stub files: `src/result.rs`, `src/principal.rs`, `src/policy.rs`
- Added `description` and `license` fields to `Cargo.toml`
- No optional dependencies or feature flags added (deferred to their respective stages)

**`cargo check` passes.**

**Note:** Feature flags (`actix-web`, `tower`) and all optional/dev dependencies are added in Stages 4 and 5 when the code that requires them is written.

---

## Stage 2 — Core Traits and Types ✓ DONE

**Goal:** Define the `Principal` trait and the `LayeCheckResult` type. No framework dependencies.

**Files:** `src/result.rs`, `src/principal.rs`

**What was done:**
- Implemented `LayeCheckResult` enum in `src/result.rs` (originally `LayeError` in `src/error.rs`; renamed and restructured in Stage 3)
- Implemented `Principal` trait in `src/principal.rs` — object-safe, no generics
- Created `tests/principal.rs` with 8 passing tests, all asserts include failure messages

### `src/result.rs`
```rust
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LayeCheckResult {
    Authorized,
    Unauthorized,
    Forbidden,
}
```

### `src/principal.rs`
```rust
pub trait Principal {
    fn roles(&self) -> &[String];
    fn permissions(&self) -> &[String];
    fn is_authenticated(&self) -> bool;

    fn has_role(&self, role: &str) -> bool {
        self.roles().iter().any(|r| r == role)
    }
    fn has_permission(&self, permission: &str) -> bool {
        self.permissions().iter().any(|p| p == permission)
    }
}
```

**Notes:**
- `Principal` is object-safe (no `Self` in non-default signatures, no generics). This is required for `AccessRule::Custom`.
- `is_authenticated` is explicit — a principal can be authenticated with zero roles.

### Stage 2 Tests

**File:** `tests/principal.rs` — 8 tests, all passing

```
has_role_returns_true_for_matching_role
has_role_returns_false_for_missing_role
has_role_returns_false_for_empty_roles
has_permission_returns_true_for_matching_permission
has_permission_returns_false_for_missing_permission
has_permission_returns_false_for_empty_permissions
is_authenticated_reflects_field
dyn_principal_coercion_compiles   // &user as &dyn Principal — verifies object safety
```

**`cargo check` and `cargo test` pass.**

---

## Stage 3 — Policy / Condition System ✓ DONE

**Goal:** Composable AND/OR access rules with support for nesting. No framework dependencies.

**Files:** `src/result.rs`, `src/policy.rs`

**What was done:**
- Replaced `LayeError` with `LayeCheckResult` in `src/result.rs` — a plain enum (no `thiserror`) with three variants; `src/error.rs` was renamed to `src/result.rs` and the module updated accordingly
- Implemented `AccessRule`, `AccessPolicy`, and internal `PolicyCheck`/`PolicyMode` types in `src/policy.rs`
- `check()` returns `LayeCheckResult` directly, not `Result<(), _>`
- Created `tests/policy.rs` with 21 passing tests
- `thiserror` dependency removed from `Cargo.toml` (zero dependencies in core)

### `src/result.rs`
```rust
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum LayeCheckResult {
    Authorized,
    Unauthorized,
    Forbidden,
}
```

### `src/policy.rs`
```rust
pub type CustomFn = Arc<dyn Fn(Option<&dyn Principal>) -> bool + Send + Sync>;

#[derive(Clone)]
pub enum AccessRule {
    Role(String),
    NotRole(String),        // passes when principal lacks the role
    Permission(String),
    NotPermission(String),  // passes when principal lacks the permission
    Authenticated,
    Guest,   // passes for None OR is_authenticated() == false
    Custom(CustomFn),
}

#[derive(Clone)]
pub struct AccessPolicy {
    checks: Vec<PolicyCheck>,   // Rule | Nested(AccessPolicy)
    mode: PolicyMode,           // All (AND) | Any (OR)
}

impl AccessPolicy {
    pub fn require_all() -> Self { ... }
    pub fn require_any() -> Self { ... }
    pub fn add_rule(self, rule: AccessRule) -> Self { ... }
    pub fn add_policy(self, policy: AccessPolicy) -> Self { ... }
    pub fn check(&self, principal: Option<&dyn Principal>) -> LayeCheckResult { ... }
}
```

**`check` result rules:**
| Situation | Result |
|-----------|--------|
| Rule passes | `Authorized` |
| `principal` is `None` and auth needed | `Unauthorized` |
| Principal present but lacks role/permission | `Forbidden` |
| `Authenticated` rule, principal exists but `is_authenticated()` is false | `Unauthorized` |
| `Guest` rule, principal is authenticated | `Forbidden` |
| `NotRole`/`NotPermission`, principal is `None` | `Unauthorized` |
| `NotRole`/`NotPermission`, principal has the named role/permission | `Forbidden` |
| `NotRole`/`NotPermission`, principal lacks the named role/permission | `Authorized` |
| `require_any`, all checks fail, principal present | `Forbidden` |
| `require_any`, all checks fail, no principal | `Unauthorized` |

**Ergonomic helpers on `AccessPolicy`** (gated, live in `src/policy.rs`):
```rust
#[cfg(feature = "actix-web")]
pub fn into_actix_middleware<P: Principal + Clone + 'static>(self) -> actix::PolicyMiddlewareFactory<P>

#[cfg(feature = "tower")]
pub fn into_tower_layer<P: Principal + Clone + Send + Sync + 'static>(self) -> tower_middleware::AccessControlLayer<P>
```

### Stage 3 Tests

**File:** `tests/policy.rs` — 27 tests, all passing

```
// AccessRule::Role
role_rule_passes_for_matching_role
role_rule_fails_for_wrong_role
role_rule_fails_for_unauthenticated_returns_unauthorized

// AccessRule::Permission
permission_rule_passes_for_matching_permission
permission_rule_fails_for_wrong_permission

// AccessRule::Authenticated
authenticated_rule_passes_for_authenticated_principal
authenticated_rule_fails_returning_unauthorized_for_none
authenticated_rule_fails_returning_unauthenticated_for_unauthenticated_principal

// AccessRule::Guest
guest_rule_passes_for_none
guest_rule_passes_for_unauthenticated_principal
guest_rule_fails_for_authenticated_principal

// AccessRule::Custom
custom_rule_delegates_to_closure
custom_rule_receives_none_for_missing_principal

// AccessRule::NotRole / NotPermission
not_role_passes_when_principal_lacks_role
not_role_fails_when_principal_has_role
not_role_fails_for_missing_principal
not_permission_passes_when_principal_lacks_permission
not_permission_fails_when_principal_has_permission
absence_check_composes_with_presence_check

// AccessPolicy AND mode
require_all_passes_when_all_rules_pass
require_all_fails_when_any_rule_fails

// AccessPolicy OR mode
require_any_passes_when_one_rule_passes
require_any_fails_when_all_rules_fail

// Nesting
nested_policy_and_of_or_passes
nested_policy_or_of_and_fails_correctly

// Unauthorized vs Forbidden distinction
unauthenticated_request_yields_unauthorized_not_forbidden
authenticated_without_role_yields_forbidden_not_unauthorized
```

**`cargo check` and `cargo test` pass. 35 total tests (8 + 27).**

---

## Stage 4 — actix-web Middleware Helper (`feature = "actix-web"`) ✓ DONE

**Goal:** A `Transform` + `Service` middleware that reads a `P: Principal` from request extensions and enforces an `AccessPolicy`.

**Files:** `src/actix/mod.rs`, `src/actix/layer.rs`, `src/actix/extractor.rs`

**What was done:**
- Implemented `PolicyMiddlewareFactory<P>` and `PolicyMiddleware<S, P>` in `src/actix/layer.rs`
- Implemented `AuthPrincipal<P>` and `MaybeAuthPrincipal<P>` extractors in `src/actix/extractor.rs`
- Re-exported everything from `src/actix/mod.rs`
- Added `into_actix_middleware<P>()` convenience method on `AccessPolicy` (gated on `#[cfg(feature = "actix-web")]`)
- Created `tests/actix_middleware.rs` with 13 passing tests

### Middleware (`src/actix/layer.rs`)
```rust
pub struct PolicyMiddlewareFactory<P> {
    policy: AccessPolicy,
    _marker: PhantomData<P>,
}

// Implements actix_web::dev::Transform<S, ServiceRequest>
// Produces PolicyMiddleware<S, P>

pub struct PolicyMiddleware<S, P> {
    service: Rc<S>,
    policy: AccessPolicy,
    _marker: PhantomData<P>,
}

// Service::call:
//   1. reads req.extensions().get::<P>().cloned()
//   2. calls policy.check(principal.as_ref().map(|p| p as &dyn Principal))
//   3. Authorized    → forwards to inner service
//   4. Unauthorized  → ErrorUnauthorized ("Unauthorized")
//   5. Forbidden     → ErrorForbidden ("Forbidden")
```

`Future` type: `LocalBoxFuture<'static, ...>` (actix is single-threaded). Inner service wrapped in `Rc` to satisfy `'static` requirement in the future.

### Extractors (`src/actix/extractor.rs`)
```rust
pub struct AuthPrincipal<P>(pub P);              // 401 if missing from extensions
pub struct MaybeAuthPrincipal<P>(pub Option<P>); // never fails

// Both implement FromRequest, cloning P out of request extensions
```

**Design notes:**
- Users insert `P` into extensions via their own global auth middleware (e.g. a JWT decode layer). `laye` only reads it — no opinion on token format or DB.
- `ErrorUnauthorized` / `ErrorForbidden` use actix's built-in helpers; body is a plain string.
- `P: Clone` is required so the extractor can hand ownership to the handler without consuming the extension map.

### Stage 4 Tests

**File:** `tests/actix_middleware.rs` — 13 tests, all passing (requires `#[cfg(feature = "actix-web")]`)

Uses `actix_web::test::{init_service, call_service, TestRequest}` to spin up a minimal `App`. Extensions are inserted via `wrap_fn`.

```
middleware_allows_request_when_policy_passes
// → admin TestUser in extensions, Role("admin") policy → 200

middleware_returns_403_when_role_missing
// → TestUser with no roles, Role("admin") policy → 403

middleware_returns_401_when_no_principal_in_extensions
// → no extension, Authenticated policy → 401

auth_principal_extractor_injects_principal_into_handler
// → AuthPrincipal<TestUser> received in handler, asserts has_role("user") → 200

auth_principal_extractor_returns_401_when_missing
// → no extension, AuthPrincipal extractor used → 401

maybe_auth_principal_returns_some_when_present
// → extension present, MaybeAuthPrincipal is Some → 200

maybe_auth_principal_returns_none_when_absent
// → no extension, Guest policy, MaybeAuthPrincipal is None → 200

and_policy_blocks_when_one_condition_fails
// → Authenticated + Role("admin"), user only has "user" role → 403

or_policy_allows_when_one_condition_passes
// → Role("admin") | Role("editor"), user has "editor" → 200

not_role_allows_user_without_banned_role
// → Authenticated + NotRole("banned"), user has "editor" only → 200

not_role_blocks_user_with_banned_role
// → Authenticated + NotRole("banned"), user has "editor" + "banned" → 403

not_permission_allows_user_without_restricted_permission
// → Authenticated + NotPermission("delete"), user has "read" only → 200

not_permission_blocks_user_with_restricted_permission
// → Authenticated + NotPermission("delete"), user has "read" + "delete" → 403
```

**`cargo test --features actix-web` passes. 48 total tests (8 + 27 + 13).**

---

## Stage 5 — tower Middleware Helper (`feature = "tower"`) ✓ DONE

**Goal:** A `tower::Layer` + `tower::Service` that enforces an `AccessPolicy`, compatible with axum and any tower-based framework.

**Files:** `src/tower_middleware/mod.rs`, `src/tower_middleware/layer.rs`

**What was done:**
- Implemented `AccessControlLayer<P>` and `AccessControlService<S, P>` in `src/tower_middleware/layer.rs`
- Re-exported from `src/tower_middleware/mod.rs`
- Added `into_tower_layer<P>()` convenience method on `AccessPolicy` (gated on `#[cfg(feature = "tower")]`)
- Added `tower`, `http`, `futures-util` optional deps; `axum` + `tokio` dev-deps
- Created `tests/tower_middleware.rs` with 7 passing tests

### `AccessControlLayer<P>` + `AccessControlService<S, P>`

```rust
#[derive(Clone)]
pub struct AccessControlLayer<P> {
    policy: AccessPolicy,
    _marker: PhantomData<fn(P)>,   // fn(P) → Send+Sync without P bound on struct
}

impl<S, P> Layer<S> for AccessControlLayer<P> {
    type Service = AccessControlService<S, P>;
    fn layer(&self, inner: S) -> Self::Service { ... }
}

#[derive(Clone)]
pub struct AccessControlService<S, P> {
    inner: S,
    policy: AccessPolicy,
    _marker: PhantomData<fn(P)>,
}
```

### `Service::call` flow
1. Extract `P` from `req.extensions().get::<P>().cloned()`
2. Call `policy.check(principal.as_ref().map(|p| p as &dyn Principal))`
3. `Authorized``Either::Left(self.inner.call(req))`
4. `Unauthorized``Either::Right(ready(Ok(401 response)))`
5. `Forbidden``Either::Right(ready(Ok(403 response)))`

### Future type — no pin-project

```rust
// Service::Future = futures_util::future::Either<S::Future, Ready<Result<Response<ResBody>, S::Error>>>
```

Both arms share the same `Output`, so `Either` implements `Future` directly. No `pin-project` needed.

`ResBody: Default` is required to construct the rejection response body (`axum::body::Body` satisfies this).

**Design notes:**
- `P: Principal + Clone + Send + Sync + 'static` required by `http::Extensions::get` (`Send + Sync` enforced at the `Service` impl, not the layer struct).
- `fn(P)` in `PhantomData` makes the layer and service `Send + Sync` without constraining `P` on the struct definition.
- No `axum` dep in the library — `tower`, `http`, `futures-util` are sufficient. `axum` is only a dev-dependency for tests.
- actix and tower implementations are independent — not unified.
- `tower 0.5` + `http 1` (not 0.2).

### Stage 5 Tests

**File:** `tests/tower_middleware.rs` — 7 tests, all passing (requires `#[cfg(feature = "tower")]`)

Uses `axum::Router` + `.layer()` + `tower::ServiceExt::oneshot()`. `TestUser` is inserted into request extensions before `oneshot`. Inner service short-circuit verified via `Arc<AtomicBool>` in the handler.

```
layer_allows_request_when_policy_passes
// → admin + Role("admin") → 200

layer_returns_403_when_role_missing
// → user with no roles + Role("admin") → 403

layer_returns_401_when_no_principal_in_extensions
// → no extension + Authenticated → 401

layer_short_circuits_without_calling_inner_service_on_rejection
// → Arc<AtomicBool> flag in handler stays false when 401 returned

and_policy_blocks_when_one_condition_fails
// → Authenticated + Role("admin"), user has "user" only → 403

or_policy_allows_when_one_condition_passes
// → Role("admin") | Role("editor"), user has "editor" → 200

layer_is_clone_and_send
// compile-time: assert_send(layer.clone()); assert_clone(layer)
```

**`cargo test --features tower` passes. `cargo test --all-features` passes. 55 total tests (8 + 27 + 13 + 7).**

---

## Stage 6 — Documentation and Examples

**Goal:** Rustdoc on all public items; runnable examples for each integration.

**Files:**
- `src/lib.rs` — crate-level doc with feature flag table
- All public types and methods — `///` doc comments with `# Examples` snippets
- `examples/basic.rs` — core policy system, no framework dep
- `examples/actix_web_example.rs``required-features = ["actix-web"]`
- `examples/axum_example.rs``required-features = ["tower"]`

Each example demonstrates:
1. Implementing `Principal` on a custom auth info type
2. Building an `AccessPolicy` with AND/OR rules
3. Wiring the middleware into the framework's routing system
4. Using the extractor in a handler

### Stage 6 Tests

- `cargo doc --no-deps --all-features` — no warnings (`RUSTDOCFLAGS=-D warnings`)
- All `# Examples` blocks in rustdoc are compiled via `cargo test --doc --all-features`
- `cargo run --example basic` exits with code 0
- `cargo run --example actix_web_example --features actix-web` compiles and starts without panic
- `cargo run --example axum_example --features tower` compiles and starts without panic

---

## Implementation Order

```
Stage 1 (Cargo.toml + lib.rs)
Stage 2 (error.rs + principal.rs)       ← no intra-crate deps
Stage 3 (policy.rs)                     ← depends on Stage 2
    ├────────────────────┐
    ▼                    ▼
Stage 4 (actix)      Stage 5 (tower)    ← independent of each other
    │                    │
    └────────────────────┘
           Stage 6 (docs + examples)
```

## Verification

After each stage, verify by running:

```sh
# Stages 1–3: core compiles with no features
cargo check

# Stage 4: actix integration compiles
cargo check --features actix-web

# Stage 5: tower integration compiles
cargo check --features tower

# All features together
cargo check --all-features

# Run examples
cargo run --example basic
cargo run --example actix_web_example --features actix-web
cargo run --example axum_example --features tower

# Tests
cargo test
cargo test --all-features
```

## Known Challenges

1. **Object safety**: verify `&dyn Principal` coercion works in tests before Stage 3 is complete.
2. **`ResBody: Default` for tower rejections**: `axum::body::Body` satisfies this; document the constraint clearly. A `with_rejection_fn` builder can be added later for full control.
3. **actix `LocalBoxFuture` is `!Send`**: expected — do not attempt to make actix types `Send`.
4. **Turbofish on `.into_actix_middleware::<MyUser>()`**: users must specify `P`. Consider also exposing `AccessRule::role("admin").into_actix_middleware::<MyUser>()` as a convenience shorthand.