1use 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
35pub const ELEVATION_MINUTES: i64 = 60;
37
38pub const MIN_REASON_CHARS: usize = 10;
40
41pub const MAX_REASON_CHARS: usize = 1000;
43
44#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
46pub struct Elevation {
47 pub id: Uuid,
48 pub project_id: Uuid,
49 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 pub active: bool,
58}
59
60#[derive(Clone, Debug, Serialize)]
62pub struct PolicyView {
63 pub project_id: Uuid,
64 pub admin_read: AdminRead,
65 pub in_force: bool,
67 pub updated_at: Option<DateTime<Utc>>,
68 pub access: AccessSettings,
70 pub conditions: crate::services::membership_conditions::Conditions,
72 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
94pub(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, ¬ice).await?;
118 Ok(())
119}
120
121pub(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
148pub 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
187pub 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 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
294pub 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 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
405pub 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
445async 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
459pub(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
482pub(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
529pub const MAX_REQUIRED_REVIEWERS: usize = 20;
535
536#[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#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
546pub struct RoleGroup {
547 pub id: Uuid,
548 pub code: String,
549 pub name: String,
550}
551
552#[derive(Clone, Debug, Serialize)]
555pub struct ReviewPolicyView {
556 pub project_id: Uuid,
557 pub min_approvals: Option<i32>,
559 pub required_reviewers: Vec<PolicyPerson>,
560 pub role_group: Option<RoleGroup>,
561 pub role_group_hidden: bool,
563 pub obsolete_requires_change: bool,
565 pub requester_cannot_decide: bool,
567 pub concessions_need_review: bool,
569}
570
571#[derive(Clone, Debug, Default)]
573pub struct ReviewPolicyInput {
574 pub min_approvals: Option<i32>,
575 pub required_usernames: Vec<String>,
576 pub role_group_code: Option<String>,
578 pub obsolete_requires_change: Option<bool>,
580 pub requester_cannot_decide: Option<bool>,
582 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 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
635pub 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
648pub 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
828pub(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
846pub(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
865pub(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
883pub(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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
975#[serde(rename_all = "snake_case")]
976pub enum Interface {
977 ApiTokens,
979 Mcp,
981 Assistant,
983 Webhooks,
985 Packages,
987 NotificationContent,
989 ExternalLlm,
991 DesktopSync,
993}
994
995impl Interface {
996 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#[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
1097pub const MAX_REVIEW_INTERVAL_DAYS: i32 = 3650;
1099
1100#[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 pub review_interval_days: Option<i32>,
1110}
1111
1112#[derive(Clone, Debug, Default)]
1115pub struct AccessUpdate {
1116 pub interfaces: Vec<(Interface, bool)>,
1117 pub read_log: Option<bool>,
1118 pub log_retention_days: Option<Option<i32>>,
1120 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
1129pub 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
1138pub 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
1152pub(crate) fn denied_groups_sql(interfaces: &[Interface]) -> String {
1157 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
1174pub 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
1185pub 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
1203pub 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 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}