1use 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
13pub 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 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, };
53
54 if active_org == resource_org {
55 PolicyDecision::Abstain } 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
68pub struct TimeWindowPolicy {
77 start_hour: u32,
79 end_hour: u32,
81 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
147pub struct IpAllowListPolicy {
158 allowed_prefixes: Vec<String>,
162}
163
164impl IpAllowListPolicy {
165 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 if ip.is_empty() {
184 return PolicyDecision::Abstain;
185 }
186
187 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 } else {
201 tracing::warn!(ip = %ip, action = ctx.action, "ip_allow_list: blocked");
202 PolicyDecision::Deny
203 }
204 }
205}
206
207pub struct RequireEmailVerifiedPolicy {
212 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 #[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 #[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 #[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}