Skip to main content

authx_core/policy/
builtin.rs

1/// Built-in ABAC policy implementations ready for use with [`AuthzEngine`].
2///
3/// Compose these with `engine.add_policy(...)` or use them as reference
4/// implementations for custom policies.
5use std::collections::HashSet;
6use std::net::IpAddr;
7
8use async_trait::async_trait;
9use chrono::{Datelike, Timelike, Utc, Weekday};
10
11use super::engine::{AuthzContext, Policy, PolicyDecision};
12
13// ── OrgBoundaryPolicy ─────────────────────────────────────────────────────────
14
15/// Denies any action when the identity carries an org context but the
16/// current request targets a resource from a *different* org.
17///
18/// Resource IDs are expected in the form `"org:<uuid>:<rest>"`.
19/// If the resource ID doesn't match that prefix the policy abstains.
20///
21/// # Example
22/// ```rust,ignore
23/// engine.add_policy(OrgBoundaryPolicy);
24/// engine.enforce("read", &identity, Some("org:550e8400-e29b-41d4-a716-446655440000:report")).await?;
25/// ```
26pub struct OrgBoundaryPolicy;
27
28#[async_trait]
29impl Policy for OrgBoundaryPolicy {
30    fn name(&self) -> &'static str {
31        "org_boundary"
32    }
33
34    async fn evaluate(&self, ctx: &AuthzContext<'_>) -> PolicyDecision {
35        let Some(resource_id) = ctx.resource_id else {
36            return PolicyDecision::Abstain;
37        };
38
39        // Expected format: "org:<org_uuid>:<rest>"
40        let Some(rest) = resource_id.strip_prefix("org:") else {
41            return PolicyDecision::Abstain;
42        };
43
44        let resource_org = rest.split(':').next().unwrap_or("");
45        if resource_org.is_empty() {
46            return PolicyDecision::Abstain;
47        }
48
49        let active_org = match &ctx.identity.active_org {
50            Some(o) => o.id.to_string(),
51            None => return PolicyDecision::Deny, // resource is org-scoped; no active org → deny
52        };
53
54        if active_org == resource_org {
55            PolicyDecision::Abstain // let RBAC make the final call
56        } else {
57            tracing::warn!(
58                user_id  = %ctx.identity.user.id,
59                active   = %active_org,
60                resource = %resource_org,
61                "org boundary violation"
62            );
63            PolicyDecision::Deny
64        }
65    }
66}
67
68// ── TimeWindowPolicy ──────────────────────────────────────────────────────────
69
70/// Restricts actions to specific hours (UTC) and optional weekdays.
71///
72/// # Example — allow only on weekdays between 09:00 and 18:00 UTC
73/// ```rust,ignore
74/// engine.add_policy(TimeWindowPolicy::weekdays(9, 18));
75/// ```
76pub struct TimeWindowPolicy {
77    /// Inclusive start hour (0–23, UTC).
78    start_hour: u32,
79    /// Exclusive end hour (0–23, UTC).
80    end_hour: u32,
81    /// If `Some`, only allow on these weekdays.
82    weekdays: Option<HashSet<Weekday>>,
83}
84
85impl TimeWindowPolicy {
86    pub fn new(start_hour: u32, end_hour: u32) -> Self {
87        Self {
88            start_hour,
89            end_hour,
90            weekdays: None,
91        }
92    }
93
94    pub fn weekdays(start_hour: u32, end_hour: u32) -> Self {
95        use Weekday::*;
96        Self {
97            start_hour,
98            end_hour,
99            weekdays: Some([Mon, Tue, Wed, Thu, Fri].into()),
100        }
101    }
102
103    pub fn with_days(mut self, days: impl IntoIterator<Item = Weekday>) -> Self {
104        self.weekdays = Some(days.into_iter().collect());
105        self
106    }
107}
108
109#[async_trait]
110impl Policy for TimeWindowPolicy {
111    fn name(&self) -> &'static str {
112        "time_window"
113    }
114
115    async fn evaluate(&self, ctx: &AuthzContext<'_>) -> PolicyDecision {
116        let now = Utc::now();
117        let hour = now.hour();
118
119        if let Some(days) = &self.weekdays
120            && !days.contains(&now.weekday())
121        {
122            tracing::warn!(
123                user_id = %ctx.identity.user.id,
124                action  = ctx.action,
125                weekday = ?now.weekday(),
126                "time_window: wrong weekday"
127            );
128            return PolicyDecision::Deny;
129        }
130
131        if hour >= self.start_hour && hour < self.end_hour {
132            PolicyDecision::Abstain
133        } else {
134            tracing::warn!(
135                user_id     = %ctx.identity.user.id,
136                action      = ctx.action,
137                hour        = hour,
138                start_hour  = self.start_hour,
139                end_hour    = self.end_hour,
140                "time_window: outside allowed hours"
141            );
142            PolicyDecision::Deny
143        }
144    }
145}
146
147// ── IpAllowListPolicy ─────────────────────────────────────────────────────────
148
149/// Denies actions from IPs not in the allow-list.
150///
151/// The IP is read from `identity.session.ip_address`.
152///
153/// # Example
154/// ```rust,ignore
155/// engine.add_policy(IpAllowListPolicy::new(["10.0.0.0/8", "192.168.1.0/24"]));
156/// ```
157pub struct IpAllowListPolicy {
158    /// Allowed IP prefixes (CIDR prefix string, e.g. `"10.0."`, `"192.168.1."`).
159    /// For simplicity this matches on string prefix rather than a CIDR library
160    /// to keep authx-core dependency-free.
161    allowed_prefixes: Vec<String>,
162}
163
164impl IpAllowListPolicy {
165    /// Accepts CIDR-style prefix strings (`"10.0."`, `"192.168.1.0"`, full IPs).
166    pub fn new(prefixes: impl IntoIterator<Item = impl Into<String>>) -> Self {
167        Self {
168            allowed_prefixes: prefixes.into_iter().map(|s| s.into()).collect(),
169        }
170    }
171}
172
173#[async_trait]
174impl Policy for IpAllowListPolicy {
175    fn name(&self) -> &'static str {
176        "ip_allow_list"
177    }
178
179    async fn evaluate(&self, ctx: &AuthzContext<'_>) -> PolicyDecision {
180        let ip = &ctx.identity.session.ip_address;
181
182        // Empty IP (e.g. tests without ConnectInfo) → abstain.
183        if ip.is_empty() {
184            return PolicyDecision::Abstain;
185        }
186
187        // Parse as IpAddr for exact matching, fall back to prefix match.
188        let parsed: Option<IpAddr> = ip.parse().ok();
189
190        let allowed = self.allowed_prefixes.iter().any(|prefix| {
191            if let (Some(client), Ok(allowed_ip)) = (parsed, prefix.parse::<IpAddr>()) {
192                client == allowed_ip
193            } else {
194                ip.starts_with(prefix.as_str())
195            }
196        });
197
198        if allowed {
199            PolicyDecision::Abstain // IP is fine; let RBAC decide
200        } else {
201            tracing::warn!(ip = %ip, action = ctx.action, "ip_allow_list: blocked");
202            PolicyDecision::Deny
203        }
204    }
205}
206
207// ── RequireEmailVerifiedPolicy ────────────────────────────────────────────────
208
209/// Denies sensitive actions (configurable action prefix) when the user's
210/// email is not verified.
211pub struct RequireEmailVerifiedPolicy {
212    /// Only enforce on actions with this prefix (e.g. `"admin."`, `"billing."`).
213    /// `None` = enforce on all actions.
214    action_prefix: Option<String>,
215}
216
217impl RequireEmailVerifiedPolicy {
218    pub fn all_actions() -> Self {
219        Self {
220            action_prefix: None,
221        }
222    }
223
224    pub fn for_prefix(prefix: impl Into<String>) -> Self {
225        Self {
226            action_prefix: Some(prefix.into()),
227        }
228    }
229}
230
231#[async_trait]
232impl Policy for RequireEmailVerifiedPolicy {
233    fn name(&self) -> &'static str {
234        "require_email_verified"
235    }
236
237    async fn evaluate(&self, ctx: &AuthzContext<'_>) -> PolicyDecision {
238        if let Some(prefix) = &self.action_prefix
239            && !ctx.action.starts_with(prefix.as_str())
240        {
241            return PolicyDecision::Abstain;
242        }
243
244        if ctx.identity.user.email_verified {
245            PolicyDecision::Abstain
246        } else {
247            tracing::warn!(
248                user_id = %ctx.identity.user.id,
249                action  = ctx.action,
250                "require_email_verified: email not verified"
251            );
252            PolicyDecision::Deny
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::{
261        identity::Identity,
262        models::{Session, User},
263        policy::engine::{AuthzContext, Policy, PolicyDecision},
264    };
265    use chrono::Utc;
266    use uuid::Uuid;
267
268    fn dummy_user(verified: bool) -> User {
269        User {
270            id: Uuid::new_v4(),
271            email: "test@example.com".into(),
272            email_verified: verified,
273            username: None,
274            created_at: Utc::now(),
275            updated_at: Utc::now(),
276            metadata: serde_json::Value::Null,
277        }
278    }
279
280    fn dummy_session(ip: &str) -> Session {
281        Session {
282            id: Uuid::new_v4(),
283            user_id: Uuid::new_v4(),
284            token_hash: "hash".into(),
285            device_info: serde_json::Value::Null,
286            ip_address: ip.into(),
287            org_id: None,
288            expires_at: Utc::now() + chrono::Duration::hours(1),
289            created_at: Utc::now(),
290        }
291    }
292
293    fn identity(user: User, session: Session) -> Identity {
294        Identity::new(user, session)
295    }
296
297    // ── IpAllowListPolicy ────────────────────────────────────────────────────
298
299    #[tokio::test]
300    async fn ip_allow_list_permits_matching_ip() {
301        let policy = IpAllowListPolicy::new(["10.0.0.1"]);
302        let id = identity(dummy_user(true), dummy_session("10.0.0.1"));
303        let ctx = AuthzContext {
304            action: "read",
305            identity: &id,
306            resource_id: None,
307        };
308        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
309    }
310
311    #[tokio::test]
312    async fn ip_allow_list_denies_non_matching_ip() {
313        let policy = IpAllowListPolicy::new(["10.0.0.1"]);
314        let id = identity(dummy_user(true), dummy_session("192.168.1.1"));
315        let ctx = AuthzContext {
316            action: "read",
317            identity: &id,
318            resource_id: None,
319        };
320        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Deny);
321    }
322
323    #[tokio::test]
324    async fn ip_allow_list_abstains_on_empty_ip() {
325        let policy = IpAllowListPolicy::new(["10.0.0.1"]);
326        let id = identity(dummy_user(true), dummy_session(""));
327        let ctx = AuthzContext {
328            action: "read",
329            identity: &id,
330            resource_id: None,
331        };
332        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
333    }
334
335    // ── RequireEmailVerifiedPolicy ───────────────────────────────────────────
336
337    #[tokio::test]
338    async fn email_verified_policy_abstains_when_verified() {
339        let policy = RequireEmailVerifiedPolicy::all_actions();
340        let id = identity(dummy_user(true), dummy_session("127.0.0.1"));
341        let ctx = AuthzContext {
342            action: "admin.delete",
343            identity: &id,
344            resource_id: None,
345        };
346        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
347    }
348
349    #[tokio::test]
350    async fn email_verified_policy_denies_when_not_verified() {
351        let policy = RequireEmailVerifiedPolicy::all_actions();
352        let id = identity(dummy_user(false), dummy_session("127.0.0.1"));
353        let ctx = AuthzContext {
354            action: "admin.delete",
355            identity: &id,
356            resource_id: None,
357        };
358        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Deny);
359    }
360
361    #[tokio::test]
362    async fn email_verified_abstains_for_non_matching_prefix() {
363        let policy = RequireEmailVerifiedPolicy::for_prefix("admin.");
364        let id = identity(dummy_user(false), dummy_session("127.0.0.1"));
365        let ctx = AuthzContext {
366            action: "read.profile",
367            identity: &id,
368            resource_id: None,
369        };
370        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
371    }
372
373    // ── OrgBoundaryPolicy ────────────────────────────────────────────────────
374
375    #[tokio::test]
376    async fn org_boundary_abstains_when_no_resource_id() {
377        let policy = OrgBoundaryPolicy;
378        let id = identity(dummy_user(true), dummy_session("127.0.0.1"));
379        let ctx = AuthzContext {
380            action: "read",
381            identity: &id,
382            resource_id: None,
383        };
384        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
385    }
386
387    #[tokio::test]
388    async fn org_boundary_abstains_for_unscoped_resource() {
389        let policy = OrgBoundaryPolicy;
390        let id = identity(dummy_user(true), dummy_session("127.0.0.1"));
391        let ctx = AuthzContext {
392            action: "read",
393            identity: &id,
394            resource_id: Some("global:thing"),
395        };
396        assert_eq!(policy.evaluate(&ctx).await, PolicyDecision::Abstain);
397    }
398}