Skip to main content

teamcraft/services/
project_policy.rs

1//! Need-to-know policy of a restricted project group (issue #151, step 1):
2//! how administrators who are not members read the group.
3//!
4//! * `implicit` (also: no policy row) – administrators read every restricted
5//!   group, as before;
6//! * `with_reason` – the group is hidden from such an administrator until
7//!   they open a time-limited elevation with a written reason (audited, the
8//!   managers are notified, listed on the group page);
9//! * `none` – administrators manage the group (members, policy) but read
10//!   nothing in it unless they are members; adding oneself is audited and
11//!   notifies the other managers.
12//!
13//! The rule itself is in `services::access::hidden_of_user`: it fills
14//! `Actor::hidden_projects` for administrators too, so every existing guard
15//! applies without a check in any handler.
16
17use chrono::{DateTime, Utc};
18use serde::Serialize;
19use serde_json::json;
20use sqlx::{PgConnection, PgPool};
21use uuid::Uuid;
22
23use crate::{
24    auth::Actor,
25    domain::{self, AdminRead, NotificationKind, ProjectVisibility, Role},
26    error::{AppError, AppResult},
27    models::Project,
28    services::{
29        access, audit,
30        notifications::{self, Notice, Subject},
31        projects,
32    },
33};
34
35/// How long an administrator's elevation lasts.
36pub const ELEVATION_MINUTES: i64 = 60;
37
38/// Shortest accepted reason (a word is not a reason).
39pub const MIN_REASON_CHARS: usize = 10;
40
41/// Longest accepted reason (also the column's CHECK).
42pub const MAX_REASON_CHARS: usize = 1000;
43
44/// An administrator's time-limited access to a group.
45#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
46pub struct Elevation {
47    pub id: Uuid,
48    pub project_id: Uuid,
49    /// Cleared when the account is deleted; `username` stays.
50    pub user_id: Option<Uuid>,
51    pub username: String,
52    pub reason: String,
53    pub started_at: DateTime<Utc>,
54    pub expires_at: DateTime<Utc>,
55    pub ended_at: Option<DateTime<Utc>>,
56    /// Running now (not ended, not expired).
57    pub active: bool,
58}
59
60/// The policy of a group as shown to its managers.
61#[derive(Clone, Debug, Serialize)]
62pub struct PolicyView {
63    pub project_id: Uuid,
64    pub admin_read: AdminRead,
65    /// Whether the policy is in force (the group is restricted).
66    pub in_force: bool,
67    pub updated_at: Option<DateTime<Utc>>,
68    /// Interface switches and read log settings (the defaults without a row).
69    pub access: AccessSettings,
70    /// Conditions on membership (step 2; none without a row).
71    pub conditions: crate::services::membership_conditions::Conditions,
72    /// Newest first.
73    pub elevations: Vec<Elevation>,
74}
75
76const ELEVATION_SELECT: &str =
77    "SELECT e.id, e.project_id, e.user_id, e.username, e.reason, e.started_at, e.expires_at,
78            e.ended_at, (e.ended_at IS NULL AND e.expires_at > now()) AS active
79     FROM project_elevations e";
80
81async fn admin_read_of(conn: &mut PgConnection, project_id: Uuid) -> AppResult<AdminRead> {
82    let raw = sqlx::query_scalar::<_, String>(
83        "SELECT admin_read FROM project_policies WHERE project_id = $1",
84    )
85    .bind(project_id)
86    .fetch_optional(&mut *conn)
87    .await?;
88    Ok(raw
89        .as_deref()
90        .and_then(AdminRead::parse)
91        .unwrap_or(AdminRead::Implicit))
92}
93
94/// Tells the managers of the group (except the actor) about an access event.
95pub(crate) async fn alert_managers(
96    conn: &mut PgConnection,
97    actor: &Actor,
98    project_id: Uuid,
99    title: String,
100    body: String,
101) -> AppResult<()> {
102    let managers: Vec<Uuid> =
103        sqlx::query_scalar("SELECT user_id FROM project_members WHERE project_id = $1 AND manager")
104            .bind(project_id)
105            .fetch_all(&mut *conn)
106            .await?;
107    let notice = Notice {
108        kind: NotificationKind::AccessAlert,
109        title,
110        body,
111        link: format!("/projects/{project_id}"),
112        subject_type: "",
113        subject_ref: String::new(),
114        subject: Subject::Personal,
115        redacted: None,
116    };
117    notifications::notify(conn, Some(actor), &managers, &notice).await?;
118    Ok(())
119}
120
121/// Notifies the managers when an administrator adds themselves to a group
122/// with a need-to-know policy (called by `projects::add_member`).
123pub(crate) async fn note_admin_self_add(
124    conn: &mut PgConnection,
125    actor: &Actor,
126    project: &Project,
127) -> AppResult<()> {
128    if !actor.is_admin() || admin_read_of(conn, project.id).await? == AdminRead::Implicit {
129        return Ok(());
130    }
131    alert_managers(
132        conn,
133        actor,
134        project.id,
135        format!(
136            "{} added themselves to {}",
137            actor.display_name, project.code
138        ),
139        format!(
140            "Administrator {} added themselves as a member of {}, which does not give \
141             administrators read access.",
142            actor.username, project.code
143        ),
144    )
145    .await
146}
147
148/// The policy of a group (managers and administrators; a group that is
149/// hidden from everybody else answers "not found").
150pub async fn get(pool: &PgPool, actor: &Actor, project_id: Uuid) -> AppResult<PolicyView> {
151    let project = projects::get_for_management(pool, actor, project_id).await?;
152    let mut conn = pool.acquire().await?;
153    if !projects::can_manage(&mut conn, actor, project_id).await? {
154        return Err(AppError::Forbidden(
155            "Only the managers of this project group (and administrators) can see its policy."
156                .into(),
157        ));
158    }
159    let row = sqlx::query_as::<_, (String, DateTime<Utc>)>(
160        "SELECT admin_read, updated_at FROM project_policies WHERE project_id = $1",
161    )
162    .bind(project_id)
163    .fetch_optional(&mut *conn)
164    .await?;
165    let elevations = sqlx::query_as::<_, Elevation>(&format!(
166        "{ELEVATION_SELECT} WHERE e.project_id = $1 ORDER BY e.started_at DESC LIMIT 100"
167    ))
168    .bind(project_id)
169    .fetch_all(&mut *conn)
170    .await?;
171    let access = settings(&mut conn, project_id).await?;
172    let conditions = crate::services::membership_conditions::of(&mut conn, project_id).await?;
173    Ok(PolicyView {
174        project_id,
175        access,
176        conditions,
177        admin_read: row
178            .as_ref()
179            .and_then(|(m, _)| AdminRead::parse(m))
180            .unwrap_or(AdminRead::Implicit),
181        in_force: project.visibility == ProjectVisibility::Restricted.as_str(),
182        updated_at: row.map(|(_, at)| at),
183        elevations,
184    })
185}
186
187/// Sets how administrators read a restricted group (managers and
188/// administrators).
189///
190/// # Invariants
191///
192/// * **Permission:** the actor manages the group (403 otherwise; a group the
193///   actor may not even know answers "not found", administrators always
194///   know it); the group must be restricted (409).
195/// * **Locks:** the group row (`FOR UPDATE`).
196/// * **Data:** `with_reason` and `none` need at least one member who is a
197///   manager, so somebody can recover the group (409); the same value again
198///   changes nothing. Running elevations stay in the history; they have no
199///   effect once the mode is `implicit` or `none`.
200/// * **Audit:** `project.policy` with the old and the new value.
201/// * **Notifications / webhooks:** the other managers get an access alert;
202///   no webhooks.
203/// * **Atomic:** one transaction.
204pub async fn set_admin_read(
205    pool: &PgPool,
206    actor: &Actor,
207    project_id: Uuid,
208    mode: AdminRead,
209) -> AppResult<PolicyView> {
210    let mut tx = pool.begin().await?;
211    let project = sqlx::query_as::<_, (String, String)>(
212        "SELECT code, visibility FROM projects WHERE id = $1 FOR UPDATE",
213    )
214    .bind(project_id)
215    .fetch_optional(&mut *tx)
216    .await?
217    .ok_or_else(access::not_found_project)?;
218    projects::require_manager(&mut tx, actor, project_id).await?;
219    if project.1 != ProjectVisibility::Restricted.as_str() {
220        return Err(AppError::Conflict(format!(
221            "{} is not restricted: restrict the group first, the policy builds on that.",
222            project.0
223        )));
224    }
225    let current = admin_read_of(&mut tx, project_id).await?;
226    if current == mode {
227        tx.rollback().await?;
228        return get(pool, actor, project_id).await;
229    }
230    // loosening is for the managers; an administrator may only tighten
231    if rank(mode) < rank(current) {
232        require_member_manager_under_policy(
233            &mut tx,
234            actor,
235            project_id,
236            "Loosening the administrator access",
237        )
238        .await?;
239    }
240    if mode != AdminRead::Implicit {
241        let managers: i64 = sqlx::query_scalar(
242            "SELECT COUNT(*) FROM project_members WHERE project_id = $1 AND manager",
243        )
244        .bind(project_id)
245        .fetch_one(&mut *tx)
246        .await?;
247        if managers == 0 {
248            return Err(AppError::Conflict(format!(
249                "{} needs a member who is a manager before administrators can be limited: \
250                 otherwise nobody could recover the group.",
251                project.0
252            )));
253        }
254    }
255    sqlx::query(
256        "INSERT INTO project_policies (project_id, admin_read, created_by, updated_by)
257         VALUES ($1, $2, $3, $3)
258         ON CONFLICT (project_id) DO UPDATE
259         SET admin_read = EXCLUDED.admin_read, updated_by = EXCLUDED.updated_by, updated_at = now()",
260    )
261    .bind(project_id)
262    .bind(mode.as_str())
263    .bind(actor.id)
264    .execute(&mut *tx)
265    .await?;
266    audit::record(
267        &mut tx,
268        Some(actor),
269        "project.policy",
270        "project",
271        Some(project_id),
272        format!("Administrator access to {}: {}", project.0, mode.label()),
273        json!({ "admin_read": mode.as_str(), "previous": current.as_str() }),
274    )
275    .await?;
276    alert_managers(
277        &mut tx,
278        actor,
279        project_id,
280        format!("Administrator access to {} changed", project.0),
281        format!(
282            "{} set the administrator access of {} to \"{}\" (was \"{}\").",
283            actor.username,
284            project.0,
285            mode.label(),
286            current.label()
287        ),
288    )
289    .await?;
290    tx.commit().await?;
291    get(pool, actor, project_id).await
292}
293
294/// An administrator opens a time-limited elevation on a group whose policy
295/// is `with_reason`; until it expires the group is visible to them like to
296/// a member, with every guard.
297///
298/// # Invariants
299///
300/// * **Permission:** administrators only (403); the policy of the group must
301///   be `with_reason` on a restricted group (400 when nothing is needed, 403
302///   when administrators are shut out by `none`).
303/// * **Locks:** the group row (`FOR UPDATE`).
304/// * **Data:** the reason has 10 to 1000 characters (400); a running
305///   elevation of the same administrator is returned instead of a second
306///   one; the elevation lasts [`ELEVATION_MINUTES`].
307/// * **Audit:** `project.elevate` with the reason.
308/// * **Notifications / webhooks:** the managers get an access alert (so does
309///   no one else); no webhooks.
310/// * **Atomic:** one transaction.
311pub async fn elevate(
312    pool: &PgPool,
313    actor: &Actor,
314    project_id: Uuid,
315    reason: &str,
316) -> AppResult<Elevation> {
317    actor.require(Role::Admin)?;
318    let reason = reason.trim();
319    let len = reason.chars().count();
320    if !(MIN_REASON_CHARS..=MAX_REASON_CHARS).contains(&len) {
321        return Err(AppError::BadRequest(format!(
322            "Give a reason of {MIN_REASON_CHARS} to {MAX_REASON_CHARS} characters."
323        )));
324    }
325    let mut tx = pool.begin().await?;
326    let project = sqlx::query_as::<_, (String, String)>(
327        "SELECT code, visibility FROM projects WHERE id = $1 FOR UPDATE",
328    )
329    .bind(project_id)
330    .fetch_optional(&mut *tx)
331    .await?
332    .ok_or_else(access::not_found_project)?;
333    let mode = admin_read_of(&mut tx, project_id).await?;
334    if project.1 != ProjectVisibility::Restricted.as_str() || mode == AdminRead::Implicit {
335        return Err(AppError::BadRequest(format!(
336            "{} needs no elevation: administrators read it.",
337            project.0
338        )));
339    }
340    if mode == AdminRead::None {
341        return Err(AppError::Forbidden(format!(
342            "The policy of {} does not allow administrators to read it. Only a member reads \
343             the group; a manager of the group adds you (this is audited).",
344            project.0
345        )));
346    }
347    let running = sqlx::query_as::<_, Elevation>(&format!(
348        "{ELEVATION_SELECT} WHERE e.project_id = $1 AND e.user_id = $2
349           AND e.ended_at IS NULL AND e.expires_at > now()
350         ORDER BY e.started_at DESC LIMIT 1"
351    ))
352    .bind(project_id)
353    .bind(actor.id)
354    .fetch_optional(&mut *tx)
355    .await?;
356    if let Some(running) = running {
357        tx.rollback().await?;
358        return Ok(running);
359    }
360    // the clock of the database decides, like every comparison with it
361    let (id, expires) = sqlx::query_as::<_, (Uuid, DateTime<Utc>)>(
362        "INSERT INTO project_elevations (project_id, user_id, username, reason, expires_at)
363         VALUES ($1, $2, $3, $4, now() + make_interval(mins => $5))
364         RETURNING id, expires_at",
365    )
366    .bind(project_id)
367    .bind(actor.id)
368    .bind(&actor.username)
369    .bind(reason)
370    .bind(ELEVATION_MINUTES as i32)
371    .fetch_one(&mut *tx)
372    .await?;
373    audit::record(
374        &mut tx,
375        Some(actor),
376        "project.elevate",
377        "project",
378        Some(project_id),
379        format!(
380            "{} opened administrator access to {} for {ELEVATION_MINUTES} minutes",
381            actor.username, project.0
382        ),
383        json!({ "elevation_id": id, "reason": reason, "expires_at": expires }),
384    )
385    .await?;
386    alert_managers(
387        &mut tx,
388        actor,
389        project_id,
390        format!("Administrator access to {} opened", project.0),
391        format!(
392            "{} opened administrator access to {} for {ELEVATION_MINUTES} minutes. Reason: {reason}",
393            actor.username, project.0
394        ),
395    )
396    .await?;
397    let elevation = sqlx::query_as::<_, Elevation>(&format!("{ELEVATION_SELECT} WHERE e.id = $1"))
398        .bind(id)
399        .fetch_one(&mut *tx)
400        .await?;
401    tx.commit().await?;
402    Ok(elevation)
403}
404
405/// Ends the actor's running elevation of a group early.
406///
407/// # Invariants
408///
409/// * **Permission:** administrators only (403); nothing running is "not
410///   found".
411/// * **Locks:** the elevation rows of the actor and group (`FOR UPDATE`).
412/// * **Data:** sets `ended_at`; the row stays in the history.
413/// * **Audit:** `project.elevation_end`.
414/// * **Notifications / webhooks:** none.
415/// * **Atomic:** one transaction.
416pub async fn end_elevation(pool: &PgPool, actor: &Actor, project_id: Uuid) -> AppResult<()> {
417    actor.require(Role::Admin)?;
418    let mut tx = pool.begin().await?;
419    let ended = sqlx::query_scalar::<_, Uuid>(
420        "UPDATE project_elevations SET ended_at = now()
421         WHERE project_id = $1 AND user_id = $2 AND ended_at IS NULL AND expires_at > now()
422         RETURNING id",
423    )
424    .bind(project_id)
425    .bind(actor.id)
426    .fetch_all(&mut *tx)
427    .await?;
428    if ended.is_empty() {
429        return Err(AppError::not_found("No running administrator access."));
430    }
431    audit::record(
432        &mut tx,
433        Some(actor),
434        "project.elevation_end",
435        "project",
436        Some(project_id),
437        format!("{} ended administrator access early", actor.username),
438        json!({ "elevation_ids": ended }),
439    )
440    .await?;
441    tx.commit().await?;
442    Ok(())
443}
444
445/// Whether a need-to-know policy is in force on the group (restricted, and
446/// its mode is not `implicit`).
447async fn policy_in_force(conn: &mut PgConnection, project_id: Uuid) -> AppResult<bool> {
448    Ok(sqlx::query_scalar::<_, bool>(
449        "SELECT EXISTS (SELECT 1 FROM projects p
450                        JOIN project_policies pp ON pp.project_id = p.id
451                        WHERE p.id = $1 AND p.visibility = 'restricted'
452                          AND pp.admin_read <> 'implicit')",
453    )
454    .bind(project_id)
455    .fetch_one(&mut *conn)
456    .await?)
457}
458
459/// Refuses an administrator who is no member-manager of the group a change
460/// that would widen access (loosening the policy, opening the group, linking
461/// an identity-provider group) while a policy is in force: otherwise an
462/// administrator could undo the policy in one call. Other users, and
463/// administrators while no policy is in force, are not affected.
464pub(crate) async fn require_member_manager_under_policy(
465    conn: &mut PgConnection,
466    actor: &Actor,
467    project_id: Uuid,
468    what: &str,
469) -> AppResult<()> {
470    if !actor.is_admin() || !policy_in_force(conn, project_id).await? {
471        return Ok(());
472    }
473    if is_member(conn, actor, project_id, true).await? {
474        return Ok(());
475    }
476    Err(AppError::Forbidden(format!(
477        "{what} is for the managers of this group while administrators are limited by its \
478         need-to-know policy; an administrator who is no manager member cannot do it."
479    )))
480}
481
482/// Refuses an administrator who is no member yet to add themselves to a
483/// group whose policy is in force, or to make themselves a manager: the managers
484/// do that (under `with_reason` the administrator opens an elevation instead).
485pub(crate) async fn forbid_admin_self_add(
486    conn: &mut PgConnection,
487    actor: &Actor,
488    project_id: Uuid,
489    manager: bool,
490) -> AppResult<()> {
491    if !actor.is_admin()
492        || !policy_in_force(conn, project_id).await?
493        || is_member(conn, actor, project_id, manager).await?
494    {
495        return Ok(());
496    }
497    Err(AppError::Forbidden(
498        "The need-to-know policy of this group does not let administrators add themselves or \
499         make themselves managers; a manager of the group does that."
500            .into(),
501    ))
502}
503
504async fn is_member(
505    conn: &mut PgConnection,
506    actor: &Actor,
507    project_id: Uuid,
508    manager_only: bool,
509) -> AppResult<bool> {
510    Ok(sqlx::query_scalar::<_, bool>(
511        "SELECT EXISTS (SELECT 1 FROM project_members
512                        WHERE project_id = $1 AND user_id = $2 AND (manager OR NOT $3))",
513    )
514    .bind(project_id)
515    .bind(actor.id)
516    .bind(manager_only)
517    .fetch_one(&mut *conn)
518    .await?)
519}
520
521fn rank(mode: AdminRead) -> u8 {
522    match mode {
523        AdminRead::Implicit => 0,
524        AdminRead::WithReason => 1,
525        AdminRead::None => 2,
526    }
527}
528
529// ---------------------------------------------------------------------------
530// Review policy (issue #152, step 2)
531// ---------------------------------------------------------------------------
532
533/// Most people a group can name as required reviewers.
534pub const MAX_REQUIRED_REVIEWERS: usize = 20;
535
536/// A person named in a review policy.
537#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
538pub struct PolicyPerson {
539    pub id: Uuid,
540    pub username: String,
541    pub display_name: String,
542}
543
544/// The role group of a review policy.
545#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
546pub struct RoleGroup {
547    pub id: Uuid,
548    pub code: String,
549    pub name: String,
550}
551
552/// How changes touching a group are reviewed. The default (no minimum, nobody
553/// required, no role group) is "every reviewer approves".
554#[derive(Clone, Debug, Serialize)]
555pub struct ReviewPolicyView {
556    pub project_id: Uuid,
557    /// `None` = all reviewers.
558    pub min_approvals: Option<i32>,
559    pub required_reviewers: Vec<PolicyPerson>,
560    pub role_group: Option<RoleGroup>,
561    /// A role group is set but the reader may not see that group.
562    pub role_group_hidden: bool,
563    /// Obsoleting and superseding needs a released change order (step 3).
564    pub obsolete_requires_change: bool,
565    /// The requester of a change request does not decide it (step 9).
566    pub requester_cannot_decide: bool,
567    /// "Use as is" deviations are confirmed by a reviewer one by one (step 9).
568    pub concessions_need_review: bool,
569}
570
571/// What a manager submits.
572#[derive(Clone, Debug, Default)]
573pub struct ReviewPolicyInput {
574    pub min_approvals: Option<i32>,
575    pub required_usernames: Vec<String>,
576    /// Code of the role group.
577    pub role_group_code: Option<String>,
578    /// `None` leaves the setting as it is.
579    pub obsolete_requires_change: Option<bool>,
580    /// `None` leaves the setting as it is.
581    pub requester_cannot_decide: Option<bool>,
582    /// `None` leaves the setting as it is.
583    pub concessions_need_review: Option<bool>,
584}
585
586async fn review_policy_of(
587    conn: &mut PgConnection,
588    viewer: Option<&Actor>,
589    project_id: Uuid,
590) -> AppResult<ReviewPolicyView> {
591    let row = sqlx::query_as::<_, (Option<i32>, Option<Uuid>, bool, bool, bool)>(
592        "SELECT review_min_approvals, review_role_project_id, obsolete_requires_change,
593                requester_cannot_decide, concessions_need_review
594         FROM project_policies WHERE project_id = $1",
595    )
596    .bind(project_id)
597    .fetch_optional(&mut *conn)
598    .await?
599    .unwrap_or((None, None, false, false, false));
600    let required_reviewers = sqlx::query_as::<_, PolicyPerson>(
601        "SELECT u.id, u.username, u.display_name FROM project_required_reviewers r
602         JOIN users u ON u.id = r.user_id WHERE r.project_id = $1 ORDER BY u.username",
603    )
604    .bind(project_id)
605    .fetch_all(&mut *conn)
606    .await?;
607    // A role group the reader may not see is not named (it answers like a
608    // group that does not exist); `role_group_hidden` tells the form so.
609    let mut role_group_hidden = false;
610    let role_group = match row.1 {
611        Some(id) if viewer.is_some_and(|a| !a.can_see_project(Some(id))) => {
612            role_group_hidden = true;
613            None
614        }
615        Some(id) => {
616            sqlx::query_as::<_, RoleGroup>("SELECT id, code, name FROM projects WHERE id = $1")
617                .bind(id)
618                .fetch_optional(&mut *conn)
619                .await?
620        }
621        None => None,
622    };
623    Ok(ReviewPolicyView {
624        project_id,
625        min_approvals: row.0,
626        required_reviewers,
627        role_group,
628        role_group_hidden,
629        obsolete_requires_change: row.2,
630        requester_cannot_decide: row.3,
631        concessions_need_review: row.4,
632    })
633}
634
635/// The review policy of a group (anybody who may see the group; an
636/// administrator maintaining a group hidden by the need-to-know policy too).
637/// A role group the actor may not see is left out.
638pub async fn get_review_policy(
639    pool: &PgPool,
640    actor: &Actor,
641    project_id: Uuid,
642) -> AppResult<ReviewPolicyView> {
643    projects::get_for_management(pool, actor, project_id).await?;
644    let mut conn = pool.acquire().await?;
645    review_policy_of(&mut conn, Some(actor), project_id).await
646}
647
648/// Sets the review policy of a group.
649///
650/// # Invariants
651///
652/// * **Permission:** the actor manages the group (403; a group the actor may
653///   not know answers "not found"). The role group must be visible to the
654///   actor and a different group (400).
655/// * **Locks:** the group row (`FOR UPDATE`).
656/// * **Data:** the minimum is 1 to 50 or empty (all reviewers); required
657///   `obsolete_requires_change` (`None` keeps it) makes direct obsolete and supersede in the
658///   group a 409; `requester_cannot_decide` and `concessions_need_review`
659///   (`None` keeps them) are the two rules of step 9.
660///   reviewers are active users with the editor role or higher, at most
661///   [`MAX_REQUIRED_REVIEWERS`] (400). Existing changes already in review
662///   keep being evaluated with the policy as it is when they are decided.
663/// * **Audit:** `project.review_policy` with the old and the new values.
664/// * **Notifications / webhooks:** none.
665/// * **Atomic:** one transaction.
666pub async fn set_review_policy(
667    pool: &PgPool,
668    actor: &Actor,
669    project_id: Uuid,
670    input: &ReviewPolicyInput,
671) -> AppResult<ReviewPolicyView> {
672    if let Some(m) = input.min_approvals {
673        if !(1..=50).contains(&m) {
674            return Err(AppError::BadRequest(
675                "Minimum approvals must be between 1 and 50, or empty for all reviewers.".into(),
676            ));
677        }
678    }
679    let mut names: Vec<String> = input
680        .required_usernames
681        .iter()
682        .map(|n| n.trim().to_string())
683        .filter(|n| !n.is_empty())
684        .collect();
685    names.sort();
686    names.dedup();
687    if names.len() > MAX_REQUIRED_REVIEWERS {
688        return Err(AppError::BadRequest(format!(
689            "At most {MAX_REQUIRED_REVIEWERS} required reviewers."
690        )));
691    }
692    let mut tx = pool.begin().await?;
693    let code =
694        sqlx::query_scalar::<_, String>("SELECT code FROM projects WHERE id = $1 FOR UPDATE")
695            .bind(project_id)
696            .fetch_optional(&mut *tx)
697            .await?
698            .ok_or_else(access::not_found_project)?;
699    projects::require_manager(&mut tx, actor, project_id).await?;
700    let mut people = Vec::new();
701    for name in &names {
702        let row = sqlx::query_as::<_, (Uuid, String, bool)>(
703            "SELECT id, role, active FROM users WHERE lower(username) = lower($1)",
704        )
705        .bind(name)
706        .fetch_optional(&mut *tx)
707        .await?;
708        match row {
709            Some((id, role, true))
710                if Role::parse(&role).unwrap_or(Role::Viewer) >= Role::Editor =>
711            {
712                people.push(id)
713            }
714            Some((_, _, true)) => {
715                return Err(AppError::BadRequest(format!(
716                    "{name} has the viewer role and cannot review changes."
717                )))
718            }
719            Some(_) => return Err(AppError::BadRequest(format!("{name} is deactivated."))),
720            None => return Err(AppError::BadRequest(format!("Unknown user '{name}'."))),
721        }
722    }
723    let role_id = match input
724        .role_group_code
725        .as_deref()
726        .map(str::trim)
727        .filter(|c| !c.is_empty())
728    {
729        None => None,
730        Some(c) => {
731            let id = sqlx::query_scalar::<_, Uuid>(
732                "SELECT id FROM projects WHERE lower(code) = lower($1)",
733            )
734            .bind(c)
735            .fetch_optional(&mut *tx)
736            .await?;
737            match id {
738                Some(id) if id == project_id => {
739                    return Err(AppError::BadRequest(
740                        "The role group must be a different project group.".into(),
741                    ))
742                }
743                Some(id) if actor.is_admin() || actor.can_see_project(Some(id)) => Some(id),
744                _ => {
745                    return Err(AppError::BadRequest(format!(
746                        "Unknown project group '{c}'."
747                    )))
748                }
749            }
750        }
751    };
752    let before = review_policy_of(&mut tx, None, project_id).await?;
753    sqlx::query(
754        "INSERT INTO project_policies (project_id, review_min_approvals, review_role_project_id,
755                                       obsolete_requires_change, requester_cannot_decide,
756                                       concessions_need_review, created_by, updated_by)
757         VALUES ($1, $2, $3, $5, $6, $7, $4, $4)
758         ON CONFLICT (project_id) DO UPDATE
759         SET review_min_approvals = EXCLUDED.review_min_approvals,
760             review_role_project_id = EXCLUDED.review_role_project_id,
761             obsolete_requires_change = EXCLUDED.obsolete_requires_change,
762             requester_cannot_decide = EXCLUDED.requester_cannot_decide,
763             concessions_need_review = EXCLUDED.concessions_need_review,
764             updated_by = EXCLUDED.updated_by, updated_at = now()",
765    )
766    .bind(project_id)
767    .bind(input.min_approvals)
768    .bind(role_id)
769    .bind(actor.id)
770    .bind(
771        input
772            .obsolete_requires_change
773            .unwrap_or(before.obsolete_requires_change),
774    )
775    .bind(
776        input
777            .requester_cannot_decide
778            .unwrap_or(before.requester_cannot_decide),
779    )
780    .bind(
781        input
782            .concessions_need_review
783            .unwrap_or(before.concessions_need_review),
784    )
785    .execute(&mut *tx)
786    .await?;
787    sqlx::query("DELETE FROM project_required_reviewers WHERE project_id = $1")
788        .bind(project_id)
789        .execute(&mut *tx)
790        .await?;
791    for id in &people {
792        sqlx::query("INSERT INTO project_required_reviewers (project_id, user_id) VALUES ($1, $2)")
793            .bind(project_id)
794            .bind(id)
795            .execute(&mut *tx)
796            .await?;
797    }
798    let after = review_policy_of(&mut tx, None, project_id).await?;
799    audit::record(
800        &mut tx,
801        Some(actor),
802        "project.review_policy",
803        "project",
804        Some(project_id),
805        format!("Review policy of {code} changed"),
806        json!({
807            "min_approvals": after.min_approvals,
808            "required_reviewers": after.required_reviewers.iter().map(|p| &p.username).collect::<Vec<_>>(),
809            "role_group": after.role_group.as_ref().map(|g| &g.code),
810            "obsolete_requires_change": after.obsolete_requires_change,
811            "requester_cannot_decide": after.requester_cannot_decide,
812            "concessions_need_review": after.concessions_need_review,
813            "previous": {
814                "min_approvals": before.min_approvals,
815                "required_reviewers": before.required_reviewers.iter().map(|p| &p.username).collect::<Vec<_>>(),
816                "role_group": before.role_group.as_ref().map(|g| &g.code),
817                "obsolete_requires_change": before.obsolete_requires_change,
818                "requester_cannot_decide": before.requester_cannot_decide,
819                "concessions_need_review": before.concessions_need_review,
820            },
821        }),
822    )
823    .await?;
824    tx.commit().await?;
825    Ok(after)
826}
827
828/// Whether obsoleting and superseding in this group needs a change order.
829/// Items without a group and groups without a policy row: no.
830pub(crate) async fn obsolete_requires_change(
831    conn: &mut PgConnection,
832    project_id: Option<Uuid>,
833) -> AppResult<bool> {
834    let Some(project_id) = project_id else {
835        return Ok(false);
836    };
837    Ok(sqlx::query_scalar::<_, bool>(
838        "SELECT obsolete_requires_change FROM project_policies WHERE project_id = $1",
839    )
840    .bind(project_id)
841    .fetch_optional(&mut *conn)
842    .await?
843    .unwrap_or(false))
844}
845
846/// Whether one of these groups keeps the requester of a change request from
847/// deciding it. Items without a group and groups without a policy: no.
848pub(crate) async fn requester_cannot_decide(
849    conn: &mut PgConnection,
850    projects: &[Option<Uuid>],
851) -> AppResult<bool> {
852    let ids: Vec<Uuid> = projects.iter().flatten().copied().collect();
853    if ids.is_empty() {
854        return Ok(false);
855    }
856    Ok(sqlx::query_scalar::<_, bool>(
857        "SELECT EXISTS(SELECT 1 FROM project_policies
858                       WHERE project_id = ANY($1) AND requester_cannot_decide)",
859    )
860    .bind(&ids)
861    .fetch_one(&mut *conn)
862    .await?)
863}
864
865/// Whether "use as is" deviations in this group need a reviewer's separate
866/// confirmation. Items without a group and groups without a policy: no.
867pub(crate) async fn concessions_need_review(
868    conn: &mut PgConnection,
869    project_id: Option<Uuid>,
870) -> AppResult<bool> {
871    let Some(project_id) = project_id else {
872        return Ok(false);
873    };
874    Ok(sqlx::query_scalar::<_, bool>(
875        "SELECT concessions_need_review FROM project_policies WHERE project_id = $1",
876    )
877    .bind(project_id)
878    .fetch_optional(&mut *conn)
879    .await?
880    .unwrap_or(false))
881}
882
883/// What the review policies of the groups touched by a change ask for: the
884/// strictest minimum, the union of the required people and the role groups.
885/// Items without a group, and groups without a policy, ask for "all reviewers
886/// approve", so one of them in a change makes every reviewer count. People
887/// who are deactivated or no longer editors do not count. With a `viewer`,
888/// a role group the viewer cannot see is labelled neutrally.
889pub(crate) async fn review_requirement(
890    conn: &mut PgConnection,
891    viewer: Option<&Actor>,
892    change_id: Uuid,
893) -> AppResult<domain::ReviewRequirement> {
894    let all_groups: Vec<Option<Uuid>> = sqlx::query_scalar(
895        "SELECT DISTINCT i.project_id FROM change_items ci
896         JOIN revisions r ON r.id = ci.revision_id
897         JOIN items i ON i.id = r.item_id
898         WHERE ci.change_id = $1",
899    )
900    .bind(change_id)
901    .fetch_all(&mut *conn)
902    .await?;
903    let has_ungrouped = all_groups.iter().any(Option::is_none);
904    let groups: Vec<Uuid> = all_groups.into_iter().flatten().collect();
905    if groups.is_empty() {
906        return Ok(domain::ReviewRequirement::default());
907    }
908    let policies: Vec<(Uuid, Option<i32>, Option<Uuid>)> = sqlx::query_as(
909        "SELECT project_id, review_min_approvals, review_role_project_id
910         FROM project_policies WHERE project_id = ANY($1)",
911    )
912    .bind(&groups)
913    .fetch_all(&mut *conn)
914    .await?;
915    let mut mins: Vec<Option<usize>> = groups
916        .iter()
917        .map(|g| {
918            policies
919                .iter()
920                .find(|p| p.0 == *g)
921                .and_then(|p| p.1)
922                .map(|m| m as usize)
923        })
924        .collect();
925    if has_ungrouped {
926        mins.push(None);
927    }
928    let required_users = sqlx::query_as::<_, (Uuid, String)>(
929        "SELECT DISTINCT u.id, u.display_name FROM project_required_reviewers r
930         JOIN users u ON u.id = r.user_id
931         WHERE r.project_id = ANY($1) AND u.active AND u.role IN ('editor', 'admin')
932         ORDER BY u.display_name, u.id",
933    )
934    .bind(&groups)
935    .fetch_all(&mut *conn)
936    .await?;
937    let mut role_ids: Vec<Uuid> = policies.iter().filter_map(|p| p.2).collect();
938    role_ids.sort();
939    role_ids.dedup();
940    let mut role_groups = Vec::new();
941    for id in role_ids {
942        let code = sqlx::query_scalar::<_, String>("SELECT code FROM projects WHERE id = $1")
943            .bind(id)
944            .fetch_one(&mut *conn)
945            .await?;
946        let label = if viewer.is_none_or(|a| a.can_see_project(Some(id))) {
947            format!("the group {code}")
948        } else {
949            "a required role group".to_string()
950        };
951        let members = sqlx::query_scalar::<_, Uuid>(
952            "SELECT m.user_id FROM project_members m JOIN users u ON u.id = m.user_id
953             WHERE m.project_id = $1 AND u.active AND u.role IN ('editor', 'admin')",
954        )
955        .bind(id)
956        .fetch_all(&mut *conn)
957        .await?;
958        role_groups.push(domain::RoleRequirement { label, members });
959    }
960    Ok(domain::ReviewRequirement {
961        min_approvals: domain::combine_min_approvals(&mins),
962        required_users,
963        role_groups,
964    })
965}
966
967// ---------------------------------------------------------------------------
968// Interface switches and the read log (#151, steps 3 and 4)
969// ---------------------------------------------------------------------------
970
971/// A channel through which data of a restricted group can leave the web UI.
972/// Each can be switched off per group; a switched-off interface treats the
973/// group as non-existent (the usual "not found").
974#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
975#[serde(rename_all = "snake_case")]
976pub enum Interface {
977    /// REST requests (and the desktop app) with a personal access token.
978    ApiTokens,
979    /// MCP tools (also for the stdio bridge, which uses a token).
980    Mcp,
981    /// The built-in assistant (its tool results go to the LLM provider).
982    Assistant,
983    /// Outgoing webhooks: no webhook receives events of the group.
984    Webhooks,
985    /// Pack and Go and BOM downloads (CSV, Excel).
986    Packages,
987    /// E-mail and chat notifications carry titles, numbers and text of the group.
988    NotificationContent,
989    /// The assistant may use an external (not internal-listed) LLM provider.
990    ExternalLlm,
991    /// The desktop app's Get (working set).
992    DesktopSync,
993}
994
995impl Interface {
996    /// Every interface, in the order shown to the managers. The column of
997    /// `project_policies` has the name of [`Interface::as_str`].
998    pub const ALL: [Interface; 8] = [
999        Interface::ApiTokens,
1000        Interface::Mcp,
1001        Interface::Assistant,
1002        Interface::Webhooks,
1003        Interface::Packages,
1004        Interface::NotificationContent,
1005        Interface::ExternalLlm,
1006        Interface::DesktopSync,
1007    ];
1008
1009    pub fn as_str(self) -> &'static str {
1010        match self {
1011            Interface::ApiTokens => "api_tokens",
1012            Interface::Mcp => "mcp",
1013            Interface::Assistant => "assistant",
1014            Interface::Webhooks => "webhooks",
1015            Interface::Packages => "packages",
1016            Interface::NotificationContent => "notification_content",
1017            Interface::ExternalLlm => "external_llm",
1018            Interface::DesktopSync => "desktop_sync",
1019        }
1020    }
1021
1022    pub fn parse(raw: &str) -> Option<Interface> {
1023        let raw = raw.trim().to_ascii_lowercase();
1024        Interface::ALL.into_iter().find(|i| i.as_str() == raw)
1025    }
1026
1027    pub fn label(self) -> &'static str {
1028        match self {
1029            Interface::ApiTokens => "REST API and desktop app (access tokens)",
1030            Interface::Mcp => "MCP tools",
1031            Interface::Assistant => "Built-in assistant",
1032            Interface::Webhooks => "Outgoing webhooks",
1033            Interface::Packages => "Pack and Go and BOM downloads",
1034            Interface::NotificationContent => "Titles and text in e-mail and chat notifications",
1035            Interface::ExternalLlm => "Built-in assistant with an external LLM provider",
1036            Interface::DesktopSync => "Desktop app Get (working set)",
1037        }
1038    }
1039}
1040
1041/// Which interfaces are allowed for a group (all `true` = today's behaviour).
1042#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, sqlx::FromRow)]
1043pub struct Interfaces {
1044    pub api_tokens: bool,
1045    pub mcp: bool,
1046    pub assistant: bool,
1047    pub webhooks: bool,
1048    pub packages: bool,
1049    pub notification_content: bool,
1050    pub external_llm: bool,
1051    pub desktop_sync: bool,
1052}
1053
1054impl Default for Interfaces {
1055    fn default() -> Self {
1056        Interfaces {
1057            api_tokens: true,
1058            mcp: true,
1059            assistant: true,
1060            webhooks: true,
1061            packages: true,
1062            notification_content: true,
1063            external_llm: true,
1064            desktop_sync: true,
1065        }
1066    }
1067}
1068
1069impl Interfaces {
1070    pub fn allowed(&self, i: Interface) -> bool {
1071        match i {
1072            Interface::ApiTokens => self.api_tokens,
1073            Interface::Mcp => self.mcp,
1074            Interface::Assistant => self.assistant,
1075            Interface::Webhooks => self.webhooks,
1076            Interface::Packages => self.packages,
1077            Interface::NotificationContent => self.notification_content,
1078            Interface::ExternalLlm => self.external_llm,
1079            Interface::DesktopSync => self.desktop_sync,
1080        }
1081    }
1082
1083    fn set(&mut self, i: Interface, allowed: bool) {
1084        match i {
1085            Interface::ApiTokens => self.api_tokens = allowed,
1086            Interface::Mcp => self.mcp = allowed,
1087            Interface::Assistant => self.assistant = allowed,
1088            Interface::Webhooks => self.webhooks = allowed,
1089            Interface::Packages => self.packages = allowed,
1090            Interface::NotificationContent => self.notification_content = allowed,
1091            Interface::ExternalLlm => self.external_llm = allowed,
1092            Interface::DesktopSync => self.desktop_sync = allowed,
1093        }
1094    }
1095}
1096
1097/// Longest accepted access review interval (ten years).
1098pub const MAX_REVIEW_INTERVAL_DAYS: i32 = 3650;
1099
1100/// The interface switches, the log settings and the review interval of a
1101/// group.
1102#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, sqlx::FromRow)]
1103pub struct AccessSettings {
1104    #[sqlx(flatten)]
1105    pub interfaces: Interfaces,
1106    pub read_log: bool,
1107    pub log_retention_days: Option<i32>,
1108    /// The access review is due every this many days (`None` = no schedule).
1109    pub review_interval_days: Option<i32>,
1110}
1111
1112/// A change of the switches, log settings and review interval; `None`
1113/// leaves a value as it is.
1114#[derive(Clone, Debug, Default)]
1115pub struct AccessUpdate {
1116    pub interfaces: Vec<(Interface, bool)>,
1117    pub read_log: Option<bool>,
1118    /// `Some(None)` keeps the log until it is purged by hand.
1119    pub log_retention_days: Option<Option<i32>>,
1120    /// `Some(None)` removes the review schedule.
1121    pub review_interval_days: Option<Option<i32>>,
1122}
1123
1124const SETTINGS_SELECT: &str =
1125    "SELECT api_tokens, mcp, assistant, webhooks, packages, notification_content,
1126            external_llm, desktop_sync, read_log, log_retention_days, review_interval_days
1127     FROM project_policies WHERE project_id = $1";
1128
1129/// The switches and log settings of a group (defaults without a policy row).
1130pub async fn settings(conn: &mut PgConnection, project_id: Uuid) -> AppResult<AccessSettings> {
1131    Ok(sqlx::query_as::<_, AccessSettings>(SETTINGS_SELECT)
1132        .bind(project_id)
1133        .fetch_optional(&mut *conn)
1134        .await?
1135        .unwrap_or_default())
1136}
1137
1138/// The restricted groups for which at least one of `interfaces` is switched
1139/// off. A group that is not restricted is never listed: the switches build on
1140/// the restriction.
1141pub async fn denied_groups(pool: &PgPool, interfaces: &[Interface]) -> AppResult<Vec<Uuid>> {
1142    if interfaces.is_empty() {
1143        return Ok(Vec::new());
1144    }
1145    Ok(
1146        sqlx::query_scalar::<_, Uuid>(&denied_groups_sql(interfaces))
1147            .fetch_all(pool)
1148            .await?,
1149    )
1150}
1151
1152/// The query of [`denied_groups`] (no parameters), for a caller that reads
1153/// the groups in a statement of its own: `COALESCE(ARRAY(…), '{}')` gives
1154/// them as one more column, without another round trip (the personal access
1155/// token of every API request, `users::actor_for_api_token`).
1156pub(crate) fn denied_groups_sql(interfaces: &[Interface]) -> String {
1157    // column names come from `Interface::as_str`, never from input
1158    let off = if interfaces.is_empty() {
1159        "FALSE".to_string()
1160    } else {
1161        interfaces
1162            .iter()
1163            .map(|i| format!("NOT pp.{}", i.as_str()))
1164            .collect::<Vec<_>>()
1165            .join(" OR ")
1166    };
1167    format!(
1168        "SELECT pp.project_id FROM project_policies pp
1169         JOIN projects p ON p.id = pp.project_id
1170         WHERE p.visibility = 'restricted' AND ({off})"
1171    )
1172}
1173
1174/// The actor as it is seen through `interfaces`: groups that switched one of
1175/// them off are hidden. One cheap query; without a policy nothing changes.
1176pub async fn narrow(pool: &PgPool, actor: Actor, interfaces: &[Interface]) -> AppResult<Actor> {
1177    let denied = denied_groups(pool, interfaces).await?;
1178    Ok(if denied.is_empty() {
1179        actor
1180    } else {
1181        actor.without_groups(&denied)
1182    })
1183}
1184
1185/// For downloads of a whole structure: the root item's group must allow
1186/// packages (403, the actor reads it anyway), and the returned actor does not
1187/// see the items of groups that do not, so they are left out like restricted
1188/// items of non-members.
1189pub async fn package_actor(
1190    pool: &PgPool,
1191    actor: &Actor,
1192    root_project: Option<Uuid>,
1193) -> AppResult<Actor> {
1194    let denied = denied_groups(pool, &[Interface::Packages]).await?;
1195    if root_project.is_some_and(|p| denied.contains(&p)) {
1196        return Err(AppError::Forbidden(
1197            "Packages and BOM downloads are switched off for this project group.".into(),
1198        ));
1199    }
1200    Ok(actor.clone().without_groups(&denied))
1201}
1202
1203/// Changes the interface switches and the log settings of a restricted group
1204/// (managers and administrators).
1205///
1206/// # Invariants
1207///
1208/// * **Permission:** the actor manages the group (403; "not found" for a
1209///   group the actor may not know); the group must be restricted (409).
1210/// * **Locks:** the group row (`FOR UPDATE`).
1211/// * **Data:** the retention is 1 to 36500 days, the review interval 1 to
1212///   [`MAX_REVIEW_INTERVAL_DAYS`] (400). Tightening (switching an interface
1213///   off, the log on, a shorter review interval) is open to managers and
1214///   administrators; loosening (switching one on, the log off, changing the
1215///   retention, a longer or no review interval) is for the managers while
1216///   administrators are limited by the policy, like [`set_admin_read`]. No
1217///   change, no audit entry.
1218/// * **Audit:** `project.interfaces` with old and new values.
1219/// * **Notifications / webhooks:** the other managers get an access alert; no
1220///   webhooks.
1221/// * **Atomic:** one transaction.
1222pub async fn set_access(
1223    pool: &PgPool,
1224    actor: &Actor,
1225    project_id: Uuid,
1226    update: AccessUpdate,
1227) -> AppResult<PolicyView> {
1228    if let Some(Some(days)) = update.log_retention_days {
1229        if !(1..=36500).contains(&days) {
1230            return Err(AppError::BadRequest(
1231                "The retention is 1 to 36500 days.".into(),
1232            ));
1233        }
1234    }
1235    if let Some(Some(days)) = update.review_interval_days {
1236        if !(1..=MAX_REVIEW_INTERVAL_DAYS).contains(&days) {
1237            return Err(AppError::BadRequest(format!(
1238                "The review interval is 1 to {MAX_REVIEW_INTERVAL_DAYS} days."
1239            )));
1240        }
1241    }
1242    let mut tx = pool.begin().await?;
1243    let project = sqlx::query_as::<_, (String, String)>(
1244        "SELECT code, visibility FROM projects WHERE id = $1 FOR UPDATE",
1245    )
1246    .bind(project_id)
1247    .fetch_optional(&mut *tx)
1248    .await?
1249    .ok_or_else(access::not_found_project)?;
1250    projects::require_manager(&mut tx, actor, project_id).await?;
1251    if project.1 != ProjectVisibility::Restricted.as_str() {
1252        return Err(AppError::Conflict(format!(
1253            "{} is not restricted: restrict the group first, the interface switches build on that.",
1254            project.0
1255        )));
1256    }
1257    let old = settings(&mut tx, project_id).await?;
1258    let mut new = old.clone();
1259    for (i, allowed) in &update.interfaces {
1260        new.interfaces.set(*i, *allowed);
1261    }
1262    if let Some(on) = update.read_log {
1263        new.read_log = on;
1264    }
1265    if let Some(days) = update.log_retention_days {
1266        new.log_retention_days = days;
1267    }
1268    if let Some(days) = update.review_interval_days {
1269        new.review_interval_days = days;
1270    }
1271    if new == old {
1272        tx.rollback().await?;
1273        return get(pool, actor, project_id).await;
1274    }
1275    // a longer interval (or none) asks for fewer reviews: loosening
1276    let fewer_reviews = match (old.review_interval_days, new.review_interval_days) {
1277        (Some(_), None) => true,
1278        (Some(o), Some(n)) => n > o,
1279        _ => false,
1280    };
1281    let loosens = Interface::ALL
1282        .into_iter()
1283        .any(|i| new.interfaces.allowed(i) && !old.interfaces.allowed(i))
1284        || (old.read_log && !new.read_log)
1285        || new.log_retention_days != old.log_retention_days
1286        || fewer_reviews;
1287    if loosens {
1288        require_member_manager_under_policy(
1289            &mut tx,
1290            actor,
1291            project_id,
1292            "Loosening the interface switches or the log",
1293        )
1294        .await?;
1295    }
1296    sqlx::query(
1297        "INSERT INTO project_policies (project_id, api_tokens, mcp, assistant, webhooks, packages,
1298                                       notification_content, external_llm, desktop_sync,
1299                                       read_log, log_retention_days, created_by, updated_by,
1300                                       review_interval_days)
1301         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12, $13)
1302         ON CONFLICT (project_id) DO UPDATE
1303         SET api_tokens = EXCLUDED.api_tokens, mcp = EXCLUDED.mcp,
1304             assistant = EXCLUDED.assistant, webhooks = EXCLUDED.webhooks,
1305             packages = EXCLUDED.packages,
1306             notification_content = EXCLUDED.notification_content,
1307             external_llm = EXCLUDED.external_llm, desktop_sync = EXCLUDED.desktop_sync,
1308             read_log = EXCLUDED.read_log,
1309             log_retention_days = EXCLUDED.log_retention_days,
1310             review_interval_days = EXCLUDED.review_interval_days,
1311             updated_by = EXCLUDED.updated_by, updated_at = now()",
1312    )
1313    .bind(project_id)
1314    .bind(new.interfaces.api_tokens)
1315    .bind(new.interfaces.mcp)
1316    .bind(new.interfaces.assistant)
1317    .bind(new.interfaces.webhooks)
1318    .bind(new.interfaces.packages)
1319    .bind(new.interfaces.notification_content)
1320    .bind(new.interfaces.external_llm)
1321    .bind(new.interfaces.desktop_sync)
1322    .bind(new.read_log)
1323    .bind(new.log_retention_days)
1324    .bind(actor.id)
1325    .bind(new.review_interval_days)
1326    .execute(&mut *tx)
1327    .await?;
1328    audit::record(
1329        &mut tx,
1330        Some(actor),
1331        "project.interfaces",
1332        "project",
1333        Some(project_id),
1334        format!("Interfaces and read log of {} changed", project.0),
1335        json!({ "old": old, "new": new }),
1336    )
1337    .await?;
1338    alert_managers(
1339        &mut tx,
1340        actor,
1341        project_id,
1342        format!("Interfaces of {} changed", project.0),
1343        format!(
1344            "{} changed the interface switches or the read log of {}.",
1345            actor.username, project.0
1346        ),
1347    )
1348    .await?;
1349    tx.commit().await?;
1350    get(pool, actor, project_id).await
1351}
1352
1353#[cfg(test)]
1354mod interface_tests {
1355    use super::*;
1356
1357    #[test]
1358    fn interfaces_round_trip_and_default_to_allowed() {
1359        for i in Interface::ALL {
1360            assert_eq!(Interface::parse(i.as_str()), Some(i));
1361            assert!(Interfaces::default().allowed(i));
1362        }
1363        assert_eq!(Interface::parse(" MCP "), Some(Interface::Mcp));
1364        assert_eq!(Interface::parse("email"), None);
1365        let defaults = AccessSettings::default();
1366        assert!(!defaults.read_log);
1367        assert_eq!(defaults.log_retention_days, None);
1368    }
1369
1370    #[test]
1371    fn setting_one_interface_leaves_the_others() {
1372        let mut s = Interfaces::default();
1373        s.set(Interface::Webhooks, false);
1374        assert!(!s.allowed(Interface::Webhooks));
1375        assert!(Interface::ALL
1376            .into_iter()
1377            .filter(|i| *i != Interface::Webhooks)
1378            .all(|i| s.allowed(i)));
1379    }
1380
1381    #[test]
1382    fn narrowing_hides_and_removes_membership() {
1383        let (a, b) = (Uuid::new_v4(), Uuid::new_v4());
1384        let me = Actor::test(Role::Editor, vec![a, b], vec![]);
1385        let narrowed = me.without_groups(&[a]);
1386        assert!(!narrowed.can_see_project(Some(a)));
1387        assert!(narrowed.can_see_project(Some(b)));
1388        assert_eq!(narrowed.projects, vec![b]);
1389    }
1390}