Skip to main content

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}