Skip to main content

teamcraft/services/
membership_conditions.rs

1//! Need-to-know project groups, step 2 (issue #151): conditions on
2//! membership. A restricted group with a need-to-know policy can require,
3//! on top of membership,
4//!
5//! * **claims** of the identity provider – `site = main-office,
6//!   partner-workshop`, `nda = true` – taken from the user's last single
7//!   sign-on (a change at the identity provider applies at the next sign-in;
8//!   local accounts have no claims and never meet such a condition);
9//! * **networks** – the client address must be in one of the ranges (the
10//!   same client address as the sign-in throttle: the peer, or the entry the
11//!   reverse proxy added to `X-Forwarded-For`);
12//! * a **maximum sign-in age** – the group is visible only for so many hours
13//!   after signing in (the web session's start; for tokens and
14//!   notifications the user's last sign-in), so a person removed at the
15//!   identity provider loses it at the latest then.
16//!
17//! A reader who does not meet the conditions is treated as a non-member: the
18//! group is hidden ("not found"), administrators included. Claims and the
19//! sign-in age are part of the SQL that builds the hidden groups
20//! ([`unmet_sql`] in `services::access::hidden_of_user`), so they also hold
21//! for tokens, the assistant and notification recipients; networks and the
22//! session's age depend on the request and are applied by the extractors
23//! ([`apply`]). A member refused by a condition is recorded once per session
24//! (`project.access_refused`).
25
26use 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
45/// Most claim conditions of a group.
46pub const MAX_CLAIMS: usize = 10;
47/// Most accepted values of one claim condition.
48pub const MAX_VALUES: usize = 20;
49/// Most network ranges of a group.
50pub const MAX_NETWORKS: usize = 50;
51/// Longest maximum sign-in age (a year).
52pub const MAX_SIGNIN_AGE_HOURS: i32 = 8760;
53/// Values of one claim kept from a sign-in.
54const MAX_STORED_VALUES: usize = 50;
55/// Longest kept claim value.
56const MAX_VALUE_CHARS: usize = 200;
57
58/// One claim condition: the claim must hold one of the values (compared
59/// without case; a list claim such as `groups` needs one of its entries).
60#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
61pub struct ClaimCondition {
62    pub claim: String,
63    pub values: Vec<String>,
64}
65
66/// The conditions of a group (none = membership alone, as before).
67#[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// ---------------------------------------------------------------------------
83// Networks
84// ---------------------------------------------------------------------------
85
86/// An address range (`10.0.0.0/8`, `2001:db8::/32`; a bare address is one
87/// host).
88#[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    /// Whether `ip` is in the range (an IPv4 address mapped into IPv6
137    /// counts as IPv4).
138    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    /// Whether every address of `other` is in this range.
153    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
166/// Whether the client is in one of the ranges; an unknown client is not.
167pub 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
175// ---------------------------------------------------------------------------
176// Parsing and validation
177// ---------------------------------------------------------------------------
178
179fn 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
187/// A claim value as it is compared: trimmed, lower case, bounded.
188fn normal_value(v: &str) -> String {
189    v.trim()
190        .to_lowercase()
191        .chars()
192        .take(MAX_VALUE_CHARS)
193        .collect()
194}
195
196/// Checks and normalises conditions: claim names are identifiers, values
197/// are lower case, networks are canonical ranges, the sign-in age is 1 to
198/// [`MAX_SIGNIN_AGE_HOURS`] hours.
199pub 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
258/// Claim conditions from text, one per line: `claim = value, value`.
259pub 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
277/// Claim conditions as text lines (the form field).
278pub 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
286/// Whether `new` asks at least as much as `old` (every old claim condition
287/// kept with no new accepted value, every new range inside an old one, a
288/// sign-in age no longer than before). Anything else loosens the group.
289pub 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
314/// The claims of a sign-in as they are stored: names to lower-case values
315/// (a string, a number, a boolean or a list of them; other shapes are left
316/// out), only for the claims in `wanted`.
317pub 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
336/// The claims of an ID token (and userinfo) as name → values.
337pub 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
360// ---------------------------------------------------------------------------
361// SQL
362// ---------------------------------------------------------------------------
363
364/// SQL: the conditions of the policy row `pp` that the user aliased `u`
365/// does not meet with their stored claims and last sign-in (`TRUE` = not
366/// met). Without conditions (or without a policy row) it is `FALSE`.
367pub 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
381/// The conditions of a group (empty without a policy row).
382pub 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
400/// The claim names any group's conditions name: the claims kept from a
401/// sign-in.
402pub 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
412/// Stores the claims of a single sign-on that conditions name (called by
413/// `users::upsert_oidc_user` in its transaction).
414pub(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// ---------------------------------------------------------------------------
429// Per request
430// ---------------------------------------------------------------------------
431
432/// Where a request comes from, for the conditions that depend on it.
433#[derive(Clone, Debug, Default)]
434pub struct Origin {
435    /// The client address (see `auth::client_address`).
436    pub client: Option<IpAddr>,
437    /// Start of the web session; `None` for tokens (their age is the
438    /// user's last sign-in, checked in SQL).
439    pub session_started: Option<DateTime<Utc>>,
440    /// The session or token (a hash prefix) – refusals are recorded once
441    /// per key. `None` records nothing.
442    pub key: Option<String>,
443    /// The request came with a personal access token (the `api_tokens`
444    /// switch applies to work done for it).
445    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
458/// Refusals already recorded in this process: (key, group).
459static RECORDED: LazyLock<Mutex<HashSet<(String, Uuid)>>> =
460    LazyLock::new(|| Mutex::new(HashSet::new()));
461const MAX_RECORDED: usize = 10_000;
462
463/// The actor as the request may see it: groups whose network or session-age
464/// condition the request does not meet are hidden (claims and the last
465/// sign-in are already part of the actor's hidden groups). One query; without
466/// conditioned groups nothing changes. A member refused by any condition is
467/// recorded once per `origin.key`.
468pub 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
525/// Records `project.access_refused` for refusals not yet recorded for this
526/// key (in this process).
527async 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// ---------------------------------------------------------------------------
568// Setting the conditions
569// ---------------------------------------------------------------------------
570
571/// A change of the conditions; `None` leaves a part as it is.
572#[derive(Clone, Debug, Default)]
573pub struct ConditionsUpdate {
574    pub required_claims: Option<Vec<ClaimCondition>>,
575    pub allowed_networks: Option<Vec<String>>,
576    /// `Some(None)` removes the maximum sign-in age.
577    pub max_signin_age_hours: Option<Option<i32>>,
578}
579
580/// Sets the conditions on membership of a restricted group (managers and
581/// administrators).
582///
583/// # Invariants
584///
585/// * **Permission:** the actor manages the group (403; "not found" for a
586///   group the actor may not know); the group must be restricted (409).
587///   Tightening is open to managers and administrators; loosening (a
588///   condition removed, a value or range added, a longer or no sign-in age)
589///   is for the managers while administrators are limited by the policy, like
590///   `project_policy::set_access`.
591/// * **Locks:** the group row (`FOR UPDATE`).
592/// * **Data:** validated by [`validate`] (400). A member of the group may not
593///   set conditions that their own request would fail (409: they would lock
594///   themselves out). No change, no audit entry.
595/// * **Audit:** `project.conditions` with the old and the new conditions.
596/// * **Notifications / webhooks:** the other managers get an access alert; no
597///   webhooks.
598/// * **Atomic:** one transaction.
599pub 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
689/// Refuses conditions under which the acting member would no longer see the
690/// group (administrators who are no members manage it anyway).
691async 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        // fewer values, a narrower range, a shorter age, an extra condition
879        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        // anything is at least as strict as nothing
916        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}