1
use std::{path::Path, str::Lines};
2

            
3
use anyhow::Context;
4
use nix::unistd::Group;
5

            
6
use crate::core::{
7
    common::UnixUser,
8
    protocol::{
9
        CheckAuthorizationError,
10
        request_validation::{GroupDenylist, GroupNamePattern, validate_db_or_user_request},
11
    },
12
    types::DbOrUser,
13
};
14

            
15
pub async fn check_authorization(
16
    dbs_or_users: &[DbOrUser],
17
    unix_user: &UnixUser,
18
    group_denylist: &GroupDenylist,
19
) -> std::collections::BTreeMap<DbOrUser, Result<(), CheckAuthorizationError>> {
20
    dbs_or_users
21
        .iter()
22
        .cloned()
23
        .map(|db_or_user| {
24
            let result = validate_db_or_user_request(&db_or_user, unix_user, group_denylist)
25
                .map_err(CheckAuthorizationError);
26
            (db_or_user, result)
27
        })
28
        .collect()
29
}
30

            
31
/// Reads and parses a group denylist file.
32
///
33
/// The format of the denylist file is expected to be one group name or GID per line.
34
/// Lines starting with '#' are treated as comments and ignored.
35
/// Empty lines are also ignored.
36
///
37
/// Each line looks like one of the following:
38
/// - `gid:1001`
39
/// - `group:admins`
40
///
41
/// Note that the latter form supports wildcards `*` and `?`.
42
///
43
/// Non-wildcard group names are resolved to their GID immediately.
44
pub fn read_and_parse_group_denylist(denylist_path: &Path) -> anyhow::Result<GroupDenylist> {
45
    let content = std::fs::read_to_string(denylist_path)
46
        .context(format!("Failed to read denylist file at {denylist_path:?}"))?;
47

            
48
    let lines = content.lines();
49

            
50
    let groups = parse_group_denylist(denylist_path, lines);
51

            
52
    Ok(groups)
53
}
54

            
55
5
pub(crate) fn parse_group_denylist(denylist_path: &Path, lines: Lines) -> GroupDenylist {
56
5
    let mut groups = GroupDenylist::new();
57

            
58
13
    for (line_number, line) in lines.enumerate() {
59
13
        let trimmed_line = if let Some(comment_start) = line.find('#') {
60
4
            &line[..comment_start]
61
        } else {
62
9
            line
63
        }
64
13
        .trim();
65

            
66
13
        if trimmed_line.is_empty() {
67
3
            continue;
68
10
        }
69

            
70
10
        let parts: Vec<&str> = trimmed_line.splitn(2, ':').collect();
71
10
        if parts.len() != 2 {
72
1
            tracing::warn!(
73
                "Invalid format in denylist file at {:?} on line {}: {}",
74
                denylist_path,
75
                line_number + 1,
76
                line
77
            );
78
1
            continue;
79
9
        }
80

            
81
9
        match parts[0] {
82
9
            "gid" => {
83
3
                let gid: u32 = match parts[1].parse() {
84
1
                    Ok(gid) => gid,
85
2
                    Err(err) => {
86
2
                        tracing::warn!(
87
                            "Invalid GID '{}' in denylist file at {:?} on line {}: {}",
88
                            parts[1],
89
                            denylist_path,
90
                            line_number + 1,
91
                            err
92
                        );
93
2
                        continue;
94
                    }
95
                };
96
1
                let group = match Group::from_gid(nix::unistd::Gid::from_raw(gid)) {
97
1
                    Ok(Some(g)) => g,
98
                    Ok(None) => {
99
                        tracing::warn!(
100
                            "No group found for GID {} in denylist file at {:?} on line {}",
101
                            gid,
102
                            denylist_path,
103
                            line_number + 1
104
                        );
105
                        continue;
106
                    }
107
                    Err(err) => {
108
                        tracing::warn!(
109
                            "Failed to get group for GID {} in denylist file at {:?} on line {}: {}",
110
                            gid,
111
                            denylist_path,
112
                            line_number + 1,
113
                            err
114
                        );
115
                        continue;
116
                    }
117
                };
118

            
119
1
                groups.insert_gid(group.gid.as_raw());
120
            }
121
6
            "group" if parts[1].contains(['*', '?']) => {
122
4
                let pattern = GroupNamePattern::new(parts[1]);
123
4
                match pattern.to_regex() {
124
4
                    Ok(_) => groups.insert_name_pattern(pattern),
125
                    Err(err) => {
126
                        tracing::warn!(
127
                            "Invalid wildcard pattern '{}' in denylist file at {:?} on line {}: {}",
128
                            parts[1],
129
                            denylist_path,
130
                            line_number + 1,
131
                            err
132
                        );
133
                    }
134
                }
135
            }
136
2
            "group" => match Group::from_name(parts[1]) {
137
1
                Ok(Some(group)) => {
138
1
                    groups.insert_gid(group.gid.as_raw());
139
1
                }
140
                Ok(None) => {
141
1
                    tracing::warn!(
142
                        "No group found for name '{}' in denylist file at {:?} on line {}",
143
                        parts[1],
144
                        denylist_path,
145
                        line_number + 1
146
                    );
147
1
                    continue;
148
                }
149
                Err(err) => {
150
                    tracing::warn!(
151
                        "Failed to get group for name '{}' in denylist file at {:?} on line {}: {}",
152
                        parts[1],
153
                        denylist_path,
154
                        line_number + 1,
155
                        err
156
                    );
157
                }
158
            },
159
            _ => {
160
                tracing::warn!(
161
                    "Invalid prefix '{}' in denylist file at {:?} on line {}: {}",
162
                    parts[0],
163
                    denylist_path,
164
                    line_number + 1,
165
                    line
166
                );
167
                continue;
168
            }
169
        }
170
    }
171

            
172
5
    groups
173
5
}
174

            
175
#[cfg(test)]
176
mod tests {
177
    use indoc::indoc;
178

            
179
    use super::*;
180

            
181
13
    fn fake_group(name: &str, gid: u32) -> Group {
182
13
        Group {
183
13
            name: name.to_owned(),
184
13
            passwd: std::ffi::CString::default(),
185
13
            gid: nix::unistd::Gid::from_raw(gid),
186
13
            mem: Vec::new(),
187
13
        }
188
13
    }
189

            
190
    #[test]
191
1
    fn test_parse_group_denylist() {
192
1
        let denylist_content = indoc! {"
193
1
            # Valid entries
194
1
            gid:0 # This is usually the 'root' group
195
1
            group:root # This is also the 'root' group, should deduplicate
196
1

            
197
1
            # Invalid entries
198
1
            invalid_line
199
1
            gid:not_a_number
200
1
            group:nonexistent_group
201
1
        "};
202

            
203
1
        let lines = denylist_content.lines();
204
1
        let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
205

            
206
1
        assert_eq!(group_denylist.len(), 1);
207
1
        assert!(group_denylist.matches(&fake_group("root", 0)));
208
1
    }
209

            
210
    #[test]
211
1
    fn test_parse_group_denylist_wildcard() {
212
1
        let denylist_content = indoc! {"
213
1
            group:admin*
214
1
            group:svc-?db
215
1
        "};
216

            
217
1
        let lines = denylist_content.lines();
218
1
        let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
219

            
220
1
        assert_eq!(group_denylist.len(), 2);
221

            
222
1
        assert!(group_denylist.matches(&fake_group("admin", 100)));
223
1
        assert!(group_denylist.matches(&fake_group("admins", 101)));
224
1
        assert!(!group_denylist.matches(&fake_group("badmin", 102)));
225

            
226
1
        assert!(group_denylist.matches(&fake_group("svc-1db", 103)));
227
1
        assert!(!group_denylist.matches(&fake_group("svc-12db", 104)));
228
1
        assert!(!group_denylist.matches(&fake_group("other", 105)));
229
1
    }
230

            
231
    #[test]
232
1
    fn test_wildcards_not_supported_for_gid() {
233
1
        let denylist_content = indoc! {"
234
1
            gid:*
235
1
        "};
236

            
237
1
        let lines = denylist_content.lines();
238
1
        let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
239

            
240
1
        assert!(group_denylist.is_empty());
241
1
    }
242

            
243
    #[test]
244
1
    fn test_parse_group_denylist_wildcard_only_entry_star() {
245
1
        let denylist_content = indoc! {"
246
1
            group:*
247
1
        "};
248

            
249
1
        let lines = denylist_content.lines();
250
1
        let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
251

            
252
1
        assert_eq!(group_denylist.len(), 1);
253

            
254
        // `group:*` matches any group name, including empty and multi-character ones.
255
1
        assert!(group_denylist.matches(&fake_group("", 100)));
256
1
        assert!(group_denylist.matches(&fake_group("a", 101)));
257
1
        assert!(group_denylist.matches(&fake_group("anything", 102)));
258
1
    }
259

            
260
    #[test]
261
1
    fn test_parse_group_denylist_wildcard_only_entry_question_mark() {
262
1
        let denylist_content = indoc! {"
263
1
            group:?
264
1
        "};
265

            
266
1
        let lines = denylist_content.lines();
267
1
        let group_denylist = parse_group_denylist(Path::new("test_denylist"), lines);
268

            
269
1
        assert_eq!(group_denylist.len(), 1);
270

            
271
        // `group:?` matches any single-character group name only.
272
1
        assert!(!group_denylist.matches(&fake_group("", 103)));
273
1
        assert!(group_denylist.matches(&fake_group("a", 104)));
274
1
        assert!(!group_denylist.matches(&fake_group("ab", 105)));
275
1
    }
276
}