teamcraft/services/access.rs
1//! Read access control: the only place that writes the SQL guards.
2//!
3//! A project group is either `internal` (everybody reads) or `restricted`
4//! (only its members and administrators read). The [`Actor`] carries the
5//! restricted groups it may *not* read (`hidden_projects`, empty for admins
6//! except in need-to-know groups, see [`hidden_of_user`]);
7//! every guard binds that array. Hidden objects answer with exactly the same
8//! "not found" error as missing ones, so their existence is not confirmed.
9//!
10//! Change orders and change requests have no group of their own; their
11//! rules are [`change_visible`] and [`request_visible`].
12
13use uuid::Uuid;
14
15use crate::{auth::Actor, error::AppError};
16
17/// SQL guard: the item aliased `alias` is visible. `n` is the bind position
18/// of `actor.hidden_projects` (`uuid[]`).
19///
20/// The `IS NULL OR` prefix is mandatory: `NULL <> ALL('{x}')` is NULL and
21/// would hide items without a group.
22pub fn item_visible(alias: &str, n: usize) -> String {
23 format!("({alias}.project_id IS NULL OR {alias}.project_id <> ALL(${n}))")
24}
25
26/// SQL guard on a bare project-id expression (e.g. `w.project_id`).
27pub fn project_visible(expr: &str, n: usize) -> String {
28 project_visible_in(expr, &format!("${n}"))
29}
30
31/// Like [`project_visible`] with an SQL expression for the hidden groups.
32pub fn project_visible_in(expr: &str, hidden: &str) -> String {
33 format!("({expr} IS NULL OR {expr} <> ALL({hidden}))")
34}
35
36/// SQL guard: the change aliased `c` is visible to the actor bound at
37/// `actor_n` (uuid) whose hidden groups are bound at `hidden_n`. Visible to
38/// administrators, its author, its reviewers and anyone who sees every
39/// affected item of a change that has items (an empty draft is therefore
40/// visible to its author, reviewers and admins only).
41pub fn change_visible(c: &str, actor_n: usize, hidden_n: usize) -> String {
42 change_visible_for(c, &format!("${actor_n}"), &format!("${hidden_n}"))
43}
44
45/// [`change_visible`] with SQL expressions for the actor id and the hidden
46/// groups (used for notification recipients).
47pub fn change_visible_for(c: &str, actor: &str, hidden: &str) -> String {
48 format!(
49 "({c}.created_by = {actor}
50 OR (EXISTS (SELECT 1 FROM users aau WHERE aau.id = {actor} AND aau.role = 'admin')
51 AND NOT EXISTS (SELECT 1 FROM change_items aci2
52 JOIN revisions ar2 ON ar2.id = aci2.revision_id
53 JOIN items ai2 ON ai2.id = ar2.item_id
54 WHERE aci2.change_id = {c}.id AND NOT {admin_items}))
55 OR EXISTS (SELECT 1 FROM change_reviews acr
56 WHERE acr.change_id = {c}.id AND acr.reviewer_id = {actor})
57 OR (EXISTS (SELECT 1 FROM change_items aci WHERE aci.change_id = {c}.id)
58 AND NOT EXISTS (SELECT 1 FROM change_items aci
59 JOIN revisions ar ON ar.id = aci.revision_id
60 JOIN items ai ON ai.id = ar.item_id
61 WHERE aci.change_id = {c}.id
62 AND NOT {})))",
63 project_visible_in("ai.project_id", hidden),
64 admin_items = project_visible_in("ai2.project_id", hidden),
65 )
66}
67
68/// SQL guard: the change request aliased `r` is visible: requester,
69/// assignee, or every affected item visible (which includes requests
70/// without items).
71pub fn request_visible(r: &str, actor_n: usize, hidden_n: usize) -> String {
72 request_visible_for(r, &format!("${actor_n}"), &format!("${hidden_n}"))
73}
74
75/// [`request_visible`] with SQL expressions for actor id and hidden groups.
76pub fn request_visible_for(r: &str, actor: &str, hidden: &str) -> String {
77 format!(
78 "({r}.requested_by = {actor}
79 OR {r}.assignee IS NOT DISTINCT FROM {actor}
80 OR NOT EXISTS (SELECT 1 FROM change_request_items acri
81 JOIN items ai ON ai.id = acri.item_id
82 WHERE acri.request_id = {r}.id AND NOT {}))",
83 project_visible_in("ai.project_id", hidden)
84 )
85}
86
87/// Recipient-side check: the user aliased `u` may see an item of project
88/// expression `p` (items without a group, internal groups, members, and
89/// administrators unless the group's need-to-know policy shuts them out).
90pub fn user_sees_project(u: &str, p: &str) -> String {
91 format!("({p} IS NULL OR NOT (({p}) = ANY({})))", hidden_of_user(u))
92}
93
94/// The groups a user may not read, as an SQL expression (`uuid[]`) for the
95/// user aliased `u`. Used to evaluate the change and request guards for
96/// notification recipients and to build the [`Actor`].
97///
98/// * non-administrators: every restricted group they are no member of;
99/// * administrators: only the restricted groups whose need-to-know policy
100/// (`project_policies.admin_read`) shuts them out, they are no member of,
101/// and for which they hold no running elevation. Without a policy row an
102/// administrator reads everything, as before;
103/// * everybody, members and administrators alike: the restricted groups
104/// whose conditions on membership (identity-provider claims, maximum
105/// sign-in age) the user does not meet
106/// (`services::membership_conditions::unmet_sql`). The conditions that
107/// depend on the request (networks, the session's age) are applied to the
108/// actor afterwards (`membership_conditions::apply`).
109pub fn hidden_of_user(u: &str) -> String {
110 format!(
111 "COALESCE((SELECT array_agg(hp.id) FROM projects hp
112 LEFT JOIN project_policies hpp ON hpp.project_id = hp.id
113 WHERE hp.visibility = 'restricted'
114 AND ((CASE WHEN {u}.role = 'admin' THEN
115 COALESCE(hpp.admin_read, 'implicit') <> 'implicit'
116 AND NOT EXISTS (SELECT 1 FROM project_members hpm
117 WHERE hpm.project_id = hp.id AND hpm.user_id = {u}.id)
118 AND NOT (hpp.admin_read = 'with_reason' AND EXISTS (
119 SELECT 1 FROM project_elevations hpe
120 WHERE hpe.project_id = hp.id AND hpe.user_id = {u}.id
121 AND hpe.ended_at IS NULL AND hpe.expires_at > now()))
122 ELSE
123 NOT EXISTS (SELECT 1 FROM project_members hpm
124 WHERE hpm.project_id = hp.id AND hpm.user_id = {u}.id)
125 END)
126 OR {unmet})),
127 '{{}}'::uuid[])",
128 unmet = crate::services::membership_conditions::unmet_sql(u, "hpp")
129 )
130}
131
132/// Fails with the regular "not found" error unless the actor may read the
133/// project group. `not_found` is the exact error a missing object gives.
134pub fn ensure_visible(
135 actor: &Actor,
136 project_id: Option<Uuid>,
137 not_found: AppError,
138) -> Result<(), AppError> {
139 if actor.can_see_project(project_id) {
140 Ok(())
141 } else {
142 Err(not_found)
143 }
144}
145
146pub fn not_found_item() -> AppError {
147 AppError::not_found("Item not found.")
148}
149
150pub fn not_found_revision() -> AppError {
151 AppError::not_found("Revision not found.")
152}
153
154pub fn not_found_file() -> AppError {
155 AppError::not_found("File not found.")
156}
157
158pub fn not_found_project() -> AppError {
159 AppError::not_found("Project not found.")
160}
161
162/// The error of a form field naming an unknown project group (also for a
163/// hidden one: its code and state are never told).
164pub fn unknown_project() -> AppError {
165 AppError::BadRequest("Unknown project.".into())
166}
167
168/// Text of a placeholder row that stands for a hidden item.
169pub const RESTRICTED_LABEL: &str = "restricted item";
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn item_guard_handles_items_without_group() {
177 assert_eq!(
178 item_visible("i", 3),
179 "(i.project_id IS NULL OR i.project_id <> ALL($3))"
180 );
181 assert!(!item_visible("i", 3).contains("NOT (")); // never NOT (x = ANY(..))
182 assert!(change_visible("c", 1, 2).contains("ai.project_id <> ALL($2)"));
183 assert!(request_visible("r", 1, 2).contains("$1"));
184 assert!(hidden_of_user("u").contains("'{}'::uuid[]"));
185 }
186}