1use std::{
27 collections::{BTreeMap, HashSet},
28 net::IpAddr,
29 sync::{LazyLock, Mutex},
30};
31
32use chrono::{DateTime, Utc};
33use serde::{Deserialize, Serialize};
34use serde_json::json;
35use sqlx::{PgConnection, PgPool};
36use uuid::Uuid;
37
38use crate::{
39 auth::Actor,
40 domain::ProjectVisibility,
41 error::{AppError, AppResult},
42 services::{access, audit, project_policy, projects},
43};
44
45pub const MAX_CLAIMS: usize = 10;
47pub const MAX_VALUES: usize = 20;
49pub const MAX_NETWORKS: usize = 50;
51pub const MAX_SIGNIN_AGE_HOURS: i32 = 8760;
53const MAX_STORED_VALUES: usize = 50;
55const MAX_VALUE_CHARS: usize = 200;
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ClaimCondition {
62 pub claim: String,
63 pub values: Vec<String>,
64}
65
66#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Conditions {
69 pub required_claims: Vec<ClaimCondition>,
70 pub allowed_networks: Vec<String>,
71 pub max_signin_age_hours: Option<i32>,
72}
73
74impl Conditions {
75 pub fn is_empty(&self) -> bool {
76 self.required_claims.is_empty()
77 && self.allowed_networks.is_empty()
78 && self.max_signin_age_hours.is_none()
79 }
80}
81
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub struct Network {
90 addr: IpAddr,
91 prefix: u8,
92}
93
94impl Network {
95 pub fn parse(raw: &str) -> Result<Network, String> {
96 let raw = raw.trim();
97 let (addr, prefix) = match raw.split_once('/') {
98 Some((a, p)) => (a.trim(), Some(p.trim())),
99 None => (raw, None),
100 };
101 let addr: IpAddr = addr
102 .parse()
103 .map_err(|_| format!("'{raw}' is not an address range such as 10.0.0.0/8."))?;
104 let max = if addr.is_ipv4() { 32 } else { 128 };
105 let prefix = match prefix {
106 None => max,
107 Some(p) => p
108 .parse::<u8>()
109 .ok()
110 .filter(|p| *p <= max)
111 .ok_or_else(|| format!("'{raw}': the prefix length is 0 to {max}."))?,
112 };
113 let net = Network { addr, prefix };
114 if net.masked() != addr {
115 return Err(format!(
116 "'{raw}' has host bits set; the range starts at {}/{prefix}.",
117 net.masked()
118 ));
119 }
120 Ok(net)
121 }
122
123 fn masked(&self) -> IpAddr {
124 match self.addr {
125 IpAddr::V4(a) => {
126 let mask = u32::MAX.checked_shl(32 - self.prefix as u32).unwrap_or(0);
127 IpAddr::V4((u32::from(a) & mask).into())
128 }
129 IpAddr::V6(a) => {
130 let mask = u128::MAX.checked_shl(128 - self.prefix as u32).unwrap_or(0);
131 IpAddr::V6((u128::from(a) & mask).into())
132 }
133 }
134 }
135
136 pub fn contains(&self, ip: IpAddr) -> bool {
139 match (self.addr, ip.to_canonical()) {
140 (IpAddr::V4(net), IpAddr::V4(ip)) => {
141 let mask = u32::MAX.checked_shl(32 - self.prefix as u32).unwrap_or(0);
142 u32::from(net) & mask == u32::from(ip) & mask
143 }
144 (IpAddr::V6(net), IpAddr::V6(ip)) => {
145 let mask = u128::MAX.checked_shl(128 - self.prefix as u32).unwrap_or(0);
146 u128::from(net) & mask == u128::from(ip) & mask
147 }
148 _ => false,
149 }
150 }
151
152 fn covers(&self, other: &Network) -> bool {
154 self.addr.is_ipv4() == other.addr.is_ipv4()
155 && other.prefix >= self.prefix
156 && self.contains(other.addr)
157 }
158}
159
160impl std::fmt::Display for Network {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 write!(f, "{}/{}", self.addr, self.prefix)
163 }
164}
165
166pub fn network_allows(networks: &[String], client: Option<IpAddr>) -> bool {
168 let Some(ip) = client else { return false };
169 networks
170 .iter()
171 .filter_map(|n| Network::parse(n).ok())
172 .any(|n| n.contains(ip))
173}
174
175fn valid_claim_name(name: &str) -> bool {
180 !name.is_empty()
181 && name.len() <= 100
182 && name
183 .chars()
184 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':' | '/'))
185}
186
187fn normal_value(v: &str) -> String {
189 v.trim()
190 .to_lowercase()
191 .chars()
192 .take(MAX_VALUE_CHARS)
193 .collect()
194}
195
196pub fn validate(c: Conditions) -> AppResult<Conditions> {
200 let bad = |m: String| AppError::BadRequest(m);
201 if c.required_claims.len() > MAX_CLAIMS {
202 return Err(bad(format!("At most {MAX_CLAIMS} claim conditions.")));
203 }
204 let mut claims: Vec<ClaimCondition> = Vec::new();
205 for cond in c.required_claims {
206 let claim = cond.claim.trim().to_string();
207 if !valid_claim_name(&claim) {
208 return Err(bad(format!(
209 "'{claim}' is not a claim name (letters, digits, _ - . : /)."
210 )));
211 }
212 let mut values: Vec<String> = cond
213 .values
214 .iter()
215 .map(|v| normal_value(v))
216 .filter(|v| !v.is_empty())
217 .collect();
218 values.sort();
219 values.dedup();
220 if values.is_empty() || values.len() > MAX_VALUES {
221 return Err(bad(format!(
222 "The claim '{claim}' needs 1 to {MAX_VALUES} accepted values."
223 )));
224 }
225 if claims.iter().any(|c| c.claim == claim) {
226 return Err(bad(format!("The claim '{claim}' is named twice.")));
227 }
228 claims.push(ClaimCondition { claim, values });
229 }
230 if c.allowed_networks.len() > MAX_NETWORKS {
231 return Err(bad(format!("At most {MAX_NETWORKS} address ranges.")));
232 }
233 let mut networks: Vec<String> = Vec::new();
234 for raw in &c.allowed_networks {
235 let raw = raw.trim();
236 if raw.is_empty() {
237 continue;
238 }
239 let net = Network::parse(raw).map_err(bad)?.to_string();
240 if !networks.contains(&net) {
241 networks.push(net);
242 }
243 }
244 if let Some(h) = c.max_signin_age_hours {
245 if !(1..=MAX_SIGNIN_AGE_HOURS).contains(&h) {
246 return Err(bad(format!(
247 "The maximum sign-in age is 1 to {MAX_SIGNIN_AGE_HOURS} hours."
248 )));
249 }
250 }
251 Ok(Conditions {
252 required_claims: claims,
253 allowed_networks: networks,
254 max_signin_age_hours: c.max_signin_age_hours,
255 })
256}
257
258pub fn parse_claim_lines(text: &str) -> AppResult<Vec<ClaimCondition>> {
260 text.lines()
261 .map(str::trim)
262 .filter(|l| !l.is_empty())
263 .map(|line| {
264 let (claim, values) = line.split_once('=').ok_or_else(|| {
265 AppError::BadRequest(format!(
266 "'{line}': write a condition as claim = value, value."
267 ))
268 })?;
269 Ok(ClaimCondition {
270 claim: claim.trim().to_string(),
271 values: values.split(',').map(|v| v.trim().to_string()).collect(),
272 })
273 })
274 .collect()
275}
276
277pub fn claim_lines(claims: &[ClaimCondition]) -> String {
279 claims
280 .iter()
281 .map(|c| format!("{} = {}", c.claim, c.values.join(", ")))
282 .collect::<Vec<_>>()
283 .join("\n")
284}
285
286pub fn at_least_as_strict(new: &Conditions, old: &Conditions) -> bool {
290 let claims = old.required_claims.iter().all(|o| {
291 new.required_claims
292 .iter()
293 .any(|n| n.claim == o.claim && n.values.iter().all(|v| o.values.contains(v)))
294 });
295 let networks = old.allowed_networks.is_empty()
296 || (!new.allowed_networks.is_empty()
297 && new.allowed_networks.iter().all(|n| {
298 let Ok(n) = Network::parse(n) else {
299 return false;
300 };
301 old.allowed_networks
302 .iter()
303 .filter_map(|o| Network::parse(o).ok())
304 .any(|o| o.covers(&n))
305 }));
306 let age = match (old.max_signin_age_hours, new.max_signin_age_hours) {
307 (None, _) => true,
308 (Some(_), None) => false,
309 (Some(o), Some(n)) => n <= o,
310 };
311 claims && networks && age
312}
313
314pub fn claims_to_store(
318 claims: &BTreeMap<String, Vec<String>>,
319 wanted: &[String],
320) -> serde_json::Value {
321 let mut out = serde_json::Map::new();
322 for name in wanted {
323 if let Some(values) = claims.get(name) {
324 let values: Vec<String> = values
325 .iter()
326 .map(|v| normal_value(v))
327 .filter(|v| !v.is_empty())
328 .take(MAX_STORED_VALUES)
329 .collect();
330 out.insert(name.clone(), json!(values));
331 }
332 }
333 serde_json::Value::Object(out)
334}
335
336pub fn claim_values(claims: &serde_json::Value) -> BTreeMap<String, Vec<String>> {
338 use serde_json::Value;
339 let scalar = |v: &Value| match v {
340 Value::String(s) => Some(s.clone()),
341 Value::Bool(b) => Some(b.to_string()),
342 Value::Number(n) => Some(n.to_string()),
343 _ => None,
344 };
345 let mut out = BTreeMap::new();
346 if let Value::Object(map) = claims {
347 for (k, v) in map {
348 let values: Vec<String> = match v {
349 Value::Array(list) => list.iter().filter_map(scalar).collect(),
350 other => scalar(other).into_iter().collect(),
351 };
352 if !values.is_empty() {
353 out.insert(k.clone(), values);
354 }
355 }
356 }
357 out
358}
359
360pub fn unmet_sql(u: &str, pp: &str) -> String {
368 format!(
369 "COALESCE((jsonb_array_length({pp}.required_claims) > 0
370 AND EXISTS (SELECT 1 FROM jsonb_array_elements({pp}.required_claims) mrc
371 WHERE NOT (COALESCE({u}.idp_claims -> (mrc->>'claim'), '[]'::jsonb)
372 ?| ARRAY(SELECT jsonb_array_elements_text(mrc->'values')))))
373 OR ({pp}.max_signin_age_hours IS NOT NULL
374 AND ({u}.last_login_at IS NULL
375 OR {u}.last_login_at
376 < now() - make_interval(hours => {pp}.max_signin_age_hours))),
377 FALSE)"
378 )
379}
380
381pub async fn of(conn: &mut PgConnection, project_id: Uuid) -> AppResult<Conditions> {
383 let row: Option<(serde_json::Value, Vec<String>, Option<i32>)> = sqlx::query_as(
384 "SELECT required_claims, allowed_networks, max_signin_age_hours
385 FROM project_policies WHERE project_id = $1",
386 )
387 .bind(project_id)
388 .fetch_optional(&mut *conn)
389 .await?;
390 Ok(match row {
391 None => Conditions::default(),
392 Some((claims, networks, age)) => Conditions {
393 required_claims: serde_json::from_value(claims).unwrap_or_default(),
394 allowed_networks: networks,
395 max_signin_age_hours: age,
396 },
397 })
398}
399
400pub async fn wanted_claims(conn: &mut PgConnection) -> AppResult<Vec<String>> {
403 Ok(sqlx::query_scalar(
404 "SELECT DISTINCT c->>'claim' FROM project_policies pp,
405 jsonb_array_elements(pp.required_claims) c
406 WHERE c->>'claim' IS NOT NULL ORDER BY 1",
407 )
408 .fetch_all(&mut *conn)
409 .await?)
410}
411
412pub(crate) async fn store_claims(
415 conn: &mut PgConnection,
416 user_id: Uuid,
417 claims: &BTreeMap<String, Vec<String>>,
418) -> AppResult<()> {
419 let wanted = wanted_claims(conn).await?;
420 sqlx::query("UPDATE users SET idp_claims = $2, idp_claims_at = now() WHERE id = $1")
421 .bind(user_id)
422 .bind(claims_to_store(claims, &wanted))
423 .execute(&mut *conn)
424 .await?;
425 Ok(())
426}
427
428#[derive(Clone, Debug, Default)]
434pub struct Origin {
435 pub client: Option<IpAddr>,
437 pub session_started: Option<DateTime<Utc>>,
440 pub key: Option<String>,
443 pub token: bool,
446}
447
448#[derive(sqlx::FromRow)]
449struct Conditioned {
450 project_id: Uuid,
451 code: String,
452 allowed_networks: Vec<String>,
453 stored_unmet: bool,
454 member: bool,
455 session_too_old: bool,
456}
457
458static RECORDED: LazyLock<Mutex<HashSet<(String, Uuid)>>> =
460 LazyLock::new(|| Mutex::new(HashSet::new()));
461const MAX_RECORDED: usize = 10_000;
462
463pub async fn apply(pool: &PgPool, actor: Actor, origin: &Origin) -> AppResult<Actor> {
469 if actor.id.is_nil() {
470 return Ok(actor);
471 }
472 let rows = sqlx::query_as::<_, Conditioned>(&format!(
473 "SELECT p.id AS project_id, p.code, pp.allowed_networks,
474 {stored} AS stored_unmet,
475 EXISTS (SELECT 1 FROM project_members pm
476 WHERE pm.project_id = p.id AND pm.user_id = u.id) AS member,
477 COALESCE(pp.max_signin_age_hours IS NOT NULL AND $2::timestamptz IS NOT NULL
478 AND $2 < now() - make_interval(hours => pp.max_signin_age_hours), FALSE)
479 AS session_too_old
480 FROM project_policies pp
481 JOIN projects p ON p.id = pp.project_id
482 JOIN users u ON u.id = $1
483 WHERE p.visibility = 'restricted'
484 AND (jsonb_array_length(pp.required_claims) > 0
485 OR cardinality(pp.allowed_networks) > 0
486 OR pp.max_signin_age_hours IS NOT NULL)",
487 stored = unmet_sql("u", "pp")
488 ))
489 .bind(actor.id)
490 .bind(origin.session_started)
491 .fetch_all(pool)
492 .await?;
493 if rows.is_empty() {
494 return Ok(actor);
495 }
496 let mut hide = Vec::new();
497 let mut refused = Vec::new();
498 for r in &rows {
499 let network =
500 !r.allowed_networks.is_empty() && !network_allows(&r.allowed_networks, origin.client);
501 if network || r.session_too_old {
502 hide.push(r.project_id);
503 }
504 if r.member && (network || r.session_too_old || r.stored_unmet) {
505 let reason = if network {
506 "network"
507 } else if r.session_too_old {
508 "session_age"
509 } else {
510 "claims_or_sign_in_age"
511 };
512 refused.push((r.project_id, r.code.clone(), reason));
513 }
514 }
515 if let Some(key) = &origin.key {
516 record_refusals(pool, &actor, key, origin.client, &refused).await?;
517 }
518 Ok(if hide.is_empty() {
519 actor
520 } else {
521 actor.without_groups(&hide)
522 })
523}
524
525async fn record_refusals(
528 pool: &PgPool,
529 actor: &Actor,
530 key: &str,
531 client: Option<IpAddr>,
532 refused: &[(Uuid, String, &str)],
533) -> AppResult<()> {
534 let fresh: Vec<&(Uuid, String, &str)> = {
535 let mut seen = RECORDED.lock().unwrap_or_else(|e| e.into_inner());
536 if seen.len() >= MAX_RECORDED {
537 seen.clear();
538 }
539 refused
540 .iter()
541 .filter(|(id, _, _)| seen.insert((key.to_string(), *id)))
542 .collect()
543 };
544 if fresh.is_empty() {
545 return Ok(());
546 }
547 let mut tx = pool.begin().await?;
548 for (id, code, reason) in fresh {
549 audit::record(
550 &mut tx,
551 Some(actor),
552 "project.access_refused",
553 "project",
554 Some(*id),
555 format!(
556 "{} does not meet the conditions of {code} ({reason}): the group is hidden",
557 actor.username
558 ),
559 json!({ "reason": reason, "client": client.map(|c| c.to_string()) }),
560 )
561 .await?;
562 }
563 tx.commit().await?;
564 Ok(())
565}
566
567#[derive(Clone, Debug, Default)]
573pub struct ConditionsUpdate {
574 pub required_claims: Option<Vec<ClaimCondition>>,
575 pub allowed_networks: Option<Vec<String>>,
576 pub max_signin_age_hours: Option<Option<i32>>,
578}
579
580pub async fn set(
600 pool: &PgPool,
601 actor: &Actor,
602 project_id: Uuid,
603 update: ConditionsUpdate,
604 origin: &Origin,
605) -> AppResult<Conditions> {
606 let mut tx = pool.begin().await?;
607 let project = sqlx::query_as::<_, (String, String)>(
608 "SELECT code, visibility FROM projects WHERE id = $1 FOR UPDATE",
609 )
610 .bind(project_id)
611 .fetch_optional(&mut *tx)
612 .await?
613 .ok_or_else(access::not_found_project)?;
614 projects::require_manager(&mut tx, actor, project_id).await?;
615 if project.1 != ProjectVisibility::Restricted.as_str() {
616 return Err(AppError::Conflict(format!(
617 "{} is not restricted: restrict the group first, the conditions build on that.",
618 project.0
619 )));
620 }
621 let old = of(&mut tx, project_id).await?;
622 let new = validate(Conditions {
623 required_claims: update
624 .required_claims
625 .unwrap_or_else(|| old.required_claims.clone()),
626 allowed_networks: update
627 .allowed_networks
628 .unwrap_or_else(|| old.allowed_networks.clone()),
629 max_signin_age_hours: update
630 .max_signin_age_hours
631 .unwrap_or(old.max_signin_age_hours),
632 })?;
633 if new == old {
634 tx.rollback().await?;
635 return Ok(old);
636 }
637 if !at_least_as_strict(&new, &old) {
638 project_policy::require_member_manager_under_policy(
639 &mut tx,
640 actor,
641 project_id,
642 "Loosening the conditions on membership",
643 )
644 .await?;
645 }
646 lockout_check(&mut tx, actor, project_id, &new, origin).await?;
647 sqlx::query(
648 "INSERT INTO project_policies (project_id, required_claims, allowed_networks,
649 max_signin_age_hours, created_by, updated_by)
650 VALUES ($1, $2, $3, $4, $5, $5)
651 ON CONFLICT (project_id) DO UPDATE
652 SET required_claims = EXCLUDED.required_claims,
653 allowed_networks = EXCLUDED.allowed_networks,
654 max_signin_age_hours = EXCLUDED.max_signin_age_hours,
655 updated_by = EXCLUDED.updated_by, updated_at = now()",
656 )
657 .bind(project_id)
658 .bind(json!(new.required_claims))
659 .bind(&new.allowed_networks)
660 .bind(new.max_signin_age_hours)
661 .bind(actor.id)
662 .execute(&mut *tx)
663 .await?;
664 audit::record(
665 &mut tx,
666 Some(actor),
667 "project.conditions",
668 "project",
669 Some(project_id),
670 format!("Conditions on membership of {} changed", project.0),
671 json!({ "old": old, "new": new }),
672 )
673 .await?;
674 project_policy::alert_managers(
675 &mut tx,
676 actor,
677 project_id,
678 format!("Conditions on membership of {} changed", project.0),
679 format!(
680 "{} changed the conditions on membership of {}.",
681 actor.username, project.0
682 ),
683 )
684 .await?;
685 tx.commit().await?;
686 Ok(new)
687}
688
689async fn lockout_check(
692 conn: &mut PgConnection,
693 actor: &Actor,
694 project_id: Uuid,
695 new: &Conditions,
696 origin: &Origin,
697) -> AppResult<()> {
698 let member: bool = sqlx::query_scalar(
699 "SELECT EXISTS (SELECT 1 FROM project_members WHERE project_id = $1 AND user_id = $2)",
700 )
701 .bind(project_id)
702 .bind(actor.id)
703 .fetch_one(&mut *conn)
704 .await?;
705 if !member {
706 return Ok(());
707 }
708 let mut problems = Vec::new();
709 if !new.allowed_networks.is_empty() && !network_allows(&new.allowed_networks, origin.client) {
710 problems.push(match origin.client {
711 Some(ip) => format!("your address {ip} is in none of the ranges"),
712 None => "your address is not known".to_string(),
713 });
714 }
715 if !new.required_claims.is_empty() {
716 let stored: serde_json::Value =
717 sqlx::query_scalar("SELECT idp_claims FROM users WHERE id = $1")
718 .bind(actor.id)
719 .fetch_one(&mut *conn)
720 .await?;
721 for c in &new.required_claims {
722 let mine: Vec<String> = stored
723 .get(&c.claim)
724 .and_then(|v| serde_json::from_value(v.clone()).ok())
725 .unwrap_or_default();
726 if !mine.iter().any(|v| c.values.contains(v)) {
727 problems.push(format!(
728 "your last single sign-on did not give the claim '{}' one of the values \
729 (sign in again after the condition is set elsewhere)",
730 c.claim
731 ));
732 }
733 }
734 }
735 if let (Some(h), Some(started)) = (new.max_signin_age_hours, origin.session_started) {
736 if started < Utc::now() - chrono::Duration::hours(h as i64) {
737 problems.push(format!(
738 "you signed in more than {h} hours ago (sign in again first)"
739 ));
740 }
741 }
742 if problems.is_empty() {
743 Ok(())
744 } else {
745 Err(AppError::Conflict(format!(
746 "These conditions would hide the group from you: {}.",
747 problems.join("; ")
748 )))
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755
756 fn cond(claim: &str, values: &[&str]) -> ClaimCondition {
757 ClaimCondition {
758 claim: claim.into(),
759 values: values.iter().map(|v| v.to_string()).collect(),
760 }
761 }
762
763 #[test]
764 fn the_sql_fragments_are_balanced() {
765 for sql in [
766 unmet_sql("u", "pp"),
767 crate::services::access::hidden_of_user("u"),
768 ] {
769 let mut depth = 0i32;
770 for c in sql.chars() {
771 match c {
772 '(' => depth += 1,
773 ')' => depth -= 1,
774 _ => {}
775 }
776 assert!(depth >= 0, "closes too early: {sql}");
777 }
778 assert_eq!(depth, 0, "{sql}");
779 }
780 }
781
782 #[test]
783 fn networks_parse_and_match() {
784 let n = Network::parse("10.20.0.0/16").unwrap();
785 assert!(n.contains("10.20.3.4".parse().unwrap()));
786 assert!(!n.contains("10.21.0.1".parse().unwrap()));
787 assert!(
788 n.contains("::ffff:10.20.0.9".parse().unwrap()),
789 "IPv4 mapped into IPv6"
790 );
791 assert!(!n.contains("2001:db8::1".parse().unwrap()));
792 let host = Network::parse(" 192.0.2.7 ").unwrap();
793 assert_eq!(host.to_string(), "192.0.2.7/32");
794 assert!(host.contains("192.0.2.7".parse().unwrap()));
795 assert!(!host.contains("192.0.2.8".parse().unwrap()));
796 let v6 = Network::parse("2001:db8::/32").unwrap();
797 assert!(v6.contains("2001:db8:ffff::1".parse().unwrap()));
798 assert!(!v6.contains("2001:db9::1".parse().unwrap()));
799 let all = Network::parse("0.0.0.0/0").unwrap();
800 assert!(all.contains("203.0.113.1".parse().unwrap()));
801 for bad in ["10.0.0.1/8", "10.0.0.0/33", "nope", "10.0.0.0/x", "::/129"] {
802 assert!(Network::parse(bad).is_err(), "{bad}");
803 }
804 assert!(network_allows(
805 &["10.0.0.0/8".into()],
806 "10.1.1.1".parse().ok()
807 ));
808 assert!(
809 !network_allows(&["10.0.0.0/8".into()], None),
810 "unknown client"
811 );
812 }
813
814 #[test]
815 fn conditions_are_validated_and_normalised() {
816 let c = validate(Conditions {
817 required_claims: vec![cond(
818 " site ",
819 &["Main-Office", " partner ", "main-office", ""],
820 )],
821 allowed_networks: vec!["10.0.0.0/8".into(), " ".into(), "10.0.0.0/8".into()],
822 max_signin_age_hours: Some(12),
823 })
824 .unwrap();
825 assert_eq!(
826 c.required_claims,
827 vec![cond("site", &["main-office", "partner"])]
828 );
829 assert_eq!(c.allowed_networks, vec!["10.0.0.0/8".to_string()]);
830 for bad in [
831 Conditions {
832 required_claims: vec![cond("site", &[" "])],
833 ..Default::default()
834 },
835 Conditions {
836 required_claims: vec![cond("bad name", &["x"])],
837 ..Default::default()
838 },
839 Conditions {
840 required_claims: vec![cond("a", &["x"]), cond("a", &["y"])],
841 ..Default::default()
842 },
843 Conditions {
844 allowed_networks: vec!["10.0.0.1/8".into()],
845 ..Default::default()
846 },
847 Conditions {
848 max_signin_age_hours: Some(0),
849 ..Default::default()
850 },
851 Conditions {
852 max_signin_age_hours: Some(MAX_SIGNIN_AGE_HOURS + 1),
853 ..Default::default()
854 },
855 ] {
856 assert!(matches!(validate(bad), Err(AppError::BadRequest(_))));
857 }
858 }
859
860 #[test]
861 fn claim_lines_round_trip() {
862 let parsed = parse_claim_lines("site = a, b\n\n nda=true \n").unwrap();
863 assert_eq!(
864 parsed,
865 vec![cond("site", &["a", "b"]), cond("nda", &["true"])]
866 );
867 assert_eq!(claim_lines(&parsed), "site = a, b\nnda = true");
868 assert!(parse_claim_lines("site").is_err());
869 }
870
871 #[test]
872 fn strictness_tells_tightening_from_loosening() {
873 let base = Conditions {
874 required_claims: vec![cond("site", &["a", "b"])],
875 allowed_networks: vec!["10.0.0.0/8".into()],
876 max_signin_age_hours: Some(24),
877 };
878 let tighter = Conditions {
880 required_claims: vec![cond("site", &["a"]), cond("nda", &["true"])],
881 allowed_networks: vec!["10.1.0.0/16".into()],
882 max_signin_age_hours: Some(8),
883 };
884 assert!(at_least_as_strict(&tighter, &base));
885 assert!(at_least_as_strict(&base, &base));
886 assert!(!at_least_as_strict(&base, &tighter));
887 for looser in [
888 Conditions {
889 required_claims: vec![cond("site", &["a", "b", "c"])],
890 ..base.clone()
891 },
892 Conditions {
893 required_claims: vec![],
894 ..base.clone()
895 },
896 Conditions {
897 allowed_networks: vec![],
898 ..base.clone()
899 },
900 Conditions {
901 allowed_networks: vec!["192.168.0.0/16".into()],
902 ..base.clone()
903 },
904 Conditions {
905 max_signin_age_hours: None,
906 ..base.clone()
907 },
908 Conditions {
909 max_signin_age_hours: Some(48),
910 ..base.clone()
911 },
912 ] {
913 assert!(!at_least_as_strict(&looser, &base), "{looser:?}");
914 }
915 assert!(at_least_as_strict(&base, &Conditions::default()));
917 }
918
919 #[test]
920 fn only_the_named_claims_are_kept() {
921 let claims = claim_values(&json!({
922 "sub": "x", "site": "Main-Office", "nda": true, "level": 3,
923 "groups": ["A", "B", 7, {"x": 1}], "address": {"street": "y"},
924 }));
925 assert_eq!(claims["nda"], vec!["true".to_string()]);
926 assert_eq!(
927 claims["groups"],
928 vec!["A".to_string(), "B".into(), "7".into()]
929 );
930 assert!(!claims.contains_key("address"));
931 let stored = claims_to_store(&claims, &["site".into(), "groups".into(), "missing".into()]);
932 assert_eq!(
933 stored,
934 json!({ "site": ["main-office"], "groups": ["a", "b", "7"] })
935 );
936 }
937}